Compare commits

...

50 Commits

Author SHA1 Message Date
db3e5dd685 Don't bother testing tsx runtime
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-08-05 14:40:43 -04:00
b01212b76e Attempt to test tsx
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-26 14:24:08 -04:00
269322e5b2 Attempt to test tsx
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-26 13:45:39 -04:00
57e96db4ff Attempt to test tsx
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-26 13:39:35 -04:00
ec3d9c3179 Attempt to test tsx
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-26 13:22:34 -04:00
7aa71d4dae Attempt to test tsx
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-26 10:45:18 -04:00
8b44e250e2 Refactor again
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-25 11:47:44 -04:00
501f5e10d5 Misc refactoring
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-24 15:44:28 -04:00
46a0314ce6 Refactor Ansi module so that it works as expected
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-24 15:19:32 -04:00
d59900a895 Add Rust filetype, full tests for Option
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-24 14:58:42 -04:00
a7fcc982fe Test coverage for the FileType classes
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-24 09:02:29 -04:00
c31933ed9b Highlight character type separate from string type
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-23 14:21:57 -04:00
58490f1c51 Actually add file for new filetype
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-22 15:44:11 -04:00
d90d685ef2 Add C highlighting support
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-22 15:33:01 -04:00
6101132a15 Tweak highlighting for keywords
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-19 15:55:16 -04:00
21d26ede6c A lot of tweaks
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-19 15:31:27 -04:00
5b40d16999 More fun with colors
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-18 17:25:21 -04:00
0c13942aae Play with colors, add hecto highlighting fix
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-18 15:46:52 -04:00
ea00f76a62 Add render optimizations from hecto, fix rendering of multiline comments
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-18 13:41:45 -04:00
2b3be61933 Add demo code folder
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-17 16:23:53 -04:00
1a8d9f5469 Add operator highlighting, partially fix search
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-17 16:23:06 -04:00
65ff7e5b79 Mostly get multi-line comments highlighted (minus the last slash...)
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-17 12:08:16 -04:00
6d9d1113f2 More Filetype information, tweak highlighting colors slightly
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-17 10:57:06 -04:00
e84dfa9ba9 Highlight keywords
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-17 10:32:00 -04:00
359e739fe8 Highlight single-line comments, and refactor highlighting method
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-16 17:18:09 -04:00
32e3676b02 Handle escaped characters in strings
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-16 16:03:28 -04:00
b5856f063a Add string highlighting
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-16 15:57:41 -04:00
01b8535c5e Highlight numbers properly
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-16 11:17:45 -04:00
8c54ceb104 Add shell scripts to run without just, fix the issue with bun and tsx failing to exit
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-12 15:33:03 -04:00
1951425508 Refactor runtimes
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-12 10:58:23 -04:00
21ff71cc94 Fix a cursor position issue when searching forward
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-11 17:29:39 -04:00
2c21bf0c9b Use node apis for test setup, refactor a bunch of runtime stuff
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-11 17:27:49 -04:00
b2169cf54b Properly document the Option type
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-11 12:06:45 -04:00
d7bf8801c9 Put the important scripts package.json so they can be run without Just
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-10 16:32:28 -04:00
4be7be09a7 Fix some issues with line splitting/merging
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-10 16:12:39 -04:00
4436a8a783 Refactor stuff
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-10 12:11:27 -04:00
b3bddbb601 Refactor search to work like in hecto, albeit with some bugs with backwards searching
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-09 17:02:16 -04:00
e0e7849fe4 Clean up some types 2024-07-09 16:12:28 -04:00
88bf3da4e7 Use Tsx/node implementation for file/terminal_io in Bun runtime
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
2024-07-09 10:23:25 -04:00
0148561240 Partially fix search lockup for Bun
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-05 16:16:05 -04:00
8d2ba868b0 Add new runtime for tsx
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-05 15:51:30 -04:00
e656ad3112 Fix tests broken by document.row change
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-05 09:31:18 -04:00
1cfdeece60 Make document.row getter return an option instead of a nullable 2024-07-05 09:27:28 -04:00
090d6262c3 More Option refactoring, and some tests
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-03 19:09:04 -04:00
4313b923bf Refactor Option type to one implementation instead of two
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-03 17:49:15 -04:00
1b3e9d9796 Add Option type to remove the need to use null/undefined
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-03 16:13:29 -04:00
76eacd835f Add test for quit command
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-02 16:59:46 -04:00
cf80dce335 Increase test coverage
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-02 16:27:18 -04:00
82bcc72d21 Fix runtime loading
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
2024-07-02 14:50:21 -04:00
32f9ef3bba Revert "Mostly refactor prompt input (search/file saving) to use input loop shared with normal functionality"
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
This reverts commit 9afeed41cd.
2024-07-02 13:41:16 -04:00
55 changed files with 7271 additions and 1543 deletions

7
.gitignore vendored
View File

@ -335,8 +335,13 @@ $RECYCLE.BIN/
# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,vim,node,deno,macos,windows,linux # End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,vim,node,deno,macos,windows,linux
# Other editors
.nova/
.zed/
## Misc generated files ## Misc generated files
scroll.err scroll*.log
docs
deno.lock deno.lock
cov_profile/ cov_profile/
coverage/ coverage/

View File

@ -1,16 +1,30 @@
# Scroll # Scroll
Making a text editor in Typescript based on Kilo (Script + Kilo = Scroll). This Making a text editor in Typescript based on Kilo (Script + Kilo = Scroll). This
runs on [Bun](https://bun.sh/) (v1.0 or later) and [Deno](https://deno.com/) runs on
(v1.37 or later).
- [Bun](https://bun.sh/) (v1.0 or later)
- [Deno](https://deno.com/) (v1.37 or later)
- [TSX](https://tsx.is/) - this is a Typescript wrapper using NodeJS (v20 or
later)
To simplify running, I'm using [Just](https://github.com/casey/just). To simplify running, I'm using [Just](https://github.com/casey/just).
- Bun: `just bun-run [filename]` - Bun: `just bun-run [filename]`
- Deno: `just deno-run [filename]` - Deno: `just deno-run [filename]`
- TSX: `just tsx-run [filename]`
Alternatively, there are shell scripts for each runtime in the `bin` folder. So
you can run the editor by calling `./bin/deno.sh [filename]` without installing
Just.
Deno is generally used for dev tools, but each runtime should be functionally
equivalent running the text editor.
## Development Notes ## Development Notes
- Implementation is based on [Kilo](https://viewsourcecode.org/snaptoken/kilo/)
and [Hecto](https://archive.flenker.blog/hecto/)
- Runtime differences are adapted into a common interface - Runtime differences are adapted into a common interface
- Runtime implementations are in the `src/deno` and `src/bun` folders - Runtime implementations are in the `src/deno`, `src/bun`, `src/tsx` folders
- The main implementation is in `src/common` - The main implementation is in `src/common`

5
bin/bun.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
PARENT_DIR="$(dirname "$(realpath "$0")")"
SCROLL="$(realpath "${PARENT_DIR}/../src/scroll.ts")"
bun run "${SCROLL}" "$@"

5
bin/deno.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
PARENT_DIR="$(dirname "$(realpath "$0")")"
SCROLL="$(realpath "${PARENT_DIR}/../src/scroll.ts")"
deno run --allow-all --deny-hrtime "${SCROLL}" "$@"

6
bin/tsx.sh Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
PARENT_DIR="$(dirname "$(realpath "$0")")"
TSX="$(realpath "${PARENT_DIR}/../node_modules/.bin/tsx")"
SCROLL="$(realpath "${PARENT_DIR}/../src/scroll.ts")"
"${TSX}" "${SCROLL}" "$@";

View File

@ -4,6 +4,3 @@ deno test --allow-all --coverage=coverage
deno coverage coverage --lcov > coverage/coverage.lcov deno coverage coverage --lcov > coverage/coverage.lcov
genhtml -o coverage coverage/coverage.lcov genhtml -o coverage coverage/coverage.lcov
rm coverage/*.json rm coverage/*.json
open coverage/index.html

155
demo/colors.ts Normal file
View File

@ -0,0 +1,155 @@
/**
* This is a test file and a terminal color table program
*/
import Ansi, { AnsiColor, Ground } from '../src/common/ansi.ts';
// ----------------------------------------------------------------------------
// Display table of the 256 type color console escape codes
// ----------------------------------------------------------------------------
const addColor = (fore: string, back: string): string => {
let output = '';
output += fore;
output += back;
return output;
};
const padNum = (num: number): string =>
String(num).padStart(3, ' ').padEnd(5, ' ');
const colorBlock = (
start: number,
end: number,
block: (i: number) => [string, string],
): string => {
let output = '';
for (let i = start; i < end; i++) {
const [fg, bg] = block(i);
output += addColor(fg, bg);
output += padNum(i);
output += Ansi.ResetFormatting;
}
return output;
};
function print16colorTable(): void {
const drawRow = (start: number): string => {
let end = start + 8;
let blocks = [
colorBlock(
start,
end,
(i: number) => [Ansi.textFormat(i), Ansi.color.background.Black],
),
colorBlock(
start,
end,
(i: number) => [Ansi.textFormat(i), Ansi.color.background.BrightWhite],
),
];
start += 10;
end += 10;
blocks.push(
colorBlock(
start,
end,
(i: number) => [Ansi.color.Black, Ansi.textFormat(i)],
),
);
blocks.push(
colorBlock(
start,
end,
(i: number) => [Ansi.color.BrightWhite, Ansi.textFormat(i)],
),
);
return blocks.join(' '.repeat(5));
};
let colorTable = [
drawRow(30),
drawRow(90),
].join('\n');
colorTable += '\n';
console.log(colorTable);
}
function print256colorTable(): void {
let colorTable = '';
// deno-fmt-ignore
const breaks = [
7, 15,
21, 27, 33, 39, 45, 51,
57, 63, 69, 75, 81, 87,
93, 99, 105, 111, 117, 123,
129, 135, 141, 147, 153, 159,
165, 171, 177, 183, 189, 195,
201, 207, 213, 219, 225, 231,
237, 243, 249, 255,
];
const doubleBreaks = [15, 51, 87, 123, 159, 195, 231, 255];
breaks.forEach((line, n) => {
const start = (n > 0) ? breaks[n - 1] + 1 : 0;
const end = line + 1;
const blocks = [
colorBlock(
start,
end,
(
i: number,
) => [Ansi.color256(i, Ground.Fore), Ansi.color.background.Black],
),
colorBlock(
start,
end,
(
i: number,
) => [Ansi.color256(i, Ground.Fore), Ansi.color.background.BrightWhite],
),
colorBlock(
start,
end,
(i: number) => [Ansi.color.Black, Ansi.color256(i, Ground.Back)],
),
colorBlock(
start,
end,
(i: number) => [Ansi.color.BrightWhite, Ansi.color256(i, Ground.Back)],
),
];
colorTable += blocks.join(' '.repeat(5));
colorTable += '\n';
if (doubleBreaks.includes(line)) {
colorTable += '\n';
}
});
console.log(colorTable);
}
print16colorTable();
print256colorTable();
/**
* Test code for highlighting
*/
const decimal: number[] = [0, 117];
const bigDecimal = 123456789123456789n;
const octal: number[] = [0o15, 0o1];
const bigOctal = 0o777777777777n;
const hexadecimal: number[] = [0x1123, 0x00111];
const bigHex = 0x123456789ABCDEFn;
const binary: number[] = [0b11, 0b0011];
const bigBinary = 0b11101001010101010101n;

1686
demo/editor.rs Normal file

File diff suppressed because it is too large Load Diff

1468
demo/kilo.c Normal file

File diff suppressed because it is too large Load Diff

271
demo/test.css Normal file
View File

@ -0,0 +1,271 @@
/* -----------------------------------------------------------------------------
CSS loading icon
------------------------------------------------------------------------------*/
.cssload-loader {
position: relative;
left: calc(50% - 31px);
width: 62px;
height: 62px;
border-radius: 50%;
perspective: 780px;
}
.cssload-inner {
position: absolute;
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 50%;
}
.cssload-inner.cssload-one {
left: 0%;
top: 0%;
animation: cssload-rotate-one 1.15s linear infinite;
border-bottom: 3px solid rgb(0, 0, 0);
}
.cssload-inner.cssload-two {
right: 0%;
top: 0%;
animation: cssload-rotate-two 1.15s linear infinite;
border-right: 3px solid rgb(0, 0, 0);
}
.cssload-inner.cssload-three {
right: 0%;
bottom: 0%;
animation: cssload-rotate-three 1.15s linear infinite;
border-top: 3px solid rgb(0, 0, 0);
}
@keyframes cssload-rotate-one {
0% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg);
}
}
@keyframes cssload-rotate-two {
0% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg);
}
100% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg);
}
}
@keyframes cssload-rotate-three {
0% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg);
}
}
/* ----------------------------------------------------------------------------
Loading overlay
-----------------------------------------------------------------------------*/
#loading-shadow {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 500;
}
#loading-shadow .loading-wrapper {
position: fixed;
z-index: 501;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#loading-shadow .loading-content {
position: relative;
color: #fff
}
.loading-content .cssload-inner.cssload-one,
.loading-content .cssload-inner.cssload-two,
.loading-content .cssload-inner.cssload-three {
border-color: #fff
}
/* ----------------------------------------------------------------------------
CSS Tabs
-----------------------------------------------------------------------------*/
.tabs {
display: inline-block;
display: flex;
flex-wrap: wrap;
background: #efefef;
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin-top: 1.5em;
}
.tabs > label {
border: 1px solid #e5e5e5;
width: 100%;
padding: 20px 30px;
background: #e5e5e5;
cursor: pointer;
font-weight: bold;
font-size: 18px;
color: #7f7f7f;
transition: background 0.1s, color 0.1s;
/* margin-left: 4em; */
}
.tabs > label:hover {
background: #d8d8d8;
}
.tabs > label:active {
background: #ccc;
}
.tabs > [type=radio]:focus + label {
box-shadow: inset 0px 0px 0px 3px #2aa1c0;
z-index: 1;
}
.tabs > [type=radio] {
position: absolute;
opacity: 0;
}
.tabs > [type=radio]:checked + label {
border-bottom: 1px solid #fff;
background: #fff;
color: #000;
}
.tabs > [type=radio]:checked + label + .content {
border: 1px solid #e5e5e5;
border-top: 0;
display: block;
padding: 15px;
background: #fff;
width: 100%;
margin: 0 auto;
overflow: auto;
/* text-align: center; */
}
.tabs .content, .single-tab {
display: none;
max-height: 950px;
border: 1px solid #e5e5e5;
border-top: 0;
padding: 15px;
background: #fff;
width: 100%;
margin: 0 auto;
overflow: auto;
}
.single-tab {
display: block;
border: 1px solid #e5e5e5;
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin-top: 1.5em;
}
.tabs .content.full-height, .single-tab.full-height {
max-height: none;
}
@media (min-width: 800px) {
.tabs > label {
width: auto;
}
.tabs .content {
order: 99;
}
}
/* ---------------------------------------------------------------------------
Vertical Tabs
----------------------------------------------------------------------------*/
.vertical-tabs {
border: 1px solid #e5e5e5;
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin: 0 auto;
position: relative;
width: 100%;
}
.vertical-tabs input[type="radio"] {
position: absolute;
opacity: 0;
}
.vertical-tabs .tab {
align-items: center;
display: inline-block;
display: flex;
flex-wrap: nowrap;
}
.vertical-tabs .tab label {
align-items: center;
background: #e5e5e5;
border: 1px solid #e5e5e5;
color: #7f7f7f;
cursor: pointer;
font-size: 18px;
font-weight: bold;
padding: 0 20px;
width: 28%;
}
.vertical-tabs .tab label:hover {
background: #d8d8d8;
}
.vertical-tabs .tab label:active {
background: #ccc;
}
.vertical-tabs .tab .content {
display: none;
border: 1px solid #e5e5e5;
border-left: 0;
border-right: 0;
max-height: 950px;
overflow: auto;
}
.vertical-tabs .tab .content.full-height {
max-height: none;
}
.vertical-tabs [type=radio]:checked + label {
border: 0;
background: #fff;
color: #000;
width: 38%;
}
.vertical-tabs [type=radio]:focus + label {
box-shadow: inset 0px 0px 0px 3px #2aa1c0;
z-index: 1;
}
.vertical-tabs [type=radio]:checked ~ .content {
display: block;
}

View File

@ -13,5 +13,6 @@
"semiColons": true, "semiColons": true,
"singleQuote": true "singleQuote": true
}, },
"nodeModulesDir": true "nodeModulesDir": true,
"exclude": ["src/bun/"]
} }

View File

@ -3,31 +3,41 @@ default:
@just --list @just --list
# Test coverage # Test coverage
coverage: bun-test deno-coverage coverage: deno-coverage bun-coverage
# Generate test coverage and open report in default browser
open-coverage: coverage
open coverage/index.html
# Typescript checking # Typescript checking
check: deno-check bun-check check: deno-check bun-check
# Generate source docs
docs: docs:
deno doc --html --unstable-ffi --name="Scroll" ./src/scroll.ts ./src/common/mod.ts ./src/deno/mod.ts ./src/bun/mod.ts deno doc --html --name="Scroll" ./src/common/*.ts ./src/common/**/*.ts
# Generate source docs and open in default browser
open-docs: docs
open docs/all_symbols.html
# Reformat the code # Reformat the code
fmt: fmt:
deno fmt deno fmt
# Run tests with all the runtimes # Run tests with all the runtimes
test: deno-test bun-test test: deno-test tsx-test bun-test
# Run all code-quality related tasks # Run all code-quality related tasks
quality: check test quality: check test
# Clean up any generated files # Clean up any generated files
clean: clean:
rm -f test.file
rm -rf .deno-cover rm -rf .deno-cover
rm -rf coverage rm -rf coverage
rm -rf docs rm -rf docs
rm -f scroll.log rm -f scroll*.log
rm -f scroll.err rm -f test.file
rm -f tsconfig.tsbuildinfo rm -f tsconfig.tsbuildinfo
########################################################################################## ##########################################################################################
@ -36,15 +46,19 @@ clean:
# Check code with actual Typescript compiler # Check code with actual Typescript compiler
bun-check: bun-check:
bunx tsc bun run bun-check
# Test with bun # Test with bun
bun-test: bun-test:
bun test --coverage bun run bun-test
# CLI test coverage report
bun-coverage:
bun run bun-coverage
# Run with bun # Run with bun
bun-run file="": bun-run file="":
bun run ./src/scroll.ts {{file}} bun run bun-run {{file}}
########################################################################################## ##########################################################################################
# Deno-specific commands # Deno-specific commands
@ -52,17 +66,35 @@ bun-run file="":
# Lint code and check types # Lint code and check types
deno-check: deno-check:
deno lint deno task deno-lint
deno check --unstable-ffi --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts deno task deno-check
# Test with deno # Test with deno
deno-test: deno-test:
deno test --allow-all --unstable-ffi deno task deno-test
# Create test coverage report with deno # Create test coverage report with deno
deno-coverage: deno-coverage:
./coverage.sh deno task deno-coverage
# Run with deno # Run with deno
deno-run file="": deno-run file="":
deno run --allow-all --allow-ffi --deny-hrtime --unstable-ffi ./src/scroll.ts {{file}} deno task deno-run {{file}}
##########################################################################################
# tsx(Node JS)-specific commands
##########################################################################################
# Check code with actual Typescript compiler
tsx-check:
npm run tsx-check
# Test with tsx (NodeJS)
tsx-test:
npm run tsx-test
# Run with tsx (NodeJS)
tsx-run file="":
npm run tsx-run {{file}}

View File

@ -1,6 +1,24 @@
{ {
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"bun-types": "^1.0.11" "@types/node": "*",
} "bun-types": "*",
"typescript": "^5.5",
"tsx": "*"
},
"scripts": {
"bun-check": "bunx tsc",
"bun-coverage": "bun test --coverage",
"bun-run": "bun run ./src/scroll.ts",
"bun-test": "bun test",
"deno-lint": "deno lint",
"deno-check": "deno check --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts ./src/tsx/*.ts",
"deno-coverage": "./coverage.sh",
"deno-run": "deno run --allow-all --deny-hrtime ./src/scroll.ts",
"deno-test": "deno test --allow-all",
"tsx-check": "npx tsc",
"tsx-run": "tsx ./src/scroll.ts",
"tsx-test": "tsx --test './src/common/all_test.ts'"
},
"type": "module"
} }

View File

@ -1,20 +0,0 @@
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
import { IFileIO } from '../common/runtime.ts';
import { appendFile } from 'node:fs/promises';
const BunFileIO: IFileIO = {
openFile: async (path: string): Promise<string> => {
const file = await globalThis.Bun.file(path);
return await file.text();
},
appendFile: async function (path: string, contents: string): Promise<void> {
return await appendFile(path, contents);
},
saveFile: async function (path: string, contents: string): Promise<void> {
await globalThis.Bun.write(path, contents);
return;
},
};
export default BunFileIO;

View File

@ -1,25 +1,14 @@
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
/** /**
* The main entrypoint when using Bun as the runtime * The main entrypoint when using Bun as the runtime
*/ */
import { CommonRuntime, IRuntime, RunTimeType } from '../common/runtime.ts';
import { IRuntime, RunTimeType } from '../common/runtime.ts'; /**
import BunTerminalIO from './terminal_io.ts'; * The Bun Runtime implementation
import BunFileIO from './file_io.ts'; */
import * as process from 'node:process';
const BunRuntime: IRuntime = { const BunRuntime: IRuntime = {
...CommonRuntime,
name: RunTimeType.Bun, name: RunTimeType.Bun,
file: BunFileIO,
term: BunTerminalIO,
onEvent: (eventName: string, handler) => process.on(eventName, handler),
onExit: (cb: () => void): void => {
process.on('beforeExit', cb);
process.on('exit', cb);
process.on('SIGINT', cb);
},
exit: (code?: number) => process.exit(code),
}; };
export default BunRuntime; export default BunRuntime;

View File

@ -1,90 +0,0 @@
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
/**
* Wrap the runtime-specific hook into stdin
*/
import process from 'node:process';
import Ansi from '../common/ansi.ts';
import { defaultTerminalSize } from '../common/config.ts';
import { readKey } from '../common/fns.ts';
import { ITerminal, ITerminalSize } from '../common/types.ts';
const encoder = new TextEncoder();
async function _getTerminalSizeFromAnsi(): Promise<ITerminalSize> {
// Tell the cursor to move to Row 999 and Column 999
// Since this command specifically doesn't go off the screen
// When we ask where the cursor is, we should get the size of the screen
await BunTerminalIO.writeStdout(
Ansi.moveCursorForward(999) + Ansi.moveCursorDown(999),
);
// Ask where the cursor is
await BunTerminalIO.writeStdout(Ansi.GetCursorLocation);
// Get the first chunk from stdin
// The response is \x1b[(rows);(cols)R..
const chunk = await BunTerminalIO.readStdinRaw();
if (chunk === null) {
return defaultTerminalSize;
}
const rawCode = (new TextDecoder()).decode(chunk);
const res = rawCode.trim().replace(/^.\[([0-9]+;[0-9]+)R$/, '$1');
const [srows, scols] = res.split(';');
const rows = parseInt(srows, 10) ?? 24;
const cols = parseInt(scols, 10) ?? 80;
// Clear the screen
await BunTerminalIO.writeStdout(Ansi.ClearScreen + Ansi.ResetCursor);
return {
rows,
cols,
};
}
const BunTerminalIO: ITerminal = {
// Deno only returns arguments passed to the script, so
// remove the bun runtime executable, and entry script arguments
// to have consistent argument lists
argv: (Bun.argv.length > 2) ? Bun.argv.slice(2) : [],
inputLoop: async function* inputLoop() {
// for await (const chunk of Bun.stdin.stream()) {
// yield chunk;
// }
//
// return null;
for await (const chunk of process.stdin) {
yield encoder.encode(chunk);
}
return null;
},
/**
* Get the size of the terminal window via ANSI codes
* @see https://viewsourcecode.org/snaptoken/kilo/03.rawInputAndOutput.html#window-size-the-hard-way
*/
getTerminalSize: function getTerminalSize(): Promise<ITerminalSize> {
const [cols, rows] = process.stdout.getWindowSize();
return Promise.resolve({
rows,
cols,
});
},
readStdin: async function (): Promise<string | null> {
const raw = await BunTerminalIO.readStdinRaw();
return readKey(raw ?? new Uint8Array(0));
},
readStdinRaw: async function (): Promise<Uint8Array | null> {
const chunk = await BunTerminalIO.inputLoop().next();
return chunk.value ?? null;
},
writeStdout: async function write(s: string): Promise<void> {
const buffer = encoder.encode(s);
await Bun.write(Bun.stdout, buffer);
},
};
export default BunTerminalIO;

View File

@ -1,35 +1,19 @@
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
/** /**
* Adapt the bun test interface to the shared testing interface * Adapt the bun test interface to the shared testing interface
*/ */
import { describe, expect, test } from 'bun:test'; import { describe, test } from 'bun:test';
import { ITestBase } from '../common/types.ts'; import AbstractTestBase from '../common/runtime/test_base.ts';
class BunTestBase extends AbstractTestBase {
export function testSuite(testObj: any) { public static testSuite(testObj: any): void {
Object.keys(testObj).forEach((group) => { Object.keys(testObj).forEach((group) => {
describe(group, () => { describe(group, () => {
const groupObj = testObj[group]; const groupObj = testObj[group];
Object.keys(groupObj).forEach((testName) => { Object.keys(groupObj).forEach((testName) => {
test(testName, groupObj[testName]); test(testName, groupObj[testName]);
});
}); });
}); });
}); }
} }
const BunTestBase: ITestBase = {
assertEquals: (actual: unknown, expected: unknown) =>
expect(actual).toEqual(expected),
assertExists: (actual: unknown) => expect(actual).toBeDefined(),
assertFalse: (actual: boolean) => expect(actual).toBe(false),
assertInstanceOf: (actual: unknown, expectedType: any) =>
expect(actual).toBeInstanceOf(expectedType),
assertNotEquals: (actual: unknown, expected: unknown) =>
expect(actual).not.toBe(expected),
assertNull: (actual: unknown) => expect(actual).toBeNull(),
assertStrictEquals: (actual: unknown, expected: unknown) =>
expect(actual).toBe(expected),
assertTrue: (actual: boolean) => expect(actual).toBe(true),
testSuite,
};
export default BunTestBase; export default BunTestBase;

View File

@ -2,270 +2,41 @@ import Ansi, * as _Ansi from './ansi.ts';
import Buffer from './buffer.ts'; import Buffer from './buffer.ts';
import Document from './document.ts'; import Document from './document.ts';
import Editor from './editor.ts'; import Editor from './editor.ts';
import { FileLang } from './filetype.ts';
import Option, { None, Some } from './option.ts';
import Position from './position.ts'; import Position from './position.ts';
import Row from './row.ts'; import Row from './row.ts';
import FileType, * as FT from './filetype.ts';
import * as Fn from './fns.ts'; import * as Fn from './fns.ts';
import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts'; import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts';
import { getTestRunner } from './runtime.ts'; import { getTestRunner } from './runtime.ts';
import { HighlightType, SearchDirection } from './types.ts';
import fs from 'node:fs';
const { const {
assertEquals, assertEquals,
assertEquivalent,
assertExists, assertExists,
assertInstanceOf, assertInstanceOf,
assertNotEquals, assertNotEquals,
assertNull,
assertFalse, assertFalse,
assertTrue, assertTrue,
assertSome,
assertNone,
testSuite, testSuite,
} = await getTestRunner(); } = await getTestRunner();
// ---------------------------------------------------------------------------- const THIS_FILE = './src/common/all_test.ts';
const KILO_FILE = './demo/kilo.c';
const ANSITest = () => {
const { AnsiColor, Ground } = _Ansi;
return {
'color()': () => {
assertEquals(Ansi.color(AnsiColor.FgBlue), '\x1b[34m');
},
'color256()': () => {
assertEquals(Ansi.color256(128, Ground.Back), '\x1b[48;5;128m');
assertEquals(Ansi.color256(128, Ground.Fore), '\x1b[38;5;128m');
},
'rgb()': () => {
assertEquals(Ansi.rgb(32, 64, 128, Ground.Back), '\x1b[48;2;32;64;128m');
assertEquals(Ansi.rgb(32, 64, 128, Ground.Fore), '\x1b[38;2;32;64;128m');
},
'moveCursor()': () => {
assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H');
},
'moveCursorForward()': () => {
assertEquals(Ansi.moveCursorForward(2), '\x1b[2C');
},
'moveCursorDown()': () => {
assertEquals(Ansi.moveCursorDown(7), '\x1b[7B');
},
};
};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helper Function Tests
const BufferTest = {
'new Buffer': () => {
const b = new Buffer();
assertInstanceOf(b, Buffer);
assertEquals(b.strlen(), 0);
},
'.appendLine': () => {
const b = new Buffer();
// Carriage return and line feed
b.appendLine();
assertEquals(b.strlen(), 2);
b.clear();
assertEquals(b.strlen(), 0);
b.appendLine('foo');
assertEquals(b.strlen(), 5);
},
'.append': () => {
const b = new Buffer();
b.append('foobar');
assertEquals(b.strlen(), 6);
b.clear();
b.append('foobar', 3);
assertEquals(b.strlen(), 3);
},
'.flush': async () => {
const b = new Buffer();
b.appendLine('foobarbaz' + Ansi.ClearLine);
assertEquals(b.strlen(), 14);
await b.flush();
assertEquals(b.strlen(), 0);
},
};
// ----------------------------------------------------------------------------
const DocumentTest = {
'.default': () => {
const doc = Document.default();
assertEquals(doc.numRows, 0);
assertTrue(doc.isEmpty());
assertEquals(doc.row(0), null);
},
'.insertRow': () => {
const doc = Document.default();
doc.insertRow(undefined, 'foobar');
assertEquals(doc.numRows, 1);
assertFalse(doc.isEmpty());
assertInstanceOf(doc.row(0), Row);
},
'.insert': () => {
const doc = Document.default();
assertFalse(doc.dirty);
doc.insert(Position.at(0, 0), 'foobar');
assertEquals(doc.numRows, 1);
assertTrue(doc.dirty);
doc.insert(Position.at(2, 0), 'baz');
assertEquals(doc.numRows, 1);
assertTrue(doc.dirty);
doc.insert(Position.at(9, 0), 'buzz');
assertEquals(doc.numRows, 1);
assertTrue(doc.dirty);
const row0 = doc.row(0);
assertEquals(row0?.toString(), 'foobazbarbuzz');
assertEquals(row0?.rstring(), 'foobazbarbuzz');
assertEquals(row0?.rsize, 13);
doc.insert(Position.at(0, 1), 'Lorem Ipsum');
assertEquals(doc.numRows, 2);
assertTrue(doc.dirty);
},
'.delete': () => {
const doc = Document.default();
doc.insert(Position.default(), 'foobar');
doc.delete(Position.at(3, 0));
assertEquals(doc.row(0)?.toString(), 'fooar');
},
};
// ----------------------------------------------------------------------------
const EditorTest = {
'new Editor': () => {
const e = new Editor(defaultTerminalSize);
assertInstanceOf(e, Editor);
},
};
// ----------------------------------------------------------------------------
const PositionTest = {
'.default': () => {
const p = Position.default();
assertEquals(p.x, 0);
assertEquals(p.y, 0);
},
'.at': () => {
const p = Position.at(5, 7);
assertEquals(p.x, 5);
assertEquals(p.y, 7);
},
'.from': () => {
const p1 = Position.at(1, 2);
const p2 = Position.from(p1);
p1.x = 2;
p1.y = 4;
assertEquals(p1.x, 2);
assertEquals(p1.y, 4);
assertEquals(p2.x, 1);
assertEquals(p2.y, 2);
},
};
// ----------------------------------------------------------------------------
const RowTest = {
'.default': () => {
const row = Row.default();
assertEquals(row.toString(), '');
},
'.from': () => {
// From string
const row = Row.from('xyz');
assertEquals(row.toString(), 'xyz');
// From existing Row
assertEquals(Row.from(row).toString(), row.toString());
// From 'chars'
assertEquals(Row.from(['😺', '😸', '😹']).toString(), '😺😸😹');
},
'.append': () => {
const row = Row.from('foo');
row.append('bar');
assertEquals(row.toString(), 'foobar');
},
'.delete': () => {
const row = Row.from('foof');
row.delete(3);
assertEquals(row.toString(), 'foo');
row.delete(4);
assertEquals(row.toString(), 'foo');
},
'.split': () => {
// When you split a row, it's from the cursor position
// (Kind of like if the string were one-indexed)
const row = Row.from('foobar');
const row2 = Row.from('bar');
assertEquals(row.split(3).toString(), row2.toString());
},
'.find': () => {
const normalRow = Row.from('For whom the bell tolls');
assertEquals(normalRow.find('who'), 4);
assertNull(normalRow.find('foo'));
const emojiRow = Row.from('😺😸😹');
assertEquals(emojiRow.find('😹'), 2);
assertNull(emojiRow.find('🤰🏼'));
},
'.byteIndexToCharIndex': () => {
// Each 'character' is two bytes
const row = Row.from('😺😸😹👨‍👩‍👧‍👦');
assertEquals(row.byteIndexToCharIndex(4), 2);
assertEquals(row.byteIndexToCharIndex(2), 1);
assertEquals(row.byteIndexToCharIndex(0), 0);
// Return count on nonsense index
assertEquals(Fn.strlen(row.toString()), 10);
assertEquals(row.byteIndexToCharIndex(72), 10);
const row2 = Row.from('foobar');
assertEquals(row2.byteIndexToCharIndex(2), 2);
},
'.charIndexToByteIndex': () => {
// Each 'character' is two bytes
const row = Row.from('😺😸😹👨‍👩‍👧‍👦');
assertEquals(row.charIndexToByteIndex(2), 4);
assertEquals(row.charIndexToByteIndex(1), 2);
assertEquals(row.charIndexToByteIndex(0), 0);
},
'.cxToRx, .rxToCx': () => {
const row = Row.from('foo\tbar\tbaz');
row.update();
assertNotEquals(row.chars, row.rchars);
assertNotEquals(row.size, row.rsize);
assertEquals(row.size, 11);
assertEquals(row.rsize, row.size + (SCROLL_TAB_SIZE * 2) - 2);
const cx = 11;
const aRx = row.cxToRx(cx);
const rx = 11;
const aCx = row.rxToCx(aRx);
assertEquals(aCx, cx);
assertEquals(aRx, rx);
},
};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
const fnTest = () => { const fnTest = () => {
const { const {
some,
none,
arrayInsert, arrayInsert,
noop, noop,
posSub, posSub,
@ -279,47 +50,49 @@ const fnTest = () => {
isAsciiDigit, isAsciiDigit,
strlen, strlen,
truncate, truncate,
highlightToColor,
} = Fn; } = Fn;
return { return {
'some()': () => {
assertFalse(some(null));
assertFalse(some(void 0));
assertFalse(some(undefined));
assertTrue(some(0));
assertTrue(some(false));
},
'none()': () => {
assertTrue(none(null));
assertTrue(none(void 0));
assertTrue(none(undefined));
assertFalse(none(0));
assertFalse(none(false));
},
'arrayInsert() strings': () => { 'arrayInsert() strings': () => {
const a = ['😺', '😸', '😹']; const a = ['😺', '😸', '😹'];
const b = arrayInsert(a, 1, 'x'); const b = arrayInsert(a, 1, 'x');
const c = ['😺', 'x', '😸', '😹']; const c = ['😺', 'x', '😸', '😹'];
assertEquals(b, c); assertEquivalent(b, c);
const d = arrayInsert(c, 17, 'y'); const d = arrayInsert(c, 17, 'y');
const e = ['😺', 'x', '😸', '😹', 'y']; const e = ['😺', 'x', '😸', '😹', 'y'];
assertEquals(d, e); assertEquivalent(d, e);
assertEquals(arrayInsert([], 0, 'foo'), ['foo']); assertEquivalent(arrayInsert([], 0, 'foo'), ['foo']);
}, },
'arrayInsert() numbers': () => { 'arrayInsert() numbers': () => {
const a = [1, 3, 5]; const a = [1, 3, 5];
const b = [1, 3, 4, 5]; const b = [1, 3, 4, 5];
assertEquals(arrayInsert(a, 2, 4), b); assertEquivalent(arrayInsert(a, 2, 4), b);
const c = [1, 2, 3, 4, 5]; const c = [1, 2, 3, 4, 5];
assertEquals(arrayInsert(b, 1, 2), c); assertEquivalent(arrayInsert(b, 1, 2), c);
}, },
'noop fn': () => { 'noop fn': () => {
assertExists(noop); assertExists(noop);
assertEquals(noop(), undefined); assertEquals(noop(), undefined);
}, },
'highlightToColor()': () => {
[
HighlightType.Number,
HighlightType.Match,
HighlightType.String,
HighlightType.SingleLineComment,
HighlightType.MultiLineComment,
HighlightType.Keyword1,
HighlightType.Keyword2,
HighlightType.Operator,
HighlightType.None,
].forEach((type) => {
assertTrue(highlightToColor(type).length > 0);
});
},
'posSub()': () => { 'posSub()': () => {
assertEquals(posSub(14, 15), 0); assertEquals(posSub(14, 15), 0);
assertEquals(posSub(15, 1), 14); assertEquals(posSub(15, 1), 14);
@ -340,7 +113,7 @@ const fnTest = () => {
assertEquals(ord('a'), 97); assertEquals(ord('a'), 97);
}, },
'strChars() properly splits strings into unicode characters': () => { 'strChars() properly splits strings into unicode characters': () => {
assertEquals(strChars('😺😸😹'), ['😺', '😸', '😹']); assertEquivalent(strChars('😺😸😹'), ['😺', '😸', '😹']);
}, },
'ctrlKey()': () => { 'ctrlKey()': () => {
const ctrl_a = ctrlKey('a'); const ctrl_a = ctrlKey('a');
@ -391,8 +164,6 @@ const fnTest = () => {
}; };
}; };
// ----------------------------------------------------------------------------
const readKeyTest = () => { const readKeyTest = () => {
const { KeyCommand } = _Ansi; const { KeyCommand } = _Ansi;
const { readKey, ctrlKey } = Fn; const { readKey, ctrlKey } = Fn;
@ -445,17 +216,502 @@ const readKeyTest = () => {
}; };
}; };
// ----------------------------------------------------------------------------
// Tests by module
// ----------------------------------------------------------------------------
const ANSITest = () => {
const { Ground } = _Ansi;
return {
'color()': () => {
assertEquals(Ansi.color.Blue, '\x1b[34m');
},
'color256()': () => {
assertEquals(Ansi.color256(128, Ground.Back), '\x1b[48;5;128m');
assertEquals(Ansi.color256(128, Ground.Fore), '\x1b[38;5;128m');
},
'rgb()': () => {
assertEquals(Ansi.rgb(32, 64, 128, Ground.Back), '\x1b[48;2;32;64;128m');
assertEquals(Ansi.rgb(32, 64, 128, Ground.Fore), '\x1b[38;2;32;64;128m');
},
'moveCursor()': () => {
assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H');
},
'moveCursorForward()': () => {
assertEquals(Ansi.moveCursorForward(2), '\x1b[2C');
},
'moveCursorDown()': () => {
assertEquals(Ansi.moveCursorDown(7), '\x1b[7B');
},
};
};
// ----------------------------------------------------------------------------
const BufferTest = {
'new Buffer': () => {
const b = Buffer.default();
assertInstanceOf(b, Buffer);
assertEquals(b.strlen(), 0);
},
'.appendLine': () => {
const b = Buffer.default();
// Carriage return and line feed
b.appendLine();
assertEquals(b.strlen(), 2);
b.clear();
assertEquals(b.strlen(), 0);
b.appendLine('foo');
assertEquals(b.strlen(), 5);
},
'.append': () => {
const b = Buffer.default();
b.append('foobar');
assertEquals(b.strlen(), 6);
b.clear();
b.append('foobar', 3);
assertEquals(b.strlen(), 3);
},
'.flush': async () => {
const b = Buffer.default();
b.appendLine('foobarbaz' + Ansi.ClearLine);
assertEquals(b.strlen(), 14);
await b.flush();
assertEquals(b.strlen(), 0);
},
};
// ----------------------------------------------------------------------------
const DocumentTest = {
'.default': () => {
const doc = Document.default();
assertEquals(doc.numRows, 0);
assertTrue(doc.isEmpty());
assertEquivalent(doc.row(0), None);
},
'.open': async () => {
const oldDoc = Document.default();
oldDoc.insert(Position.default(), 'foobarbaz');
assertTrue(oldDoc.dirty);
assertEquals(oldDoc.numRows, 1);
const doc = await oldDoc.open(THIS_FILE);
assertEquals(FileLang.TypeScript, doc.fileType);
assertFalse(doc.dirty);
assertFalse(doc.isEmpty());
assertTrue(doc.numRows > 1);
},
'.save': async () => {
const doc = await Document.default().open(THIS_FILE);
doc.insertNewline(Position.default());
assertTrue(doc.dirty);
await doc.save('test.file');
fs.rm('test.file', (err: any) => {
assertNone(Option.from(err));
});
assertFalse(doc.dirty);
},
'.find': async () => {
const doc = await Document.default().open(KILO_FILE);
// First search forward from the beginning of the file
const query1 = doc.find(
'editor',
Position.default(),
SearchDirection.Forward,
);
assertTrue(query1.isSome());
const pos1 = query1.unwrap();
assertEquivalent(pos1, Position.at(5, 27));
// Now search backwards from line 400
const query2 = doc.find(
'realloc',
Position.at(44, 400),
SearchDirection.Backward,
);
assertTrue(query2.isSome());
const pos2 = query2.unwrap();
assertEquivalent(pos2, Position.at(11, 330));
// And backwards again
const query3 = doc.find(
'editor',
Position.from(pos2),
SearchDirection.Backward,
);
assertTrue(query3.isSome());
const pos3 = query3.unwrap();
assertEquivalent(pos3, Position.at(5, 328));
},
'.find - empty result': () => {
const doc = Document.default();
doc.insertNewline(Position.default());
const query = doc.find('foo', Position.default(), SearchDirection.Forward);
assertNone(query);
const query2 = doc.find('bar', Position.at(0, 5), SearchDirection.Forward);
assertNone(query2);
},
'.insert': () => {
const doc = Document.default();
assertFalse(doc.dirty);
doc.insert(Position.at(0, 0), 'foobar');
assertEquals(doc.numRows, 1);
assertTrue(doc.dirty);
doc.insert(Position.at(2, 0), 'baz');
assertEquals(doc.numRows, 1);
assertTrue(doc.dirty);
doc.insert(Position.at(9, 0), 'buzz');
assertEquals(doc.numRows, 1);
assertTrue(doc.dirty);
// Update row
doc.highlight(None, None);
const row0 = doc.row(0).unwrap();
assertEquals(row0.toString(), 'foobazbarbuzz');
assertEquals(row0.rstring(), 'foobazbarbuzz');
assertEquals(row0.rsize, 13);
doc.insert(Position.at(0, 1), 'Lorem Ipsum');
assertEquals(doc.numRows, 2);
assertTrue(doc.dirty);
},
'.insertNewline': () => {
// Invalid insert location
const doc = Document.default();
doc.insertNewline(Position.at(0, 3));
assertFalse(doc.dirty);
assertTrue(doc.isEmpty());
// Add new empty row
const doc2 = Document.default();
doc2.insertNewline(Position.default());
assertTrue(doc2.dirty);
assertFalse(doc2.isEmpty());
// Split an existing line
const doc3 = Document.default();
doc3.insert(Position.default(), 'foobar');
doc3.insertNewline(Position.at(3, 0));
assertEquals(doc3.numRows, 2);
assertEquals(doc3.row(0).unwrap().toString(), 'foo');
assertEquals(doc3.row(1).unwrap().toString(), 'bar');
},
'.delete': () => {
const doc = Document.default();
doc.insert(Position.default(), 'foobar');
doc.delete(Position.at(3, 0));
assertEquals(doc.row(0).unwrap().toString(), 'fooar');
// Merge next row
const doc2 = Document.default();
doc2.insertNewline(Position.default());
doc2.insert(Position.at(0, 1), 'foobar');
doc2.delete(Position.at(0, 0));
assertEquals(doc2.row(0).unwrap().toString(), 'foobar');
// Invalid delete location
const doc3 = Document.default();
doc3.insert(Position.default(), 'foobar');
doc3.delete(Position.at(0, 3));
assertEquals(doc3.row(0).unwrap().toString(), 'foobar');
},
};
// ----------------------------------------------------------------------------
const EditorTest = {
'new Editor': () => {
const e = Editor.create(defaultTerminalSize);
assertInstanceOf(e, Editor);
},
'.open': async () => {
const e = Editor.create(defaultTerminalSize);
await e.open(THIS_FILE);
assertInstanceOf(e, Editor);
},
'.processKeyPress - letters': async () => {
const e = Editor.create(defaultTerminalSize);
const res = await e.processKeyPress('a');
assertTrue(res);
},
'.processKeyPress - ctrl-q': async () => {
// Dirty file (Need to clear confirmation messages)
const e = Editor.create(defaultTerminalSize);
await e.processKeyPress('d');
assertTrue(await e.processKeyPress(Fn.ctrlKey('q')));
assertTrue(await e.processKeyPress(Fn.ctrlKey('q')));
assertTrue(await e.processKeyPress(Fn.ctrlKey('q')));
assertFalse(await e.processKeyPress(Fn.ctrlKey('q')));
// Clean file
const e2 = Editor.create(defaultTerminalSize);
const res = await e2.processKeyPress(Fn.ctrlKey('q'));
assertFalse(res);
},
};
// ----------------------------------------------------------------------------
const FileTypeTest = {
'FileType.from()': () => {
for (const [ext, typeClass] of FT.fileTypeMap.entries()) {
const file = `test${ext}`;
const syntax = FileType.from(file);
assertInstanceOf(syntax, typeClass);
}
},
};
// ----------------------------------------------------------------------------
const OptionTest = {
'Option.from()': () => {
assertNone(Option.from(null));
assertNone(Option.from());
assertEquivalent(Option.from(undefined), None);
assertSome(Option.from('foo'));
assertSome(Option.from(234));
assertSome(Option.from({}));
assertSome(Some([1, 2, 3]));
assertEquivalent(Option.from(Some('foo')), Some('foo'));
assertEquivalent(Some(Some('bar')), Some('bar'));
},
'.isSome': () => {
assertFalse(None.isSome());
assertTrue(Option.from('foo').isSome());
assertTrue(Some('foo').isSome());
},
'.isNone': () => {
assertTrue(None.isNone());
assertFalse(Option.from('foo').isNone());
assertFalse(Some('foo').isNone());
},
'.toString': () => {
assertEquals(Some({}).toString(), 'Some ({})');
assertEquals(Some([1, 2, 3]).toString(), 'Some ([1,2,3])');
assertEquals(None.toString(), 'None');
},
'.isSomeAnd': () => {
assertFalse(Option.from().isSomeAnd((_a) => true));
assertTrue(Option.from('foo').isSomeAnd((a) => typeof a === 'string'));
},
'.isNoneAnd': () => {
assertTrue(None.isNoneAnd(() => true));
assertFalse(None.isNoneAnd(() => false));
assertFalse(Some('x').isNoneAnd(() => true));
},
'.map': () => {
const fn = (_a: any) => 'bar';
assertEquivalent(Some('bar'), Some('foo').map(fn));
assertNone(None.map(fn));
},
'.mapOr': () => {
const fn = (_a: any) => 'bar';
assertEquals('bar', Some('foo').mapOr('baz', fn));
assertEquals('baz', None.mapOr('baz', fn));
},
'.mapOrElse': () => {
const fn = (_a: any) => 'bar';
const defFn = () => 'baz';
assertEquals('bar', Some('foo').mapOrElse(defFn, fn));
assertEquals('baz', None.mapOrElse(defFn, fn));
},
'.unwrapOr': () => {
assertEquals('foo', Some('foo').unwrapOr('bar'));
assertEquals('bar', None.unwrapOr('bar'));
},
'.unwrapOrElse': () => {
const fn = () => 'bar';
assertEquals('foo', Some('foo').unwrapOrElse(fn));
assertEquals('bar', None.unwrapOrElse(fn));
},
'.and': () => {
const optb = Some('bar');
assertEquivalent(optb, Some('foo').and(optb));
assertEquivalent(None, None.and(optb));
},
'.andThen': () => {
const fn = (x: any) => Some(typeof x === 'string');
assertEquivalent(Some(true), Some('foo').andThen(fn));
assertNone(None.andThen(fn));
},
'.or': () => {
const optb = Some('bar');
assertEquivalent(Some('foo'), Some('foo').or(optb));
assertEquivalent(optb, None.or(optb));
},
'.orElse': () => {
const fn = () => Some('bar');
assertEquivalent(Some('foo'), Some('foo').orElse(fn));
assertEquivalent(Some('bar'), None.orElse(fn));
},
};
// ----------------------------------------------------------------------------
const PositionTest = {
'.default': () => {
const p = Position.default();
assertEquals(p.x, 0);
assertEquals(p.y, 0);
},
'.at': () => {
const p = Position.at(5, 7);
assertEquals(p.x, 5);
assertEquals(p.y, 7);
},
'.from': () => {
const p1 = Position.at(1, 2);
const p2 = Position.from(p1);
p1.x = 2;
p1.y = 4;
assertEquals(p1.x, 2);
assertEquals(p1.y, 4);
assertEquals(p2.x, 1);
assertEquals(p2.y, 2);
},
};
// ----------------------------------------------------------------------------
const RowTest = {
'.default': () => {
const row = Row.default();
assertEquals(row.toString(), '');
},
'.from': () => {
// From string
const row = Row.from('xyz');
assertEquals(row.toString(), 'xyz');
// From existing Row
assertEquals(Row.from(row).toString(), row.toString());
// From 'chars'
assertEquals(Row.from(['😺', '😸', '😹']).toString(), '😺😸😹');
},
'.append': () => {
const row = Row.from('foo');
row.append('bar', FileType.default());
assertEquals(row.toString(), 'foobar');
},
'.delete': () => {
const row = Row.from('foof');
row.delete(3);
assertEquals(row.toString(), 'foo');
row.delete(4);
assertEquals(row.toString(), 'foo');
},
'.split': () => {
// When you split a row, it's from the cursor position
// (Kind of like if the string were one-indexed)
const row = Row.from('foobar');
const row2 = Row.from('bar');
assertEquals(row.split(3, FileType.default()).toString(), row2.toString());
},
'.find': () => {
const normalRow = Row.from('\tFor whom the bell tolls');
assertEquivalent(
normalRow.find('who', 0, SearchDirection.Forward),
Some(5),
);
assertEquals(normalRow.find('foo', 0, SearchDirection.Forward), None);
const emojiRow = Row.from('\t😺😸😹');
assertEquivalent(emojiRow.find('😹', 0, SearchDirection.Forward), Some(3));
assertEquals(emojiRow.find('🤰🏼', 10, SearchDirection.Forward), None);
},
'.find backwards': () => {
const normalRow = Row.from('For whom the bell tolls');
assertEquivalent(
normalRow.find('who', 23, SearchDirection.Backward),
Some(4),
);
assertEquals(normalRow.find('foo', 10, SearchDirection.Backward), None);
const emojiRow = Row.from('😺😸😹');
assertEquivalent(emojiRow.find('😸', 2, SearchDirection.Backward), Some(1));
assertEquals(emojiRow.find('🤰🏼', 10, SearchDirection.Backward), None);
},
'.byteIndexToCharIndex': () => {
// Each 'character' is two bytes
const row = Row.from('😺😸😹👨‍👩‍👧‍👦');
assertEquals(row.byteIndexToCharIndex(4), 2);
assertEquals(row.byteIndexToCharIndex(2), 1);
assertEquals(row.byteIndexToCharIndex(0), 0);
// Return count on nonsense index
assertEquals(Fn.strlen(row.toString()), 10);
assertEquals(row.byteIndexToCharIndex(72), 10);
const row2 = Row.from('foobar');
assertEquals(row2.byteIndexToCharIndex(2), 2);
},
'.charIndexToByteIndex': () => {
// Each 'character' is two bytes
const row = Row.from('😺😸😹👨‍👩‍👧‍👦');
assertEquals(row.charIndexToByteIndex(2), 4);
assertEquals(row.charIndexToByteIndex(1), 2);
assertEquals(row.charIndexToByteIndex(0), 0);
},
'.cxToRx, .rxToCx': () => {
const row = Row.from('foo\tbar\tbaz');
row.update(None, FileType.default());
assertNotEquals(row.chars, row.rchars);
assertNotEquals(row.size, row.rsize);
assertEquals(row.size, 11);
assertEquals(row.rsize, row.size + (SCROLL_TAB_SIZE * 2) - 2);
const cx = 11;
const aRx = row.cxToRx(cx);
const rx = 11;
const aCx = row.rxToCx(aRx);
assertEquals(aCx, cx);
assertEquals(aRx, rx);
},
};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Test Suite Setup // Test Suite Setup
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
testSuite({ testSuite({
fns: fnTest(),
'readKey()': readKeyTest(),
'ANSI utils': ANSITest(), 'ANSI utils': ANSITest(),
Buffer: BufferTest, Buffer: BufferTest,
Document: DocumentTest, Document: DocumentTest,
Editor: EditorTest, Editor: EditorTest,
FileType: FileTypeTest,
Option: OptionTest,
Position: PositionTest, Position: PositionTest,
Row: RowTest, Row: RowTest,
fns: fnTest(),
'readKey()': readKeyTest(),
}); });

View File

@ -42,6 +42,7 @@ export enum AnsiColor {
FgMagenta, FgMagenta,
FgCyan, FgCyan,
FgWhite, FgWhite,
ForegroundColor,
FgDefault, FgDefault,
// Background Colors // Background Colors
@ -53,6 +54,7 @@ export enum AnsiColor {
BgMagenta, BgMagenta,
BgCyan, BgCyan,
BgWhite, BgWhite,
BackgroundColor,
BgDefault, BgDefault,
// Bright Foreground Colors // Bright Foreground Colors
@ -77,8 +79,8 @@ export enum AnsiColor {
} }
export enum Ground { export enum Ground {
Fore = AnsiColor.FgDefault, Fore = AnsiColor.ForegroundColor,
Back = AnsiColor.BgDefault, Back = AnsiColor.BackgroundColor,
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -106,7 +108,6 @@ const moveCursorForward = (col: number): string => code(col, 'C');
const moveCursorDown = (row: number): string => code(row, 'B'); const moveCursorDown = (row: number): string => code(row, 'B');
const textFormat = (param: string | number | string[] | number[]): string => const textFormat = (param: string | number | string[] | number[]): string =>
code(param, 'm'); code(param, 'm');
const color = (value: AnsiColor): string => textFormat(value);
const color256 = (value: number, ground: Ground = Ground.Fore): string => const color256 = (value: number, ground: Ground = Ground.Fore): string =>
textFormat([ground, AnsiColor.Type256, value]); textFormat([ground, AnsiColor.Type256, value]);
const rgb = ( const rgb = (
@ -116,6 +117,9 @@ const rgb = (
ground: Ground = Ground.Fore, ground: Ground = Ground.Fore,
): string => textFormat([ground, AnsiColor.TypeRGB, r, g, b]); ): string => textFormat([ground, AnsiColor.TypeRGB, r, g, b]);
/**
* Ansi terminal codes and helper functions
*/
export const Ansi = { export const Ansi = {
ClearLine: code('K'), ClearLine: code('K'),
ClearScreen: code('2J'), ClearScreen: code('2J'),
@ -129,7 +133,46 @@ export const Ansi = {
moveCursorForward, moveCursorForward,
moveCursorDown, moveCursorDown,
textFormat, textFormat,
color, color: {
Black: textFormat(AnsiColor.FgBlack),
Red: textFormat(AnsiColor.FgRed),
Green: textFormat(AnsiColor.FgGreen),
Yellow: textFormat(AnsiColor.FgYellow),
Blue: textFormat(AnsiColor.FgBlue),
Magenta: textFormat(AnsiColor.FgMagenta),
Cyan: textFormat(AnsiColor.FgCyan),
White: textFormat(AnsiColor.FgWhite),
Default: textFormat(AnsiColor.FgDefault),
BrightBlack: textFormat(AnsiColor.FgBrightBlack),
BrightRed: textFormat(AnsiColor.FgBrightRed),
BrightGreen: textFormat(AnsiColor.FgBrightGreen),
BrightYellow: textFormat(AnsiColor.FgBrightYellow),
BrightBlue: textFormat(AnsiColor.FgBrightBlue),
BrightMagenta: textFormat(AnsiColor.FgBrightMagenta),
BrightCyan: textFormat(AnsiColor.FgBrightCyan),
BrightWhite: textFormat(AnsiColor.FgBrightWhite),
Invert: textFormat(AnsiColor.Invert),
background: {
Black: textFormat(AnsiColor.BgBlack),
Red: textFormat(AnsiColor.BgRed),
Green: textFormat(AnsiColor.BgGreen),
Yellow: textFormat(AnsiColor.BgYellow),
Blue: textFormat(AnsiColor.BgBlue),
Magenta: textFormat(AnsiColor.BgMagenta),
Cyan: textFormat(AnsiColor.BgCyan),
White: textFormat(AnsiColor.BgWhite),
Default: textFormat(AnsiColor.BgDefault),
BrightBlack: textFormat(AnsiColor.BgBrightBlack),
BrightRed: textFormat(AnsiColor.BgBrightRed),
BrightGreen: textFormat(AnsiColor.BgBrightGreen),
BrightYellow: textFormat(AnsiColor.BgBrightYellow),
BrightBlue: textFormat(AnsiColor.BgBrightBlue),
BrightMagenta: textFormat(AnsiColor.BgBrightMagenta),
BrightCyan: textFormat(AnsiColor.BgBrightCyan),
BrightWhite: textFormat(AnsiColor.BgBrightWhite),
Invert: textFormat(AnsiColor.Invert),
},
},
color256, color256,
rgb, rgb,
}; };

View File

@ -1,10 +1,17 @@
import { strlen, truncate } from './fns.ts'; import { strlen, truncate } from './fns.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
/**
* A simple string buffer
*/
class Buffer { class Buffer {
#b = ''; #b = '';
constructor() { private constructor() {
}
public static default(): Buffer {
return new Buffer();
} }
public append(s: string, maxLen?: number): void { public append(s: string, maxLen?: number): void {

View File

@ -1,13 +1,27 @@
import { ITerminalSize } from './types.ts'; import Ansi from './ansi.ts';
import { HighlightType, ITerminalSize } from './types.ts';
export const SCROLL_VERSION = '0.0.1'; export const SCROLL_VERSION = '0.1.0';
export const SCROLL_QUIT_TIMES = 3; export const SCROLL_QUIT_TIMES = 3;
export const SCROLL_TAB_SIZE = 4; export const SCROLL_TAB_SIZE = 4;
export const SCROLL_LOG_FILE = './scroll.log'; export const SCROLL_LOG_FILE_PREFIX = './scroll';
export const SCROLL_ERR_FILE = './scroll.err'; export const SCROLL_LOG_FILE_SUFFIX = '.log';
export const defaultTerminalSize: ITerminalSize = { export const defaultTerminalSize: ITerminalSize = {
rows: 24, rows: 24,
cols: 80, cols: 80,
}; };
export const SCROLL_COLOR_SCHEME: Map<HighlightType, string> = new Map([
[HighlightType.Match, Ansi.color.Invert], // Inverted color
[HighlightType.Number, Ansi.color256(196)], // Bright Red
[HighlightType.Character, Ansi.color256(207)], // Magenta
[HighlightType.String, Ansi.color256(45)], // Cyan
[HighlightType.SingleLineComment, Ansi.color256(248)], // Light Gray
[HighlightType.MultiLineComment, Ansi.color256(240)], // Medium-light Gray
[HighlightType.Keyword1, Ansi.color256(226)], // Yellow
[HighlightType.Keyword2, Ansi.color256(118)], // Green
[HighlightType.Operator, Ansi.color256(215)], // Orange/Brown
[HighlightType.None, Ansi.ResetFormatting],
]);

View File

@ -1,34 +1,36 @@
import Row from './row.ts'; import Row from './row.ts';
import { arrayInsert, some, strlen } from './fns.ts'; import { FileType } from './filetype.ts';
import { HighlightType } from './highlight.ts'; import { arrayInsert } from './fns.ts';
import { getRuntime } from './runtime.ts'; import Option, { None, Some } from './option.ts';
import { Position } from './types.ts'; import { getRuntime, logWarning } from './runtime.ts';
import { Search } from './search.ts'; import { Position, SearchDirection } from './types.ts';
export class Document { export class Document {
#rows: Row[]; /**
#search: Search; * Each line of the current document
*/
#rows: Row[] = [];
/** /**
* Has the document been modified? * @param dirty - Has the document been modified?
* @param type - The meta-data for the file type of the current document
*/ */
public dirty: boolean; private constructor(
public dirty: boolean = false,
private constructor() { public type: FileType = FileType.default(),
this.#rows = []; ) {
this.#search = new Search();
this.dirty = false;
} }
get numRows(): number { public get fileType(): string {
return this.type.name;
}
public get numRows(): number {
return this.#rows.length; return this.#rows.length;
} }
public static default(): Document { public static default(): Document {
const self = new Document(); return new Document();
self.#search.parent = self;
return self;
} }
public isEmpty(): boolean { public isEmpty(): boolean {
@ -46,9 +48,13 @@ export class Document {
this.#rows = []; this.#rows = [];
} }
this.type = FileType.from(filename);
const rawFile = await file.openFile(filename); const rawFile = await file.openFile(filename);
rawFile.split(/\r?\n/) rawFile.split(/\r?\n/)
.forEach((row) => this.insertRow(this.numRows, row)); .forEach((row: string) => {
this.#rows.push(Row.from(row));
});
this.dirty = false; this.dirty = false;
@ -58,71 +64,104 @@ export class Document {
/** /**
* Save the current document * Save the current document
*/ */
public async save(filename: string) { public async save(filename: string): Promise<void> {
const { file } = await getRuntime(); const { file } = await getRuntime();
await file.saveFile(filename, this.rowsToString()); await file.saveFile(filename, this.rowsToString());
this.type = FileType.from(filename);
this.dirty = false; this.dirty = false;
} }
public resetFind() { /**
this.#search = new Search(); * Find the cursor position of the query, if it exists
this.#search.parent = this; *
} * @param q - the search query
* @param at - the point from which to start the search
* @param direction - which direction to search, backward or forward
*/
public find( public find(
q: string, q: string,
key: string, at: Position,
): Position | null { direction: SearchDirection,
const potential = this.#search.search(q, key); ): Option<Position> {
if (some(potential) && potential instanceof Position) { if (at.y >= this.numRows) {
// Update highlight of search match logWarning('Trying to search beyond the end of the current file', {
const row = this.#rows[potential.y]; at,
document: this,
});
return None;
}
// Okay, we have to take the Javascript string index (potential.x), convert const position = Position.from(at);
// it to the Row 'character' index, and then convert that to the Row render index
// so that the highlighted color starts in the right place.
const start = row.cxToRx(row.byteIndexToCharIndex(potential.x));
// Just to be safe with unicode searches, take the number of 'characters' for (let y = at.y; y >= 0 && y < this.numRows; y += direction) {
// as the search query length, not the JS string length. const maybeMatch = this.#rows[y].find(q, position.x, direction);
const end = start + strlen(q); if (maybeMatch.isSome()) {
position.x = maybeMatch.unwrap();
return Some(position);
}
for (let i = start; i < end; i++) { if (direction === SearchDirection.Forward) {
row.hl[i] = HighlightType.Match; position.y += 1;
position.x = 0;
} else if (direction === SearchDirection.Backward) {
position.y -= 1;
position.x = this.#rows[position.y].size;
console.assert(position.y < this.numRows);
} }
} }
return potential; return None;
}
public insert(at: Position, c: string): void {
if (at.y === this.numRows) {
this.insertRow(this.numRows, c);
} else {
this.#rows[at.y].insertChar(at.x, c);
this.#rows[at.y].update();
}
this.dirty = true;
} }
/**
* Insert a new line, splitting and/or creating a new row as needed
*/
public insertNewline(at: Position): void { public insertNewline(at: Position): void {
if (at.y > this.numRows) { if (at.y > this.numRows) {
return; return;
} }
this.dirty = true;
// Just add a simple blank line
if (at.y === this.numRows) { if (at.y === this.numRows) {
this.#rows.push(Row.default()); this.#rows.push(Row.default());
return; return;
} }
const newRow = this.#rows[at.y].split(at.x); // Split the current row, and insert a new
newRow.update(); // row with the leftovers
const currentRow = this.#rows[at.y];
const newRow = currentRow.split(at.x, this.type);
this.#rows = arrayInsert(this.#rows, at.y + 1, newRow); this.#rows = arrayInsert(this.#rows, at.y + 1, newRow);
}
public insert(at: Position, c: string): void {
if (at.y > this.numRows) {
return;
}
this.dirty = true; this.dirty = true;
if (at.y === this.numRows) {
this.#rows.push(Row.from(c));
} else {
this.#rows[at.y].insertChar(at.x, c);
}
this.unHighlightRows(at.y);
}
protected unHighlightRows(start: number): void {
if (this.numRows < start && start >= 1) {
for (let i = start - 1; i < this.numRows; i++) {
this.#rows[i].isHighlighted = false;
}
}
} }
/** /**
@ -135,65 +174,71 @@ export class Document {
return; return;
} }
const row = this.row(at.y)!; this.dirty = true;
const mergeNextRow = at.x === row.size && at.y + 1 < len;
const mergeIntoPrevRow = at.x === 0 && at.y > 0; const maybeRow = this.row(at.y);
if (maybeRow.isNone()) {
return;
}
const row = maybeRow.unwrap();
const mergeNextRow = at.x === row.size && this.row(at.y + 1).isSome();
// If we are at the end of a line, and press delete, // If we are at the end of a line, and press delete,
// add the contents of the next row, and delete // add the contents of the next row, and delete
// the merged row object // the merged row object (This also works for pressing
// backspace at the beginning of a line: the cursor is
// moved to the end of the previous line)
if (mergeNextRow) { if (mergeNextRow) {
// At the end of a line, pressing delete will merge // At the end of a line, pressing delete will merge
// the next line into the current on // the next line into the current one
const rowToAppend = this.#rows.at(at.y + 1)!.toString(); const rowToAppend = this.#rows[at.y + 1].toString();
row.append(rowToAppend); row.append(rowToAppend, this.type);
this.deleteRow(at.y + 1); this.deleteRow(at.y + 1);
} else if (mergeIntoPrevRow) {
// At the beginning of a line, merge the current line
// into the previous Row
const rowToAppend = row.toString();
this.#rows[at.y - 1].append(rowToAppend);
this.deleteRow(at.y);
} else { } else {
row.delete(at.x); row.delete(at.x);
} }
row.update(); this.unHighlightRows(at.y);
this.dirty = true;
} }
public row(i: number): Row | null { public row(i: number): Option<Row> {
return this.#rows[i] ?? null; if (i >= this.numRows || i < 0) {
return None;
}
return Option.from(this.#rows.at(i));
} }
public insertRow(at: number = this.numRows, s: string = ''): void { public highlight(searchMatch: Option<string>, limit: Option<number>): void {
this.#rows = arrayInsert(this.#rows, at, Row.from(s)); let startWithComment = false;
this.#rows[at].update(); let until = this.numRows;
if (limit.isSome() && (limit.unwrap() + 1 < this.numRows)) {
until = limit.unwrap() + 1;
}
this.dirty = true; for (let i = 0; i < until; i++) {
} startWithComment = this.#rows[i].update(
searchMatch,
public highlight(searchMatch?: string): void { this.type,
this.#rows.forEach((row) => { startWithComment,
row.update(searchMatch); );
}); }
} }
/** /**
* Delete the specified row * Delete the specified row
* @param at - the index of the row to delete * @param at - the index of the row to delete
* @private
*/ */
private deleteRow(at: number): void { protected deleteRow(at: number): void {
this.#rows.splice(at, 1); this.#rows.splice(at, 1);
} }
/** /**
* Convert the array of row objects into one string * Convert the array of row objects into one string
* @private
*/ */
private rowsToString(): string { protected rowsToString(): string {
return this.#rows.map((r) => r.toString()).join('\n'); return this.#rows.map((r) => r.toString()).join('\n');
} }
} }

File diff suppressed because it is too large Load Diff

5
src/common/filetype.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './filetype/base.ts';
export * from './filetype/filetype.ts';
import FileType from './filetype/filetype.ts';
export default FileType;

View File

@ -0,0 +1,91 @@
import Option, { None } from '../option.ts';
// ----------------------------------------------------------------------------
// File-related types
// ----------------------------------------------------------------------------
export enum FileLang {
C = 'C',
CPP = 'C++',
TypeScript = 'TypeScript',
JavaScript = 'JavaScript',
PHP = 'PHP',
Go = 'Golang',
Rust = 'Rust',
CSS = 'CSS',
Shell = 'Shell',
Plain = 'Plain Text',
}
export interface HighlightingOptions {
characters: boolean;
numbers: boolean;
octalNumbers: boolean;
hexNumbers: boolean;
binNumbers: boolean;
jsBigInt: boolean;
strings: boolean;
}
interface IFileType {
readonly name: FileLang;
readonly singleLineComment: Option<string>;
readonly multiLineCommentStart: Option<string>;
readonly multiLineCommentEnd: Option<string>;
readonly keywords1: string[];
readonly keywords2: string[];
readonly operators: string[];
readonly hlOptions: HighlightingOptions;
get flags(): HighlightingOptions;
get primaryKeywords(): string[];
get secondaryKeywords(): string[];
hasMultilineComments(): boolean;
}
/**
* The base class for File Types
*/
export abstract class AbstractFileType implements IFileType {
public readonly name: FileLang = FileLang.Plain;
public readonly singleLineComment = None;
public readonly multiLineCommentStart: Option<string> = None;
public readonly multiLineCommentEnd: Option<string> = None;
public readonly keywords1: string[] = [];
public readonly keywords2: string[] = [];
public readonly operators: string[] = [];
public readonly hlOptions: HighlightingOptions = {
characters: false,
numbers: false,
octalNumbers: false,
hexNumbers: false,
binNumbers: false,
jsBigInt: false,
strings: false,
};
get flags(): HighlightingOptions {
return this.hlOptions;
}
get primaryKeywords(): string[] {
return this.keywords1;
}
get secondaryKeywords(): string[] {
return this.keywords2;
}
public hasMultilineComments(): boolean {
return this.multiLineCommentStart.and(this.multiLineCommentEnd).isSome();
}
}
export const defaultHighlightOptions: HighlightingOptions = {
characters: false,
numbers: true,
octalNumbers: false,
hexNumbers: false,
binNumbers: false,
jsBigInt: false,
strings: true,
};

151
src/common/filetype/c.ts Normal file
View File

@ -0,0 +1,151 @@
import Option, { Some } from '../option.ts';
import {
AbstractFileType,
defaultHighlightOptions,
FileLang,
HighlightingOptions,
} from './base.ts';
export class CFile extends AbstractFileType {
public readonly name: FileLang = FileLang.C;
public readonly singleLineComment = Some('//');
public readonly multiLineCommentStart: Option<string> = Some('/*');
public readonly multiLineCommentEnd: Option<string> = Some('*/');
public readonly keywords1 = [
'continue',
'register',
'restrict',
'volatile',
'default',
'typedef',
'typedef',
'switch',
'return',
'static',
'struct',
'extern',
'inline',
'return',
'sizeof',
'switch',
'break',
'const',
'while',
'break',
'union',
'class',
'union',
'auto',
'case',
'else',
'enum',
'case',
'for',
'do',
'if',
];
public readonly keywords2 = [
'#include',
'unsigned',
'uint32_t',
'uint64_t',
'uint16_t',
'#define',
'#ifndef',
'wchar_t',
'int32_t',
'int64_t',
'int16_t',
'uint8_t',
'double',
'signed',
'#endif',
'#ifdef',
'#error',
'#undef',
'int8_t',
'time_t',
'size_t',
'float',
'#elif',
'long',
'char',
'void',
'int',
'#if',
];
public readonly operators = [
'>>>=',
'**=',
'&&=',
'||=',
'??=',
'>>>',
'<=>',
'<<=',
'>>=',
'+=',
'-=',
'*=',
'/=',
'%=',
'&=',
'^=',
'|=',
'==',
'!=',
'>=',
'<=',
'++',
'--',
'**',
'<<',
'>>',
'&&',
'||',
'??',
'?.',
'++',
'--',
'==',
'!=',
'>=',
'<=',
'&&',
'||',
'<<',
'>>',
'+=',
'-=',
'*=',
'/=',
'%=',
'&=',
'|=',
'^=',
'->',
'::',
'?',
':',
'=',
'>',
'<',
'%',
'-',
'+',
'*',
'&',
'|',
'^',
'~',
'!',
'.',
',',
';',
];
public readonly hlOptions: HighlightingOptions = {
...defaultHighlightOptions,
characters: true,
hexNumbers: true,
};
}

393
src/common/filetype/css.ts Normal file
View File

@ -0,0 +1,393 @@
import Option, { None, Some } from '../option.ts';
import {
AbstractFileType,
defaultHighlightOptions,
FileLang,
HighlightingOptions,
} from './base.ts';
export class CSSFile extends AbstractFileType {
public readonly name: FileLang = FileLang.CSS;
public readonly singleLineComment = None;
public readonly multiLineCommentStart: Option<string> = Some('/*');
public readonly multiLineCommentEnd: Option<string> = Some('*/');
public readonly keywords1 = [
':active',
':any-link',
':autofill',
':checked',
':default',
':disabled',
':empty',
':enabled',
':first-child',
':first-of-type',
':focus-visible',
':focus-within',
':focus',
':fullscreen',
':hover',
':in-range',
':indeterminate',
':invalid',
':last-child',
':last-of-type',
':link',
':modal',
':nth-child',
':nth-last-child',
':nth-last-of-type',
':nth-of-type',
':only-child',
':only-of-type',
':optional',
':out-of-range',
':paused',
':picture-in-picture',
':placeholder-shown',
':playing',
':read-only',
':read-write',
':required',
':root',
':scope',
':target',
':user-valid',
':valid',
':visited',
'::after',
'::backdrop',
'::before',
'::cue',
'::file-selector-button',
'::first-letter',
'::first-line',
'::grammar-error',
'::marker',
'::placeholder',
'::selection',
'::spelling-error',
'@charset',
'@color-profile',
'@container',
'@counter-style',
'@font-face',
'@font-feature-values',
'@font-palette-values',
'@import',
'@keyframes',
'@layer',
'@media',
'@namespace',
'@page',
'@position-try',
'@property',
'@scope',
'@starting-style',
'@supports',
'@view-transition',
];
public readonly keywords2 = [
'animation-range-end',
'animation-range-start',
'accent-color',
'animation-timeline',
'animation',
'animation-timing-function',
'animation-composition',
'animation-delay',
'animation-direction',
'appearance',
'align-content',
'animation-duration',
'align-items',
'animation-fill-mode',
'align-self',
'animation-iteration-count',
'aspect-ratio',
'align-tracks',
'animation-name',
'all',
'animation-play-state',
'animation-name',
'anchor-name',
'border-block-start-color',
'border-inline-style',
'backdrop-filter',
'border-block-start-style',
'border-inline-width',
'backface-visibility',
'border-block-start-width',
'border-left',
'background',
'border-block-style',
'border-left-color',
'background-attachment',
'border-block-width',
'border-left-style',
'background-blend-mode',
'border-bottom',
'border-left-width',
'background-clip',
'border-bottom-color',
'border-radius',
'background-color',
'border-bottom-left-radius',
'border-right',
'background-image',
'border-bottom-right-radius',
'border-right-color',
'background-origin',
'border-bottom-style',
'border-right-style',
'background-position',
'border-bottom-width',
'border-right-width',
'background-position-x',
'border-collapse',
'border-spacing',
'background-position-y',
'border-color',
'border-start-end-radius',
'background-repeat',
'border-end-end-radius',
'border-start-start-radius',
'background-size',
'border-end-start-radius',
'border-style',
'border-image',
'border-top',
'border-image-outset',
'border-top-color',
'border-image-repeat',
'border-top-left-radius',
'border-image-slice',
'border-top-right-radius',
'border-image-source',
'border-top-style',
'border-image-width',
'border-top-width',
'border-inline',
'border-width',
'block-size',
'border-inline-color',
'bottom',
'border-inline-end',
'border',
'border-inline-end-color',
'box-decoration-break',
'border-block',
'border-inline-end-style',
'box-shadow',
'border-block-color',
'border-inline-end-width',
'box-sizing',
'border-block-end',
'border-inline-start',
'break-after',
'border-block-end-color',
'border-inline-start-color',
'break-before',
'border-block-end-style',
'border-inline-start-style',
'break-inside',
'border-block-end-width',
'border-inline-start-width',
'border-block-start',
'column-rule',
'content-visibility',
'caption-side',
'column-rule-color',
'column-rule-style',
'caret-color',
'column-rule-width',
'column-span',
'counter-increment',
'column-width',
'counter-reset',
'columns',
'counter-set',
'contain',
'contain-intrinsic-block-size',
'clear',
'contain-intrinsic-height',
'clip',
'contain-intrinsic-inline-size',
'clip-path',
'color',
'contain-intrinsic-width',
'cursor',
'color-scheme',
'container',
'column-count',
'container-name',
'column-fill',
'container-type',
'column-gap',
'content',
'direction',
'display',
'empty-cells',
'font-synthesis-position',
'field-sizing',
'font',
'font-synthesis-small-caps',
'filter',
'font-synthesis-style',
'font-synthesis-weight',
'font-family',
'font-variant',
'font-variant-alternates',
'font-variant-caps',
'font-feature-settings',
'font-variant-east-asian',
'font-variant-emoji',
'font-variant-ligatures',
'font-variant-numeric',
'font-variant-position',
'flex',
'font-kerning',
'font-variation-settings',
'flex-basis',
'font-language-override',
'flex-direction',
'font-optical-sizing',
'flex-flow',
'font-palette',
'font-weight',
'flex-grow',
'flex-shrink',
'font-size',
'forced-color-adjust',
'flex-wrap',
'font-size-adjust',
'font-stretch',
'float',
'font-style',
'font-synthesis',
'grid-auto-columns',
'grid-row-end',
'gap',
'grid-auto-flow',
'grid-row-start',
'grid-auto-rows',
'grid-template',
'grid-column',
'grid-template-areas',
'grid-column-end',
'grid-template-columns',
'grid',
'grid-column-start',
'grid-template-rows',
'grid-area',
'grid-row',
'hanging-punctuation',
'hyphenate-character',
'hyphenate-limit-chars',
'height',
'hyphens',
'initial',
'inset-inline',
'initial-letter',
'inset-inline-end',
'image-orientation',
'image-rendering',
'inline-size',
'image-resolution',
'inset',
'isolation',
'inset-area',
'inset-block',
'inherit',
'inset-block-end',
'inset-block-start',
'justify-content',
'justify-self',
'justify-items',
'justify-tracks',
'letter-spacing',
'list-style',
'list-style-image',
'line-break',
'list-style-position',
'line-clamp',
'list-style-type',
'line-height',
'left',
'line-height-step',
'mask-border-outset',
'margin',
'mask-border-repeat',
'margin-block',
'mask-border-slice',
'margin-block-end',
'mask-border-source',
'margin-block-start',
'mask-border-width',
'max-height',
'margin-bottom',
'mask-clip',
'max-inline-size',
'margin-inline',
'mask-composite',
'margin-inline-end',
'mask-image',
'max-width',
'margin-inline-start',
'mask-mode',
'margin-left',
'mask-origin',
'margin-right',
'mask-position',
'min-block-size',
'margin-top',
'mask-repeat',
'min-height',
'margin-trim',
'mask-size',
'min-inline-size',
'mask-type',
'min-width',
'masonry-auto-flow',
'mask',
'math-depth',
'mix-blend-mode',
'mask-border',
'math-shift',
'mask-border-mode',
'math-style',
'object-fit',
'order',
'overflow-inline',
'object-position',
'overflow-wrap',
'offset',
'orphans',
'overflow-x',
'offset-anchor',
'overflow-y',
'offset-distance',
'outline',
'overlay',
'offset-path',
'outline-color',
'offset-position',
'outline-offset',
'offset-rotate',
'outline-style',
'overscroll-behavior',
'outline-width',
'overscroll-behavior-block',
'overscroll-behavior-inline',
'opacity',
'overflow-anchor',
'overscroll-behavior-x',
'overflow-block',
'overscroll-behavior-y',
'overflow-clip-margin',
];
public readonly operators = ['::', ':', ',', '+', '>', '~', '-'];
public readonly hlOptions: HighlightingOptions = {
...defaultHighlightOptions,
};
}

View File

@ -0,0 +1,41 @@
import { node_path as path } from '../runtime.ts';
import { AbstractFileType } from './base.ts';
import { CFile } from './c.ts';
import { CSSFile } from './css.ts';
import { JavaScriptFile, TypeScriptFile } from './javascript.ts';
import { RustFile } from './rust.ts';
import { ShellFile } from './shell.ts';
// ----------------------------------------------------------------------------
// External interface
// ----------------------------------------------------------------------------
export const fileTypeMap = new Map([
['.bash', ShellFile],
['.c', CFile],
['.css', CSSFile],
['.h', CFile],
['.js', JavaScriptFile],
['.json', JavaScriptFile],
['.jsx', JavaScriptFile],
['.mjs', JavaScriptFile],
['.rs', RustFile],
['.sh', ShellFile],
['.ts', TypeScriptFile],
['.tsx', TypeScriptFile],
]);
export class FileType extends AbstractFileType {
public static default(): FileType {
return new FileType();
}
public static from(filename: string): FileType {
const ext = path.extname(filename);
const type = fileTypeMap.get(ext) ?? FileType;
return new type();
}
}
export default FileType;

View File

@ -0,0 +1,152 @@
import Option, { Some } from '../option.ts';
import {
AbstractFileType,
defaultHighlightOptions,
FileLang,
HighlightingOptions,
} from './base.ts';
export class JavaScriptFile extends AbstractFileType {
public readonly name: FileLang = FileLang.JavaScript;
public readonly singleLineComment = Some('//');
public readonly multiLineCommentStart: Option<string> = Some('/*');
public readonly multiLineCommentEnd: Option<string> = Some('*/');
public readonly keywords1 = [
'=>',
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'let',
'new',
'null',
'return',
'static',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
];
public readonly keywords2 = [
'arguments',
'as',
'async',
'BigInt',
'Boolean',
'eval',
'from',
'get',
'JSON',
'Math',
'Number',
'Object',
'of',
'set',
'String',
'Symbol',
'undefined',
];
public readonly operators = [
'>>>=',
'**=',
'<<=',
'>>=',
'&&=',
'||=',
'??=',
'===',
'!==',
'>>>',
'+=',
'-=',
'*=',
'/=',
'%=',
'&=',
'^=',
'|=',
'==',
'!=',
'>=',
'<=',
'++',
'--',
'**',
'<<',
'>>',
'&&',
'||',
'??',
'?.',
'?',
':',
'=',
'>',
'<',
'%',
'-',
'+',
'&',
'|',
'^',
'~',
'!',
'.',
',',
';',
];
public readonly hlOptions: HighlightingOptions = {
...defaultHighlightOptions,
octalNumbers: true,
hexNumbers: true,
binNumbers: true,
jsBigInt: true,
};
}
export class TypeScriptFile extends JavaScriptFile {
public readonly name: FileLang = FileLang.TypeScript;
public readonly keywords2 = [
...super.secondaryKeywords,
// Typescript-specific
'any',
'bigint',
'boolean',
'enum',
'interface',
'keyof',
'number',
'private',
'protected',
'public',
'string',
'type',
'unknown',
];
}

169
src/common/filetype/rust.ts Normal file
View File

@ -0,0 +1,169 @@
import Option, { Some } from '../option.ts';
import {
AbstractFileType,
defaultHighlightOptions,
FileLang,
HighlightingOptions,
} from './base.ts';
export class RustFile extends AbstractFileType {
public readonly name: FileLang = FileLang.Rust;
public readonly singleLineComment = Some('//');
public readonly multiLineCommentStart: Option<string> = Some('/*');
public readonly multiLineCommentEnd: Option<string> = Some('*/');
public readonly keywords1 = [
'continue',
'return',
'static',
'struct',
'unsafe',
'break',
'const',
'crate',
'extern',
'match',
'super',
'trait',
'where',
'else',
'enum',
'false',
'impl',
'loop',
'move',
'self',
'type',
'while',
'for',
'let',
'mod',
'pub',
'ref',
'true',
'use',
'mut',
'as',
'fn',
'if',
'in',
];
public readonly keywords2 = [
'DoubleEndedIterator',
'ExactSizeIterator',
'IntoIterator',
'PartialOrd',
'PartialEq',
'Iterator',
'ToString',
'Default',
'ToOwned',
'Extend',
'FnOnce',
'Option',
'String',
'AsMut',
'AsRef',
'Clone',
'Debug',
'FnMut',
'Sized',
'Unpin',
'array',
'isize',
'usize',
'&str',
'Copy',
'Drop',
'From',
'Into',
'None',
'Self',
'Send',
'Some',
'Sync',
'bool',
'char',
'i128',
'u128',
'Box',
'Err',
'Ord',
'Vec',
'dyn',
'f32',
'f64',
'i16',
'i32',
'i64',
'str',
'u16',
'u32',
'u64',
'Eq',
'Fn',
'Ok',
'i8',
'u8',
'&mut self',
'&mut',
'&self',
'self',
];
public readonly operators = [
'||=',
'>>=',
'<=>',
'<<=',
'&&=',
'**=',
'..=',
'...',
'||',
'|=',
'>>',
'>=',
'=>',
'==',
'<=',
'<<',
'<-',
'+=',
'++',
'^=',
'%=',
'&=',
'&&',
'/=',
'*=',
'**',
'..',
'!=',
':=',
'::',
'->',
'-=',
'--',
'~',
'|',
'>',
'=',
'<',
'+',
'^',
'%',
'&',
'*',
'.',
'!',
':',
';',
',',
'-',
];
public readonly hlOptions: HighlightingOptions = {
...defaultHighlightOptions,
characters: true,
binNumbers: true,
hexNumbers: true,
};
}

View File

@ -0,0 +1,37 @@
import { Some } from '../option.ts';
import {
AbstractFileType,
defaultHighlightOptions,
FileLang,
HighlightingOptions,
} from './base.ts';
export class ShellFile extends AbstractFileType {
public readonly name: FileLang = FileLang.Shell;
public readonly singleLineComment = Some('#');
public readonly keywords1 = [
'case',
'do',
'done',
'elif',
'else',
'esac',
'fi',
'for',
'function',
'if',
'in',
'select',
'then',
'time',
'until',
'while',
'declare',
];
public readonly keywords2 = ['set'];
public readonly operators = ['[[', ']]'];
public readonly hlOptions: HighlightingOptions = {
...defaultHighlightOptions,
numbers: false,
};
}

View File

@ -1,4 +1,6 @@
import { KeyCommand } from './ansi.ts'; import Ansi, { KeyCommand } from './ansi.ts';
import { SCROLL_COLOR_SCHEME } from './config.ts';
import { HighlightType } from './types.ts';
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@ -11,20 +13,6 @@ const decoder = new TextDecoder();
*/ */
export const noop = () => {}; export const noop = () => {};
/**
* Does a value exist? (not null or undefined)
*/
export function some(v: unknown): boolean {
return v !== null && typeof v !== 'undefined';
}
/**
* Is the value null or undefined?
*/
export function none(v: unknown): boolean {
return v === null || typeof v === 'undefined';
}
/** /**
* Convert input from ANSI escape sequences into a form * Convert input from ANSI escape sequences into a form
* that can be more easily mapped to editor commands * that can be more easily mapped to editor commands
@ -42,7 +30,7 @@ export function readKey(raw: Uint8Array): string {
return parsed; return parsed;
} }
// Some keycodes have multiple potential inputs // Some key codes have multiple potential inputs
switch (parsed) { switch (parsed) {
case '\x1b[1~': case '\x1b[1~':
case '\x1b[7~': case '\x1b[7~':
@ -71,12 +59,23 @@ export function readKey(raw: Uint8Array): string {
} }
} }
/**
* Return the configured ANSI formatting escape codes for the
* type of syntax specified
*
* @param type The type of syntax to highlight
*/
export function highlightToColor(type: HighlightType): string {
return SCROLL_COLOR_SCHEME.get(type) ?? Ansi.ResetFormatting;
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Array manipulation // Array manipulation
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/** /**
* Insert a value into an array at the specified index * Insert a value into an array at the specified index
*
* @param arr - the array * @param arr - the array
* @param at - the index to insert at * @param at - the index to insert at
* @param value - what to add into the array * @param value - what to add into the array
@ -101,6 +100,7 @@ export function arrayInsert<T>(
/** /**
* Subtract two numbers, returning a zero if the result is negative * Subtract two numbers, returning a zero if the result is negative
*
* @param l * @param l
* @param s * @param s
*/ */
@ -149,10 +149,10 @@ export function ord(s: string): number {
/** /**
* Split a string by graphemes, not just bytes * Split a string by graphemes, not just bytes
* *
* @param s - the string to split into 'characters' * @param s - the string to split into unicode code points
*/ */
export function strChars(s: string): string[] { export function strChars(s: string): string[] {
return s.split(/(?:)/u); return [...s];
} }
/** /**
@ -164,6 +164,17 @@ export function strlen(s: string): number {
return strChars(s).length; return strChars(s).length;
} }
/**
* Get a slice of a string
*
* @param s - the string
* @param from - the 'character' index of the start of the slice
* @param to - the 'character' index of the last character you want
*/
export function substr(s: string, from: number, to?: number): string {
return strChars(s).slice(from, to).join('');
}
/** /**
* Are all the characters in the string in ASCII range? * Are all the characters in the string in ASCII range?
* *
@ -193,6 +204,15 @@ export function isControl(char: string): boolean {
return isAscii(char) && (code === 0x7f || code < 0x20); return isAscii(char) && (code === 0x7f || code < 0x20);
} }
/**
* Is the one char string a common separator/operator character
*
* @param char - a one character string to check
*/
export function isSeparator(char: string): boolean {
return /\s/.test(char) || char === '\0' || ',.()+-/*=~%<>[];'.includes(char);
}
/** /**
* Get the key code for a ctrl chord * Get the key code for a ctrl chord
* *

View File

@ -1,20 +0,0 @@
import Ansi from './ansi.ts';
export enum HighlightType {
None,
Number,
Match,
}
export function highlightToColor(type: HighlightType): string {
switch (type) {
case HighlightType.Number:
return Ansi.color256(196);
case HighlightType.Match:
return Ansi.color256(21);
default:
return Ansi.ResetFormatting;
}
}

View File

@ -1,9 +1,18 @@
import process from 'node:process';
import { readKey } from './fns.ts'; import { readKey } from './fns.ts';
import { getRuntime, logError } from './runtime.ts'; import {
getRuntime,
logError,
logWarning,
node_process as process,
} from './runtime.ts';
import Editor from './editor.ts'; import Editor from './editor.ts';
export async function main() { /**
* The main runtime loop
*
* Only returns on error or quit
*/
export async function main(): Promise<void> {
const rt = await getRuntime(); const rt = await getRuntime();
const { term } = rt; const { term } = rt;
@ -14,15 +23,13 @@ export async function main() {
}); });
// Setup error handler to log to file // Setup error handler to log to file
rt.onEvent('error', (error) => { rt.onEvent('error', (error: any) => {
process.stdin.setRawMode(false); process.stdin.setRawMode(false);
logError(JSON.stringify(error, null, 2)); logError(JSON.stringify(error, null, 2));
}); });
const terminalSize = await term.getTerminalSize();
// Create the editor itself // Create the editor itself
const editor = new Editor(terminalSize); const editor = Editor.create(await term.getTerminalSize());
// Process cli arguments // Process cli arguments
if (term.argv.length > 0) { if (term.argv.length > 0) {
@ -43,7 +50,9 @@ export async function main() {
for await (const char of term.inputLoop()) { for await (const char of term.inputLoop()) {
const parsed = readKey(char); const parsed = readKey(char);
if (char.length === 0 || parsed.length === 0) { if (char.length === 0 || parsed.length === 0) {
continue; logWarning('Empty input returned from runtime input loop');
return;
} }
// Process input // Process input
@ -51,6 +60,9 @@ export async function main() {
if (!shouldLoop) { if (!shouldLoop) {
return; return;
} }
// Render output
await editor.refreshScreen();
} }
} }
} }

261
src/common/option.ts Normal file
View File

@ -0,0 +1,261 @@
/**
* The sad, lonely enum that should be more tightly coupled
* to the Option type...but this isn't Rust
*/
enum OptionType {
Some = 'Some',
None = 'None',
}
// ----------------------------------------------------------------------------
// Typeguards to handle Some/None difference
// ----------------------------------------------------------------------------
const isOption = <T>(v: any): v is Option<T> => v instanceof Option;
class OptNone {
public type: OptionType = OptionType.None;
}
class OptSome<T> {
public type: OptionType = OptionType.Some;
constructor(public value: T) {}
}
type OptType<T> = OptNone | OptSome<T>;
const isSome = <T>(v: OptType<T>): v is OptSome<T> =>
'value' in v && v.type === OptionType.Some;
/**
* Rust-style optional type
*
* Based on https://gist.github.com/s-panferov/575da5a7131c285c0539
*/
export class Option<T> {
/**
* The placeholder for the 'None' value type
*/
private static _None: Option<any> = new Option(null);
/**
* Is this a 'Some' or a 'None'?
*/
private readonly inner: OptType<T>;
private constructor(v?: T) {
this.inner = (v !== undefined && v !== null)
? new OptSome(v)
: new OptNone();
}
/**
* The equivalent of the Rust `Option`.`None` enum value
*/
public static get None(): Option<any> {
return Option._None;
}
/**
* The equivalent of the Rust `Option`.`Some` enum value
*
* If the value passed is null or undefined, this will throw an Error
*
* @param v The value to wrap
*/
public static Some<X>(v: any): Option<X> | never {
const maybeSome: Option<X> = Option.from(v);
if (maybeSome.isNone()) {
throw new Error('Cannot create Some<T> with an empty value');
} else {
return maybeSome;
}
}
/**
* Create a new `Option`
*
* If the value is null or undefined, the `Option` will have a `None` type
*
* @param v The value to wrap
*/
public static from<X>(v?: any): Option<X> {
return (isOption(v)) ? Option.from(v.unwrap()) : new Option(v);
}
/**
* The wrapped value is not null or undefined
*/
public isSome(): boolean {
return isSome(this.inner);
}
/**
* The wrapped value is null or undefined
*/
public isNone(): boolean {
return !this.isSome();
}
/**
* Check if the current `Option` is `Some`. If it is,
* return the value of the passed function.
*
* Otherwise, returns false
*
* @param fn A boolean check to run on the wrapped value
*/
public isSomeAnd(fn: (a: T) => boolean): boolean {
return isSome(this.inner) ? fn(this.inner.value) : false;
}
/**
* Check if the current `Option` is `None`. If it is,
* return the value of the passed function.
*
* Otherwise, return false
*
* @param fn A function returning a boolean value
*/
public isNoneAnd(fn: () => boolean): boolean {
return this.isNone() ? fn() : false;
}
/**
* Transform the inner value of the `Option` with the passed function.
* If this `Option` is `Some`, a new `Option` with the transformed value
* is returned. Otherwise `None` is returned
*
* @param fn A function that takes the inner value of the `Option` and returns a new one
*/
public map<U>(fn: (a: T) => U): Option<U> {
return isSome(this.inner) ? Option.from(fn(this.inner.value)) : Option.None;
}
/**
* If this `Option` is `Some`, return the transformed inner value via the passed function.
*
* Otherwise, return the passed default value
*
* @param def The default value to return if this `Option` is `None`
* @param fn A function that takes the inner value of this `Option` and returns a new value
*/
public mapOr<U>(def: U, fn: (a: T) => U): U {
return isSome(this.inner) ? fn(this.inner.value) : def;
}
/**
* If this `Option` is `Some`, return the transformed inner value via the passed function (fn).
*
* Otherwise run the function (def) to return a value
*
* @param def A function to return a value if this `Option` is `None`
* @param fn A function that takes the inner value of this `Option` and returns a new value
*/
public mapOrElse<U>(def: () => U, fn: (a: T) => U): U {
return isSome(this.inner) ? fn(this.inner.value) : def();
}
/**
* Return the inner value if not `None`.
* Otherwise, throw a new exception with the passed message
*
* @param err
*/
public assert(err: string): T | never {
if (isSome(this.inner)) {
return this.inner.value;
}
throw Error(err);
}
/**
* Return the inner value if is `Some<T>`.
*
* If `None`, throws an exception.
*/
public unwrap(): T | never {
return this.assert("Called unwrap on a 'None'");
}
/**
* Return the inner value if is `Some<T>`,
* Otherwise, return the passed default value
*
* @param def Value to return on `None` value
*/
public unwrapOr(def: T): T {
return isSome(this.inner) ? this.inner.value : def;
}
/**
* Return the inner value if is `Some<T>`,
* Otherwise, return the value generated by the passed function
*
* @param f Function to run on `None` value
*/
public unwrapOrElse(f: () => T): T {
return isSome(this.inner) ? this.inner.value : f();
}
/**
* Check if this `Option` and the passed option are both `Some`,
* otherwise return `None`
*
* @param optb Another `Option` to check
*/
public and<U>(optb: Option<U>): Option<U> {
return isSome(this.inner) ? optb : Option.None;
}
/**
* Check if this `Option` is `Some`. If it is, run the passed
* function with the wrapped value.
*
* Otherwise, return None
*
* @param f function to run on the wrapped value
*/
public andThen<U>(f: (a: T) => Option<U>): Option<U> {
return isSome(this.inner) ? f(this.inner.value) : Option.None;
}
/**
* Check if this `Option` is `None`. If it is, return the passed option.
*
* @param optb The `Option` to return if this `Option` is `None`
*/
public or(optb: Option<T>): Option<T> {
return this.isNone() ? optb : this;
}
/**
* Check if this `Option` is `None`. If it is, return the passed function.
*
* Otherwise, return this `Option`
*
* @param f A function to return a different `Option`
*/
public orElse(f: () => Option<T>): Option<T> {
return this.isNone() ? f() : this;
}
/**
* Create a string representation of the `Option`,
* mostly for debugging
*/
public toString(): string {
const innerValue = (isSome(this.inner))
? JSON.stringify(this.inner.value)
: '';
const prefix = this.inner.type.valueOf();
return (innerValue.length > 0) ? `${prefix} (${innerValue})` : prefix;
}
}
export const { Some, None } = Option;
export default Option;

View File

@ -4,14 +4,23 @@
export class Position { export class Position {
private constructor(public x: number = 0, public y: number = 0) {} private constructor(public x: number = 0, public y: number = 0) {}
/**
* Create a new `Position` at the specified location
*/
public static at(x: number, y: number): Position { public static at(x: number, y: number): Position {
return new Position(x, y); return new Position(x, y);
} }
/**
* Create a new `Position` from an existing one
*/
public static from(p: Position): Position { public static from(p: Position): Position {
return new Position(p.x, p.y); return new Position(p.x, p.y);
} }
/**
* Create a new `Position` at the origin (0, 0)
*/
public static default(): Position { public static default(): Position {
return new Position(); return new Position();
} }

View File

@ -1,8 +1,22 @@
import { SCROLL_TAB_SIZE } from './config.ts';
import { arrayInsert, isAsciiDigit, some, strChars } from './fns.ts';
import { highlightToColor, HighlightType } from './highlight.ts';
import Ansi from './ansi.ts'; import Ansi from './ansi.ts';
import { SCROLL_TAB_SIZE } from './config.ts';
import {
arrayInsert,
highlightToColor,
isAsciiDigit,
isSeparator,
strChars,
strlen,
substr,
} from './fns.ts';
import { FileType } from './filetype.ts';
import Option, { None, Some } from './option.ts';
import { HighlightType, SearchDirection } from './types.ts';
const SINGLE_QUOTE = "'";
const DOUBLE_QUOTE = '"';
/** /**
* One row of text in the current document. In order to handle * One row of text in the current document. In order to handle
* multi-byte graphemes, all operations are done on an * multi-byte graphemes, all operations are done on an
@ -25,27 +39,47 @@ export class Row {
*/ */
public hl: HighlightType[] = []; public hl: HighlightType[] = [];
/**
* Has the current row been highlighted?
*/
public isHighlighted: boolean = false;
private constructor(s: string | string[] = '') { private constructor(s: string | string[] = '') {
this.chars = Array.isArray(s) ? s : strChars(s); this.chars = Array.isArray(s) ? s : strChars(s);
this.rchars = []; this.rchars = [];
} }
/**
* Get the number of 'characters' in this row
*/
public get size(): number { public get size(): number {
return this.chars.length; return this.chars.length;
} }
/**
* Get the number of 'characters' in the 'render' array
*/
public get rsize(): number { public get rsize(): number {
return this.rchars.length; return this.rchars.length;
} }
/**
* Get the 'render' string
*/
public rstring(offset: number = 0): string { public rstring(offset: number = 0): string {
return this.rchars.slice(offset).join(''); return this.rchars.slice(offset).join('');
} }
/**
* Create a new empty Row
*/
public static default(): Row { public static default(): Row {
return new Row(); return new Row();
} }
/**
* Create a new Row
*/
public static from(s: string | string[] | Row): Row { public static from(s: string | string[] | Row): Row {
if (s instanceof Row) { if (s instanceof Row) {
return s; return s;
@ -54,11 +88,17 @@ export class Row {
return new Row(s); return new Row(s);
} }
public append(s: string): void { /**
* Add a character to the end of the current row
*/
public append(s: string, syntax: FileType): void {
this.chars = this.chars.concat(strChars(s)); this.chars = this.chars.concat(strChars(s));
this.update(); this.update(None, syntax);
} }
/**
* Add a character to the current row at the specified location
*/
public insertChar(at: number, c: string): void { public insertChar(at: number, c: string): void {
const newSlice = strChars(c); const newSlice = strChars(c);
if (at >= this.size) { if (at >= this.size) {
@ -71,10 +111,10 @@ export class Row {
/** /**
* Truncate the current row, and return a new one at the specified index * Truncate the current row, and return a new one at the specified index
*/ */
public split(at: number): Row { public split(at: number, syntax: FileType): Row {
const newRow = new Row(this.chars.slice(at)); const newRow = new Row(this.chars.slice(at));
this.chars = this.chars.slice(0, at); this.chars = this.chars.slice(0, at);
this.update(); this.update(None, syntax);
return newRow; return newRow;
} }
@ -92,25 +132,48 @@ export class Row {
/** /**
* Search the current row for the specified string, and return * Search the current row for the specified string, and return
* the index of the start of that match * the 'character' index of the start of that match
*/ */
public find(s: string, offset: number = 0): number | null { public find(
const thisStr = this.toString(); s: string,
if (!this.toString().includes(s)) { at: number = 0,
return null; direction: SearchDirection = SearchDirection.Forward,
): Option<number> {
if (at > this.size) {
return None;
} }
const thisStr = this.chars.join('');
const byteCount = thisStr.indexOf(s, this.charIndexToByteIndex(offset)); // Look for the search query `s`, starting from the 'character' `offset`
const byteIndex = (direction === SearchDirection.Forward)
? thisStr.indexOf(s, this.charIndexToByteIndex(at))
: thisStr.lastIndexOf(s, this.charIndexToByteIndex(at));
// No match after the specified offset
if (byteIndex < 0) {
return None;
}
// In many cases, the string length will // In many cases, the string length will
// equal the number of characters. So // equal the number of characters. So
// searching is fairly easy // searching is fairly easy
if (thisStr.length === this.chars.length) { if (thisStr.length === this.chars.length) {
return byteCount; return Some(byteIndex);
} }
// Emoji/Extended Unicode-friendly search // Emoji/Extended Unicode-friendly search
return this.byteIndexToCharIndex(byteCount); return Some(this.byteIndexToCharIndex(byteIndex));
}
/**
* Search the current Row for the given string, returning the index in
* the 'render' version
*/
public rIndexOf(s: string, offset: number = 0): Option<number> {
const rstring = this.rchars.join('');
const byteIndex = rstring.indexOf(s, this.charIndexToByteIndex(offset));
return (byteIndex >= 0) ? Some(this.byteIndexToCharIndex(byteIndex)) : None;
} }
/** /**
@ -179,44 +242,447 @@ export class Row {
return charIndex; return charIndex;
} }
// The char index will be the same size or smaller than
// the JS string index, as a 'character' can consist
// of multiple JS string indicies
return this.chars.slice(0, charIndex).reduce( return this.chars.slice(0, charIndex).reduce(
(prev, current) => prev += current.length, (prev, current) => prev + current.length,
0, 0,
); );
} }
/**
* Output the contents of the row
*/
public toString(): string { public toString(): string {
return this.chars.join(''); return this.chars.join('');
} }
public update(searchMatch?: string): void { /**
* Setup up the row by converting tabs to spaces for rendering,
* then setup syntax highlighting
*/
public update(
word: Option<string>,
syntax: FileType,
startWithComment: boolean = false,
): boolean {
const newString = this.chars.join('').replaceAll( const newString = this.chars.join('').replaceAll(
'\t', '\t',
' '.repeat(SCROLL_TAB_SIZE), ' '.repeat(SCROLL_TAB_SIZE),
); );
this.rchars = strChars(newString); this.rchars = strChars(newString);
this.highlight(searchMatch); return this.highlight(word, syntax, startWithComment);
} }
public highlight(searchMatch?: string): void { /**
const highlighting = []; * Calculate the syntax types of the current Row
*/
if (some(searchMatch)) { public highlight(
// TODO: highlight search here word: Option<string>,
syntax: FileType,
startWithComment: boolean,
): boolean {
// When the highlighting is already up-to-date
if (this.isHighlighted && word.isNone()) {
return false;
} }
for (const ch of this.rchars) { this.hl = [];
if (isAsciiDigit(ch)) { let i = 0;
highlighting.push(HighlightType.Number);
} else { // Handle the case where we are in a multi-line
highlighting.push(HighlightType.None); // comment from a previous row
let inMlComment = startWithComment;
if (inMlComment && syntax.hasMultilineComments()) {
const maybeEnd = this.rIndexOf(syntax.multiLineCommentEnd.unwrap(), i);
const closingIndex = (maybeEnd.isSome())
? maybeEnd.unwrap() + 2
: this.rsize;
for (; i < closingIndex; i++) {
this.hl.push(HighlightType.MultiLineComment);
}
i = closingIndex;
}
for (; i < this.rsize;) {
const maybeMultiline = this.highlightMultilineComment(i, syntax);
if (maybeMultiline.isSome()) {
inMlComment = true;
i = maybeMultiline.unwrap();
continue;
}
inMlComment = false;
// Go through the syntax highlighting types in order:
// If there is a match, we end the chain of syntax types
// and 'consume' the number of characters that matched
const maybeNext = this.highlightComment(i, syntax)
.orElse(() => this.highlightPrimaryKeywords(i, syntax))
.orElse(() => this.highlightSecondaryKeywords(i, syntax))
.orElse(() => this.highlightString(i, syntax))
.orElse(() => this.highlightCharacter(i, syntax))
.orElse(() => this.highlightNumber(i, syntax))
.orElse(() => this.highlightOperators(i, syntax));
if (maybeNext.isNone()) {
this.hl.push(HighlightType.None);
i += 1;
continue;
}
const next = maybeNext.unwrap();
if (next >= this.rsize) {
break;
}
i = next;
}
this.highlightMatch(word);
if (inMlComment && syntax.hasMultilineComments()) {
if (
substr(this.toString(), this.size - 2) !==
syntax.multiLineCommentEnd.unwrap()
) {
return true;
}
}
this.isHighlighted = true;
return false;
}
protected highlightMatch(word: Option<string>): void {
let searchIndex = 0;
// Find matches for the current search
if (word.isSome()) {
const query = word.unwrap();
while (true) {
const match = this.find(
query,
searchIndex,
SearchDirection.Forward,
);
if (match.isNone()) {
break;
}
const index = match.unwrap();
const matchSize = strlen(query);
const nextPossible = index + matchSize;
if (nextPossible < this.rsize) {
let i = index;
for (const _ in strChars(word.unwrap())) {
this.hl[i] = HighlightType.Match;
i += 1;
}
searchIndex = nextPossible;
} else {
break;
}
}
}
}
protected highlightComment(
i: number,
syntax: FileType,
): Option<number> {
// Highlight single-line comments
if (syntax.singleLineComment.isSome()) {
const commentStart = syntax.singleLineComment.unwrap();
const hasCommentStart = this.rIndexOf(commentStart).isSome();
if (
hasCommentStart && this.rIndexOf(commentStart).unwrap() === i
) {
for (; i < this.rsize; i++) {
this.hl.push(HighlightType.SingleLineComment);
}
return Some(i);
} }
} }
this.hl = highlighting; return None;
} }
private highlightStr(
i: number,
substring: string,
hl_type: HighlightType,
): Option<number> {
if (strlen(substring) === 0) {
return None;
}
const substringChars = strChars(substring);
for (const [j, ch] of substringChars.entries()) {
const nextChar = this.rchars[i + j];
if (nextChar !== ch) {
return None;
}
}
for (const _ of substringChars) {
this.hl.push(hl_type);
i += 1;
}
return Some(i);
}
private highlightKeywords(
i: number,
keywords: string[],
hl_type: HighlightType,
): Option<number> {
if (i > 0) {
const prevChar = this.rchars[i - 1];
if (!isSeparator(prevChar)) {
return None;
}
}
for (const keyword of keywords) {
// Skip keywords that can't fit in the current line
if (i + strlen(keyword) >= this.rsize) {
continue;
}
// Check the character after the keyword
// if it is not a 'separator' character,
// we must be highlighting the middle of something else
const nextChar = this.rchars[i + strlen(keyword)];
if (!isSeparator(nextChar)) {
continue;
}
const maybeHighlight = this.highlightStr(i, keyword, hl_type);
if (maybeHighlight.isSome()) {
return maybeHighlight;
}
}
return None;
}
protected highlightPrimaryKeywords(
i: number,
syntax: FileType,
): Option<number> {
return this.highlightKeywords(
i,
syntax.primaryKeywords,
HighlightType.Keyword1,
);
}
protected highlightSecondaryKeywords(
i: number,
syntax: FileType,
): Option<number> {
return this.highlightKeywords(
i,
syntax.secondaryKeywords,
HighlightType.Keyword2,
);
}
protected highlightOperators(
i: number,
syntax: FileType,
): Option<number> {
// Search the list of operators
outer: for (const op of syntax.operators) {
const chars = strChars(op);
// See if this operator (chars[j]) exists at this index
for (const [j, ch] of chars.entries()) {
// Make sure the next character of this operator matches too
const nextChar = this.rchars[i + j];
if (nextChar !== ch) {
continue outer;
}
}
// This operator matches, highlight it
for (const _ of chars) {
this.hl.push(HighlightType.Operator);
i += 1;
}
return Some(i);
}
return None;
}
protected highlightCharacter(
i: number,
syntax: FileType,
): Option<number> {
if (!syntax.flags.characters) {
return None;
}
// Highlight character literals
const ch = this.rchars[i];
if (ch === SINGLE_QUOTE && this.rIndexOf(SINGLE_QUOTE, i + 1).isSome()) {
while (true) {
this.hl.push(HighlightType.Character);
i += 1;
if (i === this.rsize) {
break;
}
const nextChar = this.rchars[i];
// Make sure to continue highlighting if
// you have an escaped character delimeter
if (nextChar === '\\') {
this.hl.push(HighlightType.Character);
i += 1;
continue;
}
if (nextChar === ch) {
break;
}
}
this.hl.push(HighlightType.Character);
i += 1;
return Some(i);
}
return None;
}
protected highlightString(
i: number,
syntax: FileType,
): Option<number> {
if (!syntax.flags.strings) {
return None;
}
// Highlight strings
const ch = this.rchars[i];
if (
ch === DOUBLE_QUOTE ||
((!syntax.flags.characters) && ch === SINGLE_QUOTE)
) {
while (true) {
this.hl.push(HighlightType.String);
i += 1;
if (i === this.rsize) {
break;
}
const nextChar = this.rchars[i];
if (nextChar === ch) {
break;
}
}
this.hl.push(HighlightType.String);
i += 1;
return Some(i);
}
return None;
}
protected highlightMultilineComment(
i: number,
syntax: FileType,
): Option<number> {
if (!syntax.hasMultilineComments()) {
return None;
}
const ch = this.rchars[i];
const startChars = syntax.multiLineCommentStart.unwrap();
const endChars = syntax.multiLineCommentEnd.unwrap();
if (ch === startChars[0] && this.rchars[i + 1] == startChars[1]) {
const maybeEnd = this.rIndexOf(endChars, i);
const end = (maybeEnd.isSome())
? maybeEnd.unwrap() + strlen(endChars) + 2
: this.rsize;
for (; i <= end; i++) {
this.hl.push(HighlightType.MultiLineComment);
}
return Some(i);
}
return None;
}
protected highlightNumber(
i: number,
syntax: FileType,
): Option<number> {
// Exit early
const ch = this.rchars[i];
if (!(syntax.flags.numbers && isAsciiDigit(ch))) {
return None;
}
// Configure which characters are valid
// for numbers in the current FileType
let validChars = ['.'];
if (syntax.flags.binNumbers) {
validChars = validChars.concat(['b', 'B']);
}
if (syntax.flags.octalNumbers) {
validChars = validChars.concat(['o', 'O']);
}
if (syntax.flags.hexNumbers) {
// deno-fmt-ignore
validChars = validChars.concat([
'a','A',
'b','B',
'c','C',
'd','D',
'e','E',
'f','F',
'x','X',
]);
}
if (syntax.flags.jsBigInt) {
validChars.push('n');
}
// Number literals are not attached to other syntax
if (i > 0 && !isSeparator(this.rchars[i - 1])) {
return None;
}
// Match until the end of the number literal
while (true) {
this.hl.push(HighlightType.Number);
i += 1;
if (i >= this.rsize) {
break;
}
const nextChar = this.rchars[i];
if (
!(validChars.includes(nextChar) || isAsciiDigit(nextChar))
) {
break;
}
}
return Some(i);
}
/**
* Return a terminal-formatted version of the current row
*/
public render(offset: number, len: number): string { public render(offset: number, len: number): string {
const end = Math.min(len, this.rsize); const end = Math.min(len, this.rsize);
const start = Math.min(offset, len); const start = Math.min(offset, len);

View File

@ -1,141 +1,11 @@
import process from 'node:process'; export * from './runtime/file_io.ts';
import { IRuntime, ITestBase } from './types.ts'; export * from './runtime/helpers.ts';
import { noop } from './fns.ts'; export * from './runtime/log.ts';
import { SCROLL_ERR_FILE, SCROLL_LOG_FILE } from './config.ts'; export * from './runtime/node.ts';
export * from './runtime/runtime.ts';
export * from './runtime/terminal_io.ts';
export * from './runtime/test_base.ts';
export { RunTimeType } from './types.ts';
export type { IFileIO, IRuntime, ITerminal } from './types.ts'; import { CommonRuntime } from './runtime/runtime.ts';
export default CommonRuntime;
/**
* Which Typescript runtime is currently being used
*/
export enum RunTimeType {
Bun = 'bun',
Deno = 'deno',
Unknown = 'common',
}
export enum LogLevel {
Debug = 'Debug',
Info = 'Info',
Notice = 'Notice',
Warning = 'Warning',
Error = 'Error',
}
let scrollRuntime: IRuntime | null = null;
// ----------------------------------------------------------------------------
// Misc runtime functions
// ----------------------------------------------------------------------------
export function log(s: unknown, level: LogLevel = LogLevel.Notice): void {
getRuntime().then(({ file }) => {
const raw = JSON.stringify(s, null, 2);
const output = `${level}: ${raw}\n`;
const outputFile = (level === LogLevel.Error)
? SCROLL_ERR_FILE
: SCROLL_LOG_FILE;
file.appendFile(outputFile, output).then(noop);
});
}
/**
* Append information to the scroll.err logfile
*/
export function logError(s: unknown): void {
log(s, LogLevel.Error);
}
/**
* Kill program, displaying an error message
* @param s
*/
export function die(s: string | Error): void {
logError(s);
process.stdin.setRawMode(false);
console.error(s);
getRuntime().then((r) => r.exit());
}
/**
* Determine which Typescript runtime we are operating under
*/
export function runtimeType(): RunTimeType {
let runtime = RunTimeType.Unknown;
if ('Deno' in globalThis) {
runtime = RunTimeType.Deno;
}
if ('Bun' in globalThis) {
runtime = RunTimeType.Bun;
}
return runtime;
}
/**
* Get the adapter object for the current Runtime
*/
export async function getRuntime(): Promise<IRuntime> {
if (scrollRuntime === null) {
const runtime = runtimeType();
const path = `../${runtime}/mod.ts`;
const pkg = await import(path);
if ('default' in pkg) {
scrollRuntime = pkg.default;
}
if (scrollRuntime !== null) {
return Promise.resolve(scrollRuntime);
}
return Promise.reject('Missing default import');
}
return Promise.resolve(scrollRuntime);
}
/**
* Get the common test interface object
*/
export async function getTestRunner(): Promise<ITestBase> {
const runtime = runtimeType();
const path = `../${runtime}/test_base.ts`;
const pkg = await import(path);
if ('default' in pkg) {
return pkg.default;
}
return pkg;
}
/**
* Import a runtime-specific module
*
* e.g. to load "src/bun/mod.ts", if the runtime is bun,
* you can use like so `await importForRuntime('index')`;
*
* @param path - the path within the runtime module
*/
export const importForRuntime = async (path: string) => {
const runtime = runtimeType();
const suffix = '.ts';
const base = `../${runtime}/`;
const pathParts = path
.split('/')
.filter((part) => part !== '' && part !== '.' && part !== suffix)
.map((part) => part.replace(suffix, ''));
const cleanedPath = pathParts.join('/');
const importPath = base + cleanedPath + suffix;
const pkg = await import(importPath);
if ('default' in pkg) {
return pkg.default;
}
return pkg;
};

View File

@ -0,0 +1,28 @@
import { appendFile, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { IFileIO } from './runtime.ts';
/**
* File IO implementation using shared node APIs
*/
export const CommonFileIO: IFileIO = {
openFile: async function (path: string): Promise<string> {
const filePath = resolve(path);
const contents = await readFile(filePath, { encoding: 'utf8' });
return contents;
},
appendFile: async function (path: string, contents: string): Promise<void> {
const filePath = resolve(path);
await appendFile(filePath, contents);
},
saveFile: async function (path: string, contents: string): Promise<void> {
const filePath = resolve(path);
await writeFile(filePath, contents);
},
};
export default CommonFileIO;

View File

@ -0,0 +1,82 @@
/**
* Functions/Methods that depend on the current runtime to function
*/
import { logError, logWarning } from './log.ts';
import { node_path as path, node_process as process } from './node.ts';
import { IRuntime } from './runtime.ts';
import { ITestBase } from './test_base.ts';
import { RunTimeType } from '../types.ts';
let scrollRuntime: IRuntime | null = null;
/**
* Kill program, displaying an error message
* @param s
*/
export function die(s: string | Error): void {
logError(s);
process.stdin.setRawMode(false);
console.error(s);
getRuntime().then((r) => r.exit());
}
/**
* Determine which Typescript runtime we are operating under
*/
export function runtimeType(): RunTimeType {
const cmd = path.basename(process.argv[0]);
switch (cmd) {
case 'deno':
return RunTimeType.Deno;
case 'bun':
return RunTimeType.Bun;
case 'node':
return RunTimeType.Tsx;
default:
logWarning('Fallback runtime detection', { cmd });
if ('bun' in globalThis) {
return RunTimeType.Bun;
}
return RunTimeType.Tsx;
}
}
/**
* Get the adapter object for the current Runtime
*/
export async function getRuntime(): Promise<IRuntime> {
if (scrollRuntime === null) {
const runtime = runtimeType();
const path = `../../${runtime}/mod.ts`;
const pkg = await import(path);
if ('default' in pkg) {
scrollRuntime = pkg.default;
}
if (scrollRuntime !== null) {
return Promise.resolve(scrollRuntime);
}
return Promise.reject('Missing default import');
}
return Promise.resolve(scrollRuntime);
}
/**
* Get the common test interface object
*/
export async function getTestRunner(): Promise<ITestBase> {
const runtime = runtimeType();
const path = `../../${runtime}/test_base.ts`;
const pkg = await import(path);
if ('default' in pkg) {
return pkg.default;
}
return pkg;
}

68
src/common/runtime/log.ts Normal file
View File

@ -0,0 +1,68 @@
import { noop } from '../fns.ts';
import { SCROLL_LOG_FILE_PREFIX, SCROLL_LOG_FILE_SUFFIX } from '../config.ts';
import { getRuntime } from './helpers.ts';
/**
* The label for type/severity of the log entry
*/
export enum LogLevel {
Debug = 'Debug',
Info = 'Info',
Notice = 'Notice',
Warning = 'Warning',
Error = 'Error',
}
/**
* Basic logging
*
* @param s The string or data to display first
* @param level The log severity
* @param data Any additional data to display with the log entry
*/
export function log(
s: unknown,
level: LogLevel = LogLevel.Notice,
data?: any,
): void {
getRuntime().then(({ file }) => {
const rawS = JSON.stringify(s, null, 2);
const rawData = JSON.stringify(data, null, 2);
const output = (typeof data !== 'undefined')
? `${rawS}\n${rawData}\n\n`
: `${rawS}\n`;
const outputFile =
`${SCROLL_LOG_FILE_PREFIX}-${level.toLowerCase()}${SCROLL_LOG_FILE_SUFFIX}`;
file.appendFile(outputFile, output).then(noop);
});
}
/**
* Make a log entry of `LogLevel.Debug` severity
*/
export const logDebug = (s: unknown, data?: any) =>
log(s, LogLevel.Debug, data);
/**
* Make a log entry of `LogLevel.Info` severity
*/
export const logInfo = (s: unknown, data?: any) => log(s, LogLevel.Info, data);
/**
* Make a log entry of `LogLevel.Notice` severity
*/
export const logNotice = (s: unknown, data?: any) =>
log(s, LogLevel.Notice, data);
/**
* Make a log entry of `LogLevel.Warning` severity
*/
export const logWarning = (s: unknown, data?: any) =>
log(s, LogLevel.Warning, data);
/**
* Make a log entry of `LogLevel.Error` severity
*/
export const logError = (s: unknown, data?: any) =>
log(s, LogLevel.Warning, data);

View File

@ -0,0 +1,9 @@
/**
* Re-export of node apis shared by runtimes
*/
import node_assert from 'node:assert';
import node_path from 'node:path';
import node_process from 'node:process';
import node_tty from 'node:tty';
export { node_assert, node_path, node_process, node_tty };

View File

@ -0,0 +1,139 @@
import { node_process as process } from './node.ts';
import CommonFileIO from './file_io.ts';
import CommonTerminalIO from './terminal_io.ts';
import { RunTimeType } from '../types.ts';
// ----------------------------------------------------------------------------
// Runtime adapter interfaces
// ----------------------------------------------------------------------------
/**
* The size of terminal in rows and columns
*/
export interface ITerminalSize {
rows: number;
cols: number;
}
/**
* Runtime-specific terminal functionality
*/
export interface ITerminal {
/**
* The arguments passed to the program on launch
*/
argv: string[];
/**
* The generator function returning chunks of input from the stdin stream
*/
inputLoop(): AsyncGenerator<Uint8Array, null>;
/**
* Get the size of the terminal
*/
getTerminalSize(): Promise<ITerminalSize>;
/**
* Get the current chunk of input, if it exists
*/
readStdin(): Promise<string | null>;
/**
* Get the raw chunk of input
*/
readStdinRaw(): Promise<Uint8Array | null>;
/**
* Pipe a string to stdout
*/
writeStdout(s: string): Promise<void>;
}
/**
* Runtime-specific file system io
*/
export interface IFileIO {
/**
* Open an entire file
*
* @param path
*/
openFile(path: string): Promise<string>;
/**
* Append to a file, or create it if it doesn't exist
*
* @param path
* @param contents
*/
appendFile(path: string, contents: string): Promise<void>;
/**
* Save a string into a file
*
* @param path
* @param contents
*/
saveFile(path: string, contents: string): Promise<void>;
}
/**
* The common interface for runtime adapters
*/
export interface IRuntime {
/**
* The name of the runtime
*/
name: RunTimeType;
/**
* Runtime-specific terminal functionality
*/
term: ITerminal;
/**
* Runtime-specific file system io
*/
file: IFileIO;
/**
* Set up an event handler
*
* @param eventName - The event to listen for
* @param handler - The event handler
*/
onEvent: (
eventName: string,
handler: (e: Event | ErrorEvent) => void,
) => void;
/**
* Set a beforeExit/beforeUnload event handler for the runtime
* @param cb - The event handler
*/
onExit(cb: () => void): void;
/**
* Stop execution
*
* @param code
*/
exit(code?: number): void;
}
/**
* Base runtime using shared Node APIs
*/
export const CommonRuntime: IRuntime = {
name: RunTimeType.Unknown,
term: CommonTerminalIO,
file: CommonFileIO,
onEvent: (eventName: string, handler) => process.on(eventName, handler),
onExit: (cb: () => void): void => {
process.on('beforeExit', cb);
process.on('exit', cb);
process.on('SIGINT', cb);
},
exit: (code?: number) => process.exit(code),
};

View File

@ -0,0 +1,44 @@
import { node_process as process } from './node.ts';
import { readKey } from '../fns.ts';
import { ITerminal, ITerminalSize } from '../types.ts';
/**
* Terminal IO using shared Node APIs
*/
export const CommonTerminalIO: ITerminal = {
argv: (process.argv.length > 2) ? process.argv.slice(2) : [],
inputLoop: async function* (): AsyncGenerator<Uint8Array, null> {
yield (await CommonTerminalIO.readStdinRaw()) ?? new Uint8Array(0);
return null;
},
getTerminalSize: function getTerminalSize(): Promise<ITerminalSize> {
const [cols, rows] = process.stdout.getWindowSize();
return Promise.resolve({
rows,
cols,
});
},
readStdin: async function (): Promise<string | null> {
const raw = await CommonTerminalIO.readStdinRaw();
return readKey(raw ?? new Uint8Array(0));
},
readStdinRaw: function (): Promise<Uint8Array | null> {
return new Promise((resolve) => {
process.stdin.resume().once(
'data',
(buffer: Uint8Array) => {
process.stdin.pause();
resolve(buffer);
},
);
});
},
writeStdout: function (s: string): Promise<void> {
process.stdout.write(s);
return Promise.resolve();
},
};
export default CommonTerminalIO;

View File

@ -0,0 +1,95 @@
/**
* Adapt the node test interface to the shared testing interface
*/
import { deepStrictEqual, notStrictEqual, strictEqual } from 'node:assert';
import Option from '../option.ts';
/**
* The shared interface for tests, running on a different test
* runner for each runtime
*/
export interface ITestBase {
assertEquivalent(actual: unknown, expected: unknown): void;
assertExists(actual: unknown): void;
assertInstanceOf(actual: unknown, expectedType: any): void;
assertNotEquals(actual: unknown, expected: unknown): void;
assertEquals(actual: unknown, expected: unknown): void;
assertTrue(actual: boolean): void;
assertFalse(actual: boolean): void;
assertSome<T>(actual: Option<T>): void;
assertNone<T>(actual: Option<T>): void;
/**
* Convert the nested test object into a test suite for the current runtime
*/
testSuite(testObj: any): void;
}
/**
* The base testing implementation using Node assert API
*/
export abstract class AbstractTestBase implements Partial<ITestBase> {
/**
* The values (often objects) have all the same property values
*/
public static assertEquivalent(actual: unknown, expected: unknown): void {
return deepStrictEqual(actual, expected);
}
/**
* The value is not null or undefined
*/
public static assertExists(actual: unknown): void {
return notStrictEqual(actual, undefined);
}
/**
* `actual` is an object implementing `expectedType`
*/
public static assertInstanceOf(actual: unknown, expectedType: any): void {
return strictEqual(actual instanceof expectedType, true);
}
/**
* The values are not exactly equal (Different instance, type, value, etc)
*/
public static assertNotEquals(actual: unknown, expected: unknown): void {
return notStrictEqual(actual, expected);
}
/**
* The values are exactly the same
*/
public static assertEquals(actual: unknown, expected: unknown): void {
return strictEqual(actual, expected);
}
/**
* The value is true
*/
public static assertTrue(actual: boolean): void {
return strictEqual(actual, true);
}
/**
* The value is false
*/
public static assertFalse(actual: boolean): void {
return strictEqual(actual, false);
}
/**
* The value is a `Some` type `Option`
*/
public static assertSome<T>(actual: Option<T>): void {
return AbstractTestBase.assertTrue(actual.isSome());
}
/**
* The value is a `None` type `Option`
*/
public static assertNone<T>(actual: Option<T>): void {
return AbstractTestBase.assertTrue(actual.isNone());
}
}
export default AbstractTestBase;

View File

@ -1,76 +0,0 @@
import { Position } from './types.ts';
import { KeyCommand } from './ansi.ts';
import Document from './document.ts';
enum SearchDirection {
Forward = 1,
Backward = -1,
}
export class Search {
private lastMatch: number = -1;
private current: number = -1;
private direction: SearchDirection = SearchDirection.Forward;
public parent: Document | null = null;
private parseInput(key: string) {
switch (key) {
case KeyCommand.ArrowRight:
case KeyCommand.ArrowDown:
this.direction = SearchDirection.Forward;
break;
case KeyCommand.ArrowLeft:
case KeyCommand.ArrowUp:
this.direction = SearchDirection.Backward;
break;
default:
this.lastMatch = -1;
this.direction = SearchDirection.Forward;
}
if (this.lastMatch === -1) {
this.direction = SearchDirection.Forward;
}
this.current = this.lastMatch;
}
private getNextRow(rowCount: number): number {
this.current += this.direction;
if (this.current === -1) {
this.current = rowCount - 1;
} else if (this.current === rowCount) {
this.current = 0;
}
return this.current;
}
public search(q: string, key: string): Position | null {
if (this.parent === null) {
return null;
}
this.parseInput(key);
let i = 0;
for (; i < this.parent.numRows; i++) {
const current = this.getNextRow(this.parent.numRows);
const row = this.parent.row(current);
if (row === null) {
continue;
}
const possible = row.find(q);
if (possible !== null) {
this.lastMatch = current;
return Position.at(possible, current);
}
}
return null;
}
}

View File

@ -1,122 +1,47 @@
import { RunTimeType } from './runtime.ts';
export { Position } from './position.ts'; export { Position } from './position.ts';
export type { ITestBase } from './runtime/test_base.ts';
export type { IFileIO, IRuntime, ITerminal, ITerminalSize } from './runtime.ts';
/** /**
* The size of terminal in rows and columns * Which Typescript runtime is currently being used
*/ */
export interface ITerminalSize { export enum RunTimeType {
rows: number; Bun = 'bun',
cols: number; Deno = 'deno',
} Tsx = 'tsx',
Unknown = 'unknown',
// ----------------------------------------------------------------------------
// Runtime adapter interfaces
// ----------------------------------------------------------------------------
/**
* The common interface for runtime adapters
*/
export interface IRuntime {
/**
* The name of the runtime
*/
name: RunTimeType;
/**
* Runtime-specific terminal functionality
*/
term: {
/**
* The arguments passed to the program on launch
*/
argv: string[];
/**
* The generator function returning chunks of input from the stdin stream
*/
inputLoop(): AsyncGenerator<Uint8Array, null>;
/**
* Get the size of the terminal
*/
getTerminalSize(): Promise<ITerminalSize>;
/**
* Get the current chunk of input, if it exists
*/
readStdin(): Promise<string | null>;
/**
* Get the raw chunk of input
*/
readStdinRaw(): Promise<Uint8Array | null>;
/**
* Pipe a string to stdout
*/
writeStdout(s: string): Promise<void>;
};
/**
* Runtime-specific file system io
*/
file: {
openFile(path: string): Promise<string>;
appendFile(path: string, contents: string): Promise<void>;
saveFile(path: string, contents: string): Promise<void>;
};
/**
* Set up an event handler
*
* @param eventName - The event to listen for
* @param handler - The event handler
*/
onEvent: (
eventName: string,
handler: (e: Event | ErrorEvent) => void,
) => void;
/**
* Set a beforeExit/beforeUnload event handler for the runtime
* @param cb - The event handler
*/
onExit(cb: () => void): void;
/**
* Stop execution
*
* @param code
*/
exit(code?: number): void;
} }
/** /**
* Runtime-specific terminal functionality * The type of Syntax being highlighted
*/ */
export type ITerminal = IRuntime['term']; export enum HighlightType {
/** No highlighting */
/** None,
* Runtime-specific file handling /** Number literals */
*/ Number,
export type IFileIO = IRuntime['file']; /** Search results */
Match,
// ---------------------------------------------------------------------------- /** Character literals */
// Testing Character,
// ---------------------------------------------------------------------------- /** String literals */
String,
/** /** Single line comments */
* The shared test interface, so tests can be run by both runtimes SingleLineComment,
*/ /** Multi-line comments */
export interface ITestBase { MultiLineComment,
assertEquals(actual: unknown, expected: unknown): void; /** Primary keywords */
assertExists(actual: unknown): void; Keyword1,
assertFalse(actual: boolean): void; /** Secondary keywords */
assertInstanceOf(actual: unknown, expectedType: any): void; Keyword2,
assertNotEquals(actual: unknown, expected: unknown): void; /** Math/logic operators */
assertNull(actual: unknown): void; Operator,
assertStrictEquals(actual: unknown, expected: unknown): void; }
assertTrue(actual: boolean): void;
testSuite(testObj: any): void; /**
* Which direction to search in the current document
*/
export enum SearchDirection {
Forward = 1,
Backward = -1,
} }

View File

@ -1 +0,0 @@
export * as stdAssert from 'https://deno.land/std@0.208.0/assert/mod.ts';

View File

@ -1,7 +1,3 @@
if (!('Deno' in globalThis)) {
throw new Error('This module requires Deno to run');
}
import { IFileIO } from '../common/runtime.ts'; import { IFileIO } from '../common/runtime.ts';
const DenoFileIO: IFileIO = { const DenoFileIO: IFileIO = {

View File

@ -1,16 +1,15 @@
if (!('Deno' in globalThis)) {
throw new Error('This module requires Deno to run');
}
/** /**
* The main entrypoint when using Deno as the runtime * The main entrypoint when using Deno as the runtime
*/ */
import { IRuntime, RunTimeType } from '../common/runtime.ts'; import { CommonRuntime, IRuntime, RunTimeType } from '../common/runtime.ts';
import DenoTerminalIO from './terminal_io.ts'; import DenoTerminalIO from './terminal_io.ts';
import DenoFileIO from './file_io.ts'; import DenoFileIO from './file_io.ts';
import * as node_process from 'node:process'; /**
* The Deno Runtime implementation
*/
const DenoRuntime: IRuntime = { const DenoRuntime: IRuntime = {
...CommonRuntime,
name: RunTimeType.Deno, name: RunTimeType.Deno,
file: DenoFileIO, file: DenoFileIO,
term: DenoTerminalIO, term: DenoTerminalIO,
@ -20,7 +19,6 @@ const DenoRuntime: IRuntime = {
globalThis.addEventListener('onbeforeunload', cb); globalThis.addEventListener('onbeforeunload', cb);
globalThis.onbeforeunload = cb; globalThis.onbeforeunload = cb;
}, },
exit: (code?: number) => node_process.exit(code),
}; };
export default DenoRuntime; export default DenoRuntime;

View File

@ -1,6 +1,3 @@
if (!('Deno' in globalThis)) {
throw new Error('This module requires Deno to run');
}
import { readKey } from '../common/fns.ts'; import { readKey } from '../common/fns.ts';
import { ITerminal, ITerminalSize } from '../common/types.ts'; import { ITerminal, ITerminalSize } from '../common/types.ts';

View File

@ -1,48 +1,15 @@
if (!('Deno' in globalThis)) { // @ts-ignore The import exists, but tsc complains
throw new Error('This module requires Deno to run'); import { test } from 'node:test';
} import AbstractTestBase from '../common/runtime/test_base.ts';
import { ITestBase } from '../common/types.ts'; class DenoTestBase extends AbstractTestBase {
import { stdAssert } from './deps.ts'; public static testSuite(testObj: any) {
const { Object.keys(testObj).forEach((group) => {
assertEquals, const groupObj = testObj[group];
assertExists, Object.keys(groupObj).forEach((testName) => {
assertInstanceOf, test(testName, groupObj[testName]);
AssertionError, });
assertNotEquals,
assertStrictEquals,
} = stdAssert;
export function testSuite(testObj: any) {
Object.keys(testObj).forEach((group) => {
const groupObj = testObj[group];
Object.keys(groupObj).forEach((testName) => {
Deno.test(testName, groupObj[testName]);
}); });
}); }
} }
const DenoTestBase: ITestBase = {
assertEquals,
assertExists,
assertInstanceOf,
assertNotEquals,
assertStrictEquals,
assertTrue: function (actual: boolean): void {
if (actual !== true) {
throw new AssertionError(`actual: "${actual}" expected to be true"`);
}
},
assertFalse(actual: boolean): void {
if (actual !== false) {
throw new AssertionError(`actual: "${actual}" expected to be false"`);
}
},
assertNull(actual: boolean): void {
if (actual !== null) {
throw new AssertionError(`actual: "${actual}" expected to be null"`);
}
},
testSuite,
};
export default DenoTestBase; export default DenoTestBase;

14
src/tsx/mod.ts Normal file
View File

@ -0,0 +1,14 @@
/**
* The main entrypoint when using Tsx as the runtime
*/
import { CommonRuntime, IRuntime, RunTimeType } from '../common/runtime.ts';
/**
* The Tsx Runtime implementation
*/
const TsxRuntime: IRuntime = {
...CommonRuntime,
name: RunTimeType.Tsx,
};
export default TsxRuntime;

21
src/tsx/test_base.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* Adapt the node test interface to the shared testing interface
*/
// @ts-ignore The import exists, but tsc complains
import { describe, it } from 'node:test';
import AbstractTestBase from '../common/runtime/test_base.ts';
class TsxTestBase extends AbstractTestBase {
public static testSuite(testObj: any): void {
Object.keys(testObj).forEach((group) => {
describe(group, () => {
const groupObj = testObj[group];
Object.keys(groupObj).forEach((testName) => {
it(testName, groupObj[testName]);
});
});
});
}
}
export default TsxTestBase;

View File

@ -10,10 +10,14 @@
"noEmit": true, "noEmit": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"skipLibCheck": true, "skipLibCheck": true,
"composite": true, "composite": true,
"downlevelIteration": true, "downlevelIteration": true,
"allowSyntheticDefaultImports": true "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"strictNullChecks": true
}, },
"exclude": ["src/deno"] "exclude": ["src/deno"]
} }