Compare commits
50 Commits
linear-inp
...
master
Author | SHA1 | Date | |
---|---|---|---|
db3e5dd685 | |||
b01212b76e | |||
269322e5b2 | |||
57e96db4ff | |||
ec3d9c3179 | |||
7aa71d4dae | |||
8b44e250e2 | |||
501f5e10d5 | |||
46a0314ce6 | |||
d59900a895 | |||
a7fcc982fe | |||
c31933ed9b | |||
58490f1c51 | |||
d90d685ef2 | |||
6101132a15 | |||
21d26ede6c | |||
5b40d16999 | |||
0c13942aae | |||
ea00f76a62 | |||
2b3be61933 | |||
1a8d9f5469 | |||
65ff7e5b79 | |||
6d9d1113f2 | |||
e84dfa9ba9 | |||
359e739fe8 | |||
32e3676b02 | |||
b5856f063a | |||
01b8535c5e | |||
8c54ceb104 | |||
1951425508 | |||
21ff71cc94 | |||
2c21bf0c9b | |||
b2169cf54b | |||
d7bf8801c9 | |||
4be7be09a7 | |||
4436a8a783 | |||
b3bddbb601 | |||
e0e7849fe4 | |||
88bf3da4e7 | |||
0148561240 | |||
8d2ba868b0 | |||
e656ad3112 | |||
1cfdeece60 | |||
090d6262c3 | |||
4313b923bf | |||
1b3e9d9796 | |||
76eacd835f | |||
cf80dce335 | |||
82bcc72d21 | |||
32f9ef3bba |
7
.gitignore
vendored
7
.gitignore
vendored
@ -335,8 +335,13 @@ $RECYCLE.BIN/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,vim,node,deno,macos,windows,linux
|
||||
|
||||
# Other editors
|
||||
.nova/
|
||||
.zed/
|
||||
|
||||
## Misc generated files
|
||||
scroll.err
|
||||
scroll*.log
|
||||
docs
|
||||
deno.lock
|
||||
cov_profile/
|
||||
coverage/
|
||||
|
20
README.md
20
README.md
@ -1,16 +1,30 @@
|
||||
# Scroll
|
||||
|
||||
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/)
|
||||
(v1.37 or later).
|
||||
runs on
|
||||
|
||||
- [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).
|
||||
|
||||
- Bun: `just bun-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
|
||||
|
||||
- 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 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`
|
||||
|
5
bin/bun.sh
Executable file
5
bin/bun.sh
Executable 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
5
bin/deno.sh
Executable 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
6
bin/tsx.sh
Executable 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}" "$@";
|
@ -4,6 +4,3 @@ deno test --allow-all --coverage=coverage
|
||||
deno coverage coverage --lcov > coverage/coverage.lcov
|
||||
genhtml -o coverage coverage/coverage.lcov
|
||||
rm coverage/*.json
|
||||
open coverage/index.html
|
||||
|
||||
|
||||
|
155
demo/colors.ts
Normal file
155
demo/colors.ts
Normal 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
1686
demo/editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
1468
demo/kilo.c
Normal file
1468
demo/kilo.c
Normal file
File diff suppressed because it is too large
Load Diff
271
demo/test.css
Normal file
271
demo/test.css
Normal 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;
|
||||
}
|
||||
|
@ -13,5 +13,6 @@
|
||||
"semiColons": true,
|
||||
"singleQuote": true
|
||||
},
|
||||
"nodeModulesDir": true
|
||||
"nodeModulesDir": true,
|
||||
"exclude": ["src/bun/"]
|
||||
}
|
||||
|
58
justfile
58
justfile
@ -3,31 +3,41 @@ default:
|
||||
@just --list
|
||||
|
||||
# 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
|
||||
check: deno-check bun-check
|
||||
|
||||
# Generate source 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
|
||||
fmt:
|
||||
deno fmt
|
||||
|
||||
# Run tests with all the runtimes
|
||||
test: deno-test bun-test
|
||||
test: deno-test tsx-test bun-test
|
||||
|
||||
# Run all code-quality related tasks
|
||||
quality: check test
|
||||
|
||||
# Clean up any generated files
|
||||
clean:
|
||||
rm -f test.file
|
||||
rm -rf .deno-cover
|
||||
rm -rf coverage
|
||||
rm -rf docs
|
||||
rm -f scroll.log
|
||||
rm -f scroll.err
|
||||
rm -f scroll*.log
|
||||
rm -f test.file
|
||||
rm -f tsconfig.tsbuildinfo
|
||||
|
||||
##########################################################################################
|
||||
@ -36,15 +46,19 @@ clean:
|
||||
|
||||
# Check code with actual Typescript compiler
|
||||
bun-check:
|
||||
bunx tsc
|
||||
bun run bun-check
|
||||
|
||||
# Test with bun
|
||||
bun-test:
|
||||
bun test --coverage
|
||||
bun run bun-test
|
||||
|
||||
# CLI test coverage report
|
||||
bun-coverage:
|
||||
bun run bun-coverage
|
||||
|
||||
# Run with bun
|
||||
bun-run file="":
|
||||
bun run ./src/scroll.ts {{file}}
|
||||
bun run bun-run {{file}}
|
||||
|
||||
##########################################################################################
|
||||
# Deno-specific commands
|
||||
@ -52,17 +66,35 @@ bun-run file="":
|
||||
|
||||
# Lint code and check types
|
||||
deno-check:
|
||||
deno lint
|
||||
deno check --unstable-ffi --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts
|
||||
deno task deno-lint
|
||||
deno task deno-check
|
||||
|
||||
# Test with deno
|
||||
deno-test:
|
||||
deno test --allow-all --unstable-ffi
|
||||
deno task deno-test
|
||||
|
||||
# Create test coverage report with deno
|
||||
deno-coverage:
|
||||
./coverage.sh
|
||||
deno task deno-coverage
|
||||
|
||||
# Run with deno
|
||||
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}}
|
||||
|
||||
|
||||
|
22
package.json
22
package.json
@ -1,6 +1,24 @@
|
||||
{
|
||||
"dependencies": {},
|
||||
"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"
|
||||
}
|
||||
|
@ -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;
|
@ -1,25 +1,14 @@
|
||||
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
|
||||
/**
|
||||
* 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';
|
||||
import BunFileIO from './file_io.ts';
|
||||
|
||||
import * as process from 'node:process';
|
||||
|
||||
/**
|
||||
* The Bun Runtime implementation
|
||||
*/
|
||||
const BunRuntime: IRuntime = {
|
||||
...CommonRuntime,
|
||||
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;
|
||||
|
@ -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;
|
@ -1,11 +1,10 @@
|
||||
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
|
||||
/**
|
||||
* Adapt the bun test interface to the shared testing interface
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { ITestBase } from '../common/types.ts';
|
||||
|
||||
export function testSuite(testObj: any) {
|
||||
import { describe, test } from 'bun:test';
|
||||
import AbstractTestBase from '../common/runtime/test_base.ts';
|
||||
class BunTestBase extends AbstractTestBase {
|
||||
public static testSuite(testObj: any): void {
|
||||
Object.keys(testObj).forEach((group) => {
|
||||
describe(group, () => {
|
||||
const groupObj = testObj[group];
|
||||
@ -14,22 +13,7 @@ export function testSuite(testObj: any) {
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -2,270 +2,41 @@ import Ansi, * as _Ansi from './ansi.ts';
|
||||
import Buffer from './buffer.ts';
|
||||
import Document from './document.ts';
|
||||
import Editor from './editor.ts';
|
||||
import { FileLang } from './filetype.ts';
|
||||
import Option, { None, Some } from './option.ts';
|
||||
import Position from './position.ts';
|
||||
import Row from './row.ts';
|
||||
|
||||
import FileType, * as FT from './filetype.ts';
|
||||
import * as Fn from './fns.ts';
|
||||
import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts';
|
||||
import { getTestRunner } from './runtime.ts';
|
||||
import { HighlightType, SearchDirection } from './types.ts';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const {
|
||||
assertEquals,
|
||||
assertEquivalent,
|
||||
assertExists,
|
||||
assertInstanceOf,
|
||||
assertNotEquals,
|
||||
assertNull,
|
||||
assertFalse,
|
||||
assertTrue,
|
||||
assertSome,
|
||||
assertNone,
|
||||
testSuite,
|
||||
} = await getTestRunner();
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
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');
|
||||
},
|
||||
};
|
||||
};
|
||||
const THIS_FILE = './src/common/all_test.ts';
|
||||
const KILO_FILE = './demo/kilo.c';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
// Helper Function Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const fnTest = () => {
|
||||
const {
|
||||
some,
|
||||
none,
|
||||
arrayInsert,
|
||||
noop,
|
||||
posSub,
|
||||
@ -279,47 +50,49 @@ const fnTest = () => {
|
||||
isAsciiDigit,
|
||||
strlen,
|
||||
truncate,
|
||||
highlightToColor,
|
||||
} = Fn;
|
||||
|
||||
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': () => {
|
||||
const a = ['😺', '😸', '😹'];
|
||||
const b = arrayInsert(a, 1, 'x');
|
||||
const c = ['😺', 'x', '😸', '😹'];
|
||||
assertEquals(b, c);
|
||||
assertEquivalent(b, c);
|
||||
|
||||
const d = arrayInsert(c, 17, 'y');
|
||||
const e = ['😺', 'x', '😸', '😹', 'y'];
|
||||
assertEquals(d, e);
|
||||
assertEquivalent(d, e);
|
||||
|
||||
assertEquals(arrayInsert([], 0, 'foo'), ['foo']);
|
||||
assertEquivalent(arrayInsert([], 0, 'foo'), ['foo']);
|
||||
},
|
||||
'arrayInsert() numbers': () => {
|
||||
const a = [1, 3, 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];
|
||||
assertEquals(arrayInsert(b, 1, 2), c);
|
||||
assertEquivalent(arrayInsert(b, 1, 2), c);
|
||||
},
|
||||
'noop fn': () => {
|
||||
assertExists(noop);
|
||||
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()': () => {
|
||||
assertEquals(posSub(14, 15), 0);
|
||||
assertEquals(posSub(15, 1), 14);
|
||||
@ -340,7 +113,7 @@ const fnTest = () => {
|
||||
assertEquals(ord('a'), 97);
|
||||
},
|
||||
'strChars() properly splits strings into unicode characters': () => {
|
||||
assertEquals(strChars('😺😸😹'), ['😺', '😸', '😹']);
|
||||
assertEquivalent(strChars('😺😸😹'), ['😺', '😸', '😹']);
|
||||
},
|
||||
'ctrlKey()': () => {
|
||||
const ctrl_a = ctrlKey('a');
|
||||
@ -391,8 +164,6 @@ const fnTest = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const readKeyTest = () => {
|
||||
const { KeyCommand } = _Ansi;
|
||||
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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
testSuite({
|
||||
fns: fnTest(),
|
||||
'readKey()': readKeyTest(),
|
||||
'ANSI utils': ANSITest(),
|
||||
Buffer: BufferTest,
|
||||
Document: DocumentTest,
|
||||
Editor: EditorTest,
|
||||
FileType: FileTypeTest,
|
||||
Option: OptionTest,
|
||||
Position: PositionTest,
|
||||
Row: RowTest,
|
||||
fns: fnTest(),
|
||||
'readKey()': readKeyTest(),
|
||||
});
|
||||
|
@ -42,6 +42,7 @@ export enum AnsiColor {
|
||||
FgMagenta,
|
||||
FgCyan,
|
||||
FgWhite,
|
||||
ForegroundColor,
|
||||
FgDefault,
|
||||
|
||||
// Background Colors
|
||||
@ -53,6 +54,7 @@ export enum AnsiColor {
|
||||
BgMagenta,
|
||||
BgCyan,
|
||||
BgWhite,
|
||||
BackgroundColor,
|
||||
BgDefault,
|
||||
|
||||
// Bright Foreground Colors
|
||||
@ -77,8 +79,8 @@ export enum AnsiColor {
|
||||
}
|
||||
|
||||
export enum Ground {
|
||||
Fore = AnsiColor.FgDefault,
|
||||
Back = AnsiColor.BgDefault,
|
||||
Fore = AnsiColor.ForegroundColor,
|
||||
Back = AnsiColor.BackgroundColor,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@ -106,7 +108,6 @@ const moveCursorForward = (col: number): string => code(col, 'C');
|
||||
const moveCursorDown = (row: number): string => code(row, 'B');
|
||||
const textFormat = (param: string | number | string[] | number[]): string =>
|
||||
code(param, 'm');
|
||||
const color = (value: AnsiColor): string => textFormat(value);
|
||||
const color256 = (value: number, ground: Ground = Ground.Fore): string =>
|
||||
textFormat([ground, AnsiColor.Type256, value]);
|
||||
const rgb = (
|
||||
@ -116,6 +117,9 @@ const rgb = (
|
||||
ground: Ground = Ground.Fore,
|
||||
): string => textFormat([ground, AnsiColor.TypeRGB, r, g, b]);
|
||||
|
||||
/**
|
||||
* Ansi terminal codes and helper functions
|
||||
*/
|
||||
export const Ansi = {
|
||||
ClearLine: code('K'),
|
||||
ClearScreen: code('2J'),
|
||||
@ -129,7 +133,46 @@ export const Ansi = {
|
||||
moveCursorForward,
|
||||
moveCursorDown,
|
||||
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,
|
||||
rgb,
|
||||
};
|
||||
|
@ -1,10 +1,17 @@
|
||||
import { strlen, truncate } from './fns.ts';
|
||||
import { getRuntime } from './runtime.ts';
|
||||
|
||||
/**
|
||||
* A simple string buffer
|
||||
*/
|
||||
class Buffer {
|
||||
#b = '';
|
||||
|
||||
constructor() {
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
public static default(): Buffer {
|
||||
return new Buffer();
|
||||
}
|
||||
|
||||
public append(s: string, maxLen?: number): void {
|
||||
|
@ -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_TAB_SIZE = 4;
|
||||
|
||||
export const SCROLL_LOG_FILE = './scroll.log';
|
||||
export const SCROLL_ERR_FILE = './scroll.err';
|
||||
export const SCROLL_LOG_FILE_PREFIX = './scroll';
|
||||
export const SCROLL_LOG_FILE_SUFFIX = '.log';
|
||||
|
||||
export const defaultTerminalSize: ITerminalSize = {
|
||||
rows: 24,
|
||||
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],
|
||||
]);
|
||||
|
@ -1,34 +1,36 @@
|
||||
import Row from './row.ts';
|
||||
import { arrayInsert, some, strlen } from './fns.ts';
|
||||
import { HighlightType } from './highlight.ts';
|
||||
import { getRuntime } from './runtime.ts';
|
||||
import { Position } from './types.ts';
|
||||
import { Search } from './search.ts';
|
||||
import { FileType } from './filetype.ts';
|
||||
import { arrayInsert } from './fns.ts';
|
||||
import Option, { None, Some } from './option.ts';
|
||||
import { getRuntime, logWarning } from './runtime.ts';
|
||||
import { Position, SearchDirection } from './types.ts';
|
||||
|
||||
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() {
|
||||
this.#rows = [];
|
||||
this.#search = new Search();
|
||||
this.dirty = false;
|
||||
private constructor(
|
||||
public dirty: boolean = false,
|
||||
public type: FileType = FileType.default(),
|
||||
) {
|
||||
}
|
||||
|
||||
get numRows(): number {
|
||||
public get fileType(): string {
|
||||
return this.type.name;
|
||||
}
|
||||
|
||||
public get numRows(): number {
|
||||
return this.#rows.length;
|
||||
}
|
||||
|
||||
public static default(): Document {
|
||||
const self = new Document();
|
||||
self.#search.parent = self;
|
||||
|
||||
return self;
|
||||
return new Document();
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
@ -46,9 +48,13 @@ export class Document {
|
||||
this.#rows = [];
|
||||
}
|
||||
|
||||
this.type = FileType.from(filename);
|
||||
|
||||
const rawFile = await file.openFile(filename);
|
||||
rawFile.split(/\r?\n/)
|
||||
.forEach((row) => this.insertRow(this.numRows, row));
|
||||
.forEach((row: string) => {
|
||||
this.#rows.push(Row.from(row));
|
||||
});
|
||||
|
||||
this.dirty = false;
|
||||
|
||||
@ -58,71 +64,104 @@ export class Document {
|
||||
/**
|
||||
* Save the current document
|
||||
*/
|
||||
public async save(filename: string) {
|
||||
public async save(filename: string): Promise<void> {
|
||||
const { file } = await getRuntime();
|
||||
|
||||
await file.saveFile(filename, this.rowsToString());
|
||||
this.type = FileType.from(filename);
|
||||
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
public resetFind() {
|
||||
this.#search = new Search();
|
||||
this.#search.parent = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cursor position of the query, if it exists
|
||||
*
|
||||
* @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(
|
||||
q: string,
|
||||
key: string,
|
||||
): Position | null {
|
||||
const potential = this.#search.search(q, key);
|
||||
if (some(potential) && potential instanceof Position) {
|
||||
// Update highlight of search match
|
||||
const row = this.#rows[potential.y];
|
||||
at: Position,
|
||||
direction: SearchDirection,
|
||||
): Option<Position> {
|
||||
if (at.y >= this.numRows) {
|
||||
logWarning('Trying to search beyond the end of the current file', {
|
||||
at,
|
||||
document: this,
|
||||
});
|
||||
return None;
|
||||
}
|
||||
|
||||
// Okay, we have to take the Javascript string index (potential.x), convert
|
||||
// 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));
|
||||
const position = Position.from(at);
|
||||
|
||||
// Just to be safe with unicode searches, take the number of 'characters'
|
||||
// as the search query length, not the JS string length.
|
||||
const end = start + strlen(q);
|
||||
for (let y = at.y; y >= 0 && y < this.numRows; y += direction) {
|
||||
const maybeMatch = this.#rows[y].find(q, position.x, direction);
|
||||
if (maybeMatch.isSome()) {
|
||||
position.x = maybeMatch.unwrap();
|
||||
return Some(position);
|
||||
}
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
row.hl[i] = HighlightType.Match;
|
||||
if (direction === SearchDirection.Forward) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
return None;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new line, splitting and/or creating a new row as needed
|
||||
*/
|
||||
public insertNewline(at: Position): void {
|
||||
if (at.y > this.numRows) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
// Just add a simple blank line
|
||||
if (at.y === this.numRows) {
|
||||
this.#rows.push(Row.default());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newRow = this.#rows[at.y].split(at.x);
|
||||
newRow.update();
|
||||
// Split the current row, and insert a new
|
||||
// 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);
|
||||
}
|
||||
|
||||
public insert(at: Position, c: string): void {
|
||||
if (at.y > this.numRows) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const row = this.row(at.y)!;
|
||||
const mergeNextRow = at.x === row.size && at.y + 1 < len;
|
||||
const mergeIntoPrevRow = at.x === 0 && at.y > 0;
|
||||
this.dirty = true;
|
||||
|
||||
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,
|
||||
// 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) {
|
||||
// At the end of a line, pressing delete will merge
|
||||
// the next line into the current on
|
||||
const rowToAppend = this.#rows.at(at.y + 1)!.toString();
|
||||
row.append(rowToAppend);
|
||||
// the next line into the current one
|
||||
const rowToAppend = this.#rows[at.y + 1].toString();
|
||||
row.append(rowToAppend, this.type);
|
||||
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 {
|
||||
row.delete(at.x);
|
||||
}
|
||||
|
||||
row.update();
|
||||
|
||||
this.dirty = true;
|
||||
this.unHighlightRows(at.y);
|
||||
}
|
||||
|
||||
public row(i: number): Row | null {
|
||||
return this.#rows[i] ?? null;
|
||||
public row(i: number): Option<Row> {
|
||||
if (i >= this.numRows || i < 0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
public insertRow(at: number = this.numRows, s: string = ''): void {
|
||||
this.#rows = arrayInsert(this.#rows, at, Row.from(s));
|
||||
this.#rows[at].update();
|
||||
|
||||
this.dirty = true;
|
||||
return Option.from(this.#rows.at(i));
|
||||
}
|
||||
|
||||
public highlight(searchMatch?: string): void {
|
||||
this.#rows.forEach((row) => {
|
||||
row.update(searchMatch);
|
||||
});
|
||||
public highlight(searchMatch: Option<string>, limit: Option<number>): void {
|
||||
let startWithComment = false;
|
||||
let until = this.numRows;
|
||||
if (limit.isSome() && (limit.unwrap() + 1 < this.numRows)) {
|
||||
until = limit.unwrap() + 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < until; i++) {
|
||||
startWithComment = this.#rows[i].update(
|
||||
searchMatch,
|
||||
this.type,
|
||||
startWithComment,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the specified row
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
5
src/common/filetype.ts
Normal file
5
src/common/filetype.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './filetype/base.ts';
|
||||
export * from './filetype/filetype.ts';
|
||||
|
||||
import FileType from './filetype/filetype.ts';
|
||||
export default FileType;
|
91
src/common/filetype/base.ts
Normal file
91
src/common/filetype/base.ts
Normal 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
151
src/common/filetype/c.ts
Normal 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
393
src/common/filetype/css.ts
Normal 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,
|
||||
};
|
||||
}
|
41
src/common/filetype/filetype.ts
Normal file
41
src/common/filetype/filetype.ts
Normal 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;
|
152
src/common/filetype/javascript.ts
Normal file
152
src/common/filetype/javascript.ts
Normal 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
169
src/common/filetype/rust.ts
Normal 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,
|
||||
};
|
||||
}
|
37
src/common/filetype/shell.ts
Normal file
37
src/common/filetype/shell.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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();
|
||||
|
||||
@ -11,20 +13,6 @@ const decoder = new TextDecoder();
|
||||
*/
|
||||
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
|
||||
* that can be more easily mapped to editor commands
|
||||
@ -42,7 +30,7 @@ export function readKey(raw: Uint8Array): string {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Some keycodes have multiple potential inputs
|
||||
// Some key codes have multiple potential inputs
|
||||
switch (parsed) {
|
||||
case '\x1b[1~':
|
||||
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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Insert a value into an array at the specified index
|
||||
*
|
||||
* @param arr - the array
|
||||
* @param at - the index to insert at
|
||||
* @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
|
||||
*
|
||||
* @param l
|
||||
* @param s
|
||||
*/
|
||||
@ -149,10 +149,10 @@ export function ord(s: string): number {
|
||||
/**
|
||||
* 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[] {
|
||||
return s.split(/(?:)/u);
|
||||
return [...s];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,6 +164,17 @@ export function strlen(s: string): number {
|
||||
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?
|
||||
*
|
||||
@ -193,6 +204,15 @@ export function isControl(char: string): boolean {
|
||||
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
|
||||
*
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,9 +1,18 @@
|
||||
import process from 'node:process';
|
||||
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';
|
||||
|
||||
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 { term } = rt;
|
||||
|
||||
@ -14,15 +23,13 @@ export async function main() {
|
||||
});
|
||||
|
||||
// Setup error handler to log to file
|
||||
rt.onEvent('error', (error) => {
|
||||
rt.onEvent('error', (error: any) => {
|
||||
process.stdin.setRawMode(false);
|
||||
logError(JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
const terminalSize = await term.getTerminalSize();
|
||||
|
||||
// Create the editor itself
|
||||
const editor = new Editor(terminalSize);
|
||||
const editor = Editor.create(await term.getTerminalSize());
|
||||
|
||||
// Process cli arguments
|
||||
if (term.argv.length > 0) {
|
||||
@ -43,7 +50,9 @@ export async function main() {
|
||||
for await (const char of term.inputLoop()) {
|
||||
const parsed = readKey(char);
|
||||
if (char.length === 0 || parsed.length === 0) {
|
||||
continue;
|
||||
logWarning('Empty input returned from runtime input loop');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Process input
|
||||
@ -51,6 +60,9 @@ export async function main() {
|
||||
if (!shouldLoop) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render output
|
||||
await editor.refreshScreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
261
src/common/option.ts
Normal file
261
src/common/option.ts
Normal 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;
|
@ -4,14 +4,23 @@
|
||||
export class Position {
|
||||
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 {
|
||||
return new Position(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new `Position` from an existing one
|
||||
*/
|
||||
public static from(p: Position): Position {
|
||||
return new Position(p.x, p.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new `Position` at the origin (0, 0)
|
||||
*/
|
||||
public static default(): Position {
|
||||
return new Position();
|
||||
}
|
||||
|
@ -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 { 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
|
||||
* multi-byte graphemes, all operations are done on an
|
||||
@ -25,27 +39,47 @@ export class Row {
|
||||
*/
|
||||
public hl: HighlightType[] = [];
|
||||
|
||||
/**
|
||||
* Has the current row been highlighted?
|
||||
*/
|
||||
public isHighlighted: boolean = false;
|
||||
|
||||
private constructor(s: string | string[] = '') {
|
||||
this.chars = Array.isArray(s) ? s : strChars(s);
|
||||
this.rchars = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of 'characters' in this row
|
||||
*/
|
||||
public get size(): number {
|
||||
return this.chars.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of 'characters' in the 'render' array
|
||||
*/
|
||||
public get rsize(): number {
|
||||
return this.rchars.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the 'render' string
|
||||
*/
|
||||
public rstring(offset: number = 0): string {
|
||||
return this.rchars.slice(offset).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty Row
|
||||
*/
|
||||
public static default(): Row {
|
||||
return new Row();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Row
|
||||
*/
|
||||
public static from(s: string | string[] | Row): Row {
|
||||
if (s instanceof Row) {
|
||||
return s;
|
||||
@ -54,11 +88,17 @@ export class Row {
|
||||
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.update();
|
||||
this.update(None, syntax);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a character to the current row at the specified location
|
||||
*/
|
||||
public insertChar(at: number, c: string): void {
|
||||
const newSlice = strChars(c);
|
||||
if (at >= this.size) {
|
||||
@ -71,10 +111,10 @@ export class Row {
|
||||
/**
|
||||
* 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));
|
||||
this.chars = this.chars.slice(0, at);
|
||||
this.update();
|
||||
this.update(None, syntax);
|
||||
|
||||
return newRow;
|
||||
}
|
||||
@ -92,25 +132,48 @@ export class Row {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const thisStr = this.toString();
|
||||
if (!this.toString().includes(s)) {
|
||||
return null;
|
||||
public find(
|
||||
s: string,
|
||||
at: number = 0,
|
||||
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
|
||||
// equal the number of characters. So
|
||||
// searching is fairly easy
|
||||
if (thisStr.length === this.chars.length) {
|
||||
return byteCount;
|
||||
return Some(byteIndex);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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(
|
||||
(prev, current) => prev += current.length,
|
||||
(prev, current) => prev + current.length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the contents of the row
|
||||
*/
|
||||
public toString(): string {
|
||||
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(
|
||||
'\t',
|
||||
' '.repeat(SCROLL_TAB_SIZE),
|
||||
);
|
||||
|
||||
this.rchars = strChars(newString);
|
||||
this.highlight(searchMatch);
|
||||
return this.highlight(word, syntax, startWithComment);
|
||||
}
|
||||
|
||||
public highlight(searchMatch?: string): void {
|
||||
const highlighting = [];
|
||||
|
||||
if (some(searchMatch)) {
|
||||
// TODO: highlight search here
|
||||
/**
|
||||
* Calculate the syntax types of the current Row
|
||||
*/
|
||||
public highlight(
|
||||
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) {
|
||||
if (isAsciiDigit(ch)) {
|
||||
highlighting.push(HighlightType.Number);
|
||||
this.hl = [];
|
||||
let i = 0;
|
||||
|
||||
// Handle the case where we are in a multi-line
|
||||
// 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 {
|
||||
highlighting.push(HighlightType.None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.hl = highlighting;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const end = Math.min(len, this.rsize);
|
||||
const start = Math.min(offset, len);
|
||||
|
@ -1,141 +1,11 @@
|
||||
import process from 'node:process';
|
||||
import { IRuntime, ITestBase } from './types.ts';
|
||||
import { noop } from './fns.ts';
|
||||
import { SCROLL_ERR_FILE, SCROLL_LOG_FILE } from './config.ts';
|
||||
export * from './runtime/file_io.ts';
|
||||
export * from './runtime/helpers.ts';
|
||||
export * from './runtime/log.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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
import { CommonRuntime } from './runtime/runtime.ts';
|
||||
export default CommonRuntime;
|
||||
|
28
src/common/runtime/file_io.ts
Normal file
28
src/common/runtime/file_io.ts
Normal 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;
|
82
src/common/runtime/helpers.ts
Normal file
82
src/common/runtime/helpers.ts
Normal 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
68
src/common/runtime/log.ts
Normal 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);
|
9
src/common/runtime/node.ts
Normal file
9
src/common/runtime/node.ts
Normal 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 };
|
139
src/common/runtime/runtime.ts
Normal file
139
src/common/runtime/runtime.ts
Normal 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),
|
||||
};
|
44
src/common/runtime/terminal_io.ts
Normal file
44
src/common/runtime/terminal_io.ts
Normal 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;
|
95
src/common/runtime/test_base.ts
Normal file
95
src/common/runtime/test_base.ts
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,122 +1,47 @@
|
||||
import { RunTimeType } from './runtime.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 {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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;
|
||||
export enum RunTimeType {
|
||||
Bun = 'bun',
|
||||
Deno = 'deno',
|
||||
Tsx = 'tsx',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-specific terminal functionality
|
||||
* The type of Syntax being highlighted
|
||||
*/
|
||||
export type ITerminal = IRuntime['term'];
|
||||
|
||||
/**
|
||||
* Runtime-specific file handling
|
||||
*/
|
||||
export type IFileIO = IRuntime['file'];
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Testing
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shared test interface, so tests can be run by both runtimes
|
||||
*/
|
||||
export interface ITestBase {
|
||||
assertEquals(actual: unknown, expected: unknown): void;
|
||||
assertExists(actual: unknown): void;
|
||||
assertFalse(actual: boolean): void;
|
||||
assertInstanceOf(actual: unknown, expectedType: any): void;
|
||||
assertNotEquals(actual: unknown, expected: unknown): void;
|
||||
assertNull(actual: unknown): void;
|
||||
assertStrictEquals(actual: unknown, expected: unknown): void;
|
||||
assertTrue(actual: boolean): void;
|
||||
testSuite(testObj: any): void;
|
||||
export enum HighlightType {
|
||||
/** No highlighting */
|
||||
None,
|
||||
/** Number literals */
|
||||
Number,
|
||||
/** Search results */
|
||||
Match,
|
||||
/** Character literals */
|
||||
Character,
|
||||
/** String literals */
|
||||
String,
|
||||
/** Single line comments */
|
||||
SingleLineComment,
|
||||
/** Multi-line comments */
|
||||
MultiLineComment,
|
||||
/** Primary keywords */
|
||||
Keyword1,
|
||||
/** Secondary keywords */
|
||||
Keyword2,
|
||||
/** Math/logic operators */
|
||||
Operator,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which direction to search in the current document
|
||||
*/
|
||||
export enum SearchDirection {
|
||||
Forward = 1,
|
||||
Backward = -1,
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export * as stdAssert from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
@ -1,7 +1,3 @@
|
||||
if (!('Deno' in globalThis)) {
|
||||
throw new Error('This module requires Deno to run');
|
||||
}
|
||||
|
||||
import { IFileIO } from '../common/runtime.ts';
|
||||
|
||||
const DenoFileIO: IFileIO = {
|
||||
|
@ -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
|
||||
*/
|
||||
import { IRuntime, RunTimeType } from '../common/runtime.ts';
|
||||
import { CommonRuntime, IRuntime, RunTimeType } from '../common/runtime.ts';
|
||||
import DenoTerminalIO from './terminal_io.ts';
|
||||
import DenoFileIO from './file_io.ts';
|
||||
|
||||
import * as node_process from 'node:process';
|
||||
|
||||
/**
|
||||
* The Deno Runtime implementation
|
||||
*/
|
||||
const DenoRuntime: IRuntime = {
|
||||
...CommonRuntime,
|
||||
name: RunTimeType.Deno,
|
||||
file: DenoFileIO,
|
||||
term: DenoTerminalIO,
|
||||
@ -20,7 +19,6 @@ const DenoRuntime: IRuntime = {
|
||||
globalThis.addEventListener('onbeforeunload', cb);
|
||||
globalThis.onbeforeunload = cb;
|
||||
},
|
||||
exit: (code?: number) => node_process.exit(code),
|
||||
};
|
||||
|
||||
export default DenoRuntime;
|
||||
|
@ -1,6 +1,3 @@
|
||||
if (!('Deno' in globalThis)) {
|
||||
throw new Error('This module requires Deno to run');
|
||||
}
|
||||
import { readKey } from '../common/fns.ts';
|
||||
import { ITerminal, ITerminalSize } from '../common/types.ts';
|
||||
|
||||
|
@ -1,48 +1,15 @@
|
||||
if (!('Deno' in globalThis)) {
|
||||
throw new Error('This module requires Deno to run');
|
||||
}
|
||||
import { ITestBase } from '../common/types.ts';
|
||||
import { stdAssert } from './deps.ts';
|
||||
const {
|
||||
assertEquals,
|
||||
assertExists,
|
||||
assertInstanceOf,
|
||||
AssertionError,
|
||||
assertNotEquals,
|
||||
assertStrictEquals,
|
||||
} = stdAssert;
|
||||
|
||||
export function testSuite(testObj: any) {
|
||||
// @ts-ignore The import exists, but tsc complains
|
||||
import { test } from 'node:test';
|
||||
import AbstractTestBase from '../common/runtime/test_base.ts';
|
||||
class DenoTestBase extends AbstractTestBase {
|
||||
public static testSuite(testObj: any) {
|
||||
Object.keys(testObj).forEach((group) => {
|
||||
const groupObj = testObj[group];
|
||||
Object.keys(groupObj).forEach((testName) => {
|
||||
Deno.test(testName, groupObj[testName]);
|
||||
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;
|
||||
|
14
src/tsx/mod.ts
Normal file
14
src/tsx/mod.ts
Normal 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
21
src/tsx/test_base.ts
Normal 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;
|
@ -10,10 +10,14 @@
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": true,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"downlevelIteration": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"exclude": ["src/deno"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user