diff --git a/coverage.sh b/coverage.sh new file mode 100755 index 0000000..1412c6a --- /dev/null +++ b/coverage.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +rm -fr /cov_profile/ +deno test --allow-all --coverage=cov_profile +deno coverage cov_profile --lcov > cov_profile/cov_profile.lcov +genhtml -o cov_profile cov_profile/cov_profile.lcov +rm cov_profile/*.json +open cov_profile/index.html + + diff --git a/justfile b/justfile index 8dbe4f0..636269c 100644 --- a/justfile +++ b/justfile @@ -48,7 +48,7 @@ deno-check: # Test with deno deno-test: - deno test --allow-all + deno test --allow-all --unstable # Create test coverage report with deno deno-coverage: diff --git a/src/bun/ffi.ts b/src/bun/ffi.ts index 46d6d9f..95314b6 100644 --- a/src/bun/ffi.ts +++ b/src/bun/ffi.ts @@ -42,6 +42,7 @@ const BunFFI: IFFI = { tcsetattr, cfmakeraw, getPointer: ptr, + close: cStdLib.close, }; export default BunFFI; diff --git a/src/bun/file_io.ts b/src/bun/file_io.ts index 068ec52..2954de1 100644 --- a/src/bun/file_io.ts +++ b/src/bun/file_io.ts @@ -1,9 +1,9 @@ -import { IFIO } from '../common/runtime.ts'; +import { IFileIO } from '../common/runtime.ts'; import { appendFileSync, readFileSync } from 'node:fs'; import { appendFile } from 'node:fs/promises'; -const BunFileIO: IFIO = { +const BunFileIO: IFileIO = { openFile: async (path: string): Promise => { const file = await Bun.file(path); return await file.text(); diff --git a/src/bun/terminal_io.ts b/src/bun/terminal_io.ts index 129bef5..5c962e0 100644 --- a/src/bun/terminal_io.ts +++ b/src/bun/terminal_io.ts @@ -4,6 +4,11 @@ import { ITerminal, ITerminalSize } from '../common/mod.ts'; import Ansi from '../common/ansi.ts'; +const defaultTerminalSize: ITerminalSize = { + rows: 24, + cols: 80, +}; + const BunTerminalIO: ITerminal = { // Deno only returns arguments passed to the script, so // remove the bun runtime executable, and entry script arguments @@ -32,8 +37,8 @@ const BunTerminalIO: ITerminal = { 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); - const cols = parseInt(scols, 10); + const rows = parseInt(srows, 10) ?? 24; + const cols = parseInt(scols, 10) ?? 80; // Clear the screen await write(Ansi.ClearScreen + Ansi.ResetCursor); @@ -44,10 +49,7 @@ const BunTerminalIO: ITerminal = { }; } - return { - rows: 24, - cols: 80, - }; + return defaultTerminalSize; }, writeStdout: async function write(s: string): Promise { const buffer = new TextEncoder().encode(s); diff --git a/src/common/ansi.ts b/src/common/ansi.ts index 7698c36..ab59288 100644 --- a/src/common/ansi.ts +++ b/src/common/ansi.ts @@ -43,11 +43,13 @@ export const Ansi = { moveCursorDown: (row: number): string => ANSI_PREFIX + `${row}B`, }; -const decoder = new TextDecoder(); - -export function readKey(raw: Uint8Array): string { - const parsed = decoder.decode(raw); - +/** + * Convert input from ANSI escape sequences into a form + * that can be more easily mapped to editor commands + * + * @param parsed - the decoded chunk of input + */ +export function readKey(parsed: string): string { // Return the input if it's unambiguous if (parsed in KeyCommand) { return parsed; diff --git a/src/common/ansi_test.ts b/src/common/ansi_test.ts new file mode 100644 index 0000000..f466eb0 --- /dev/null +++ b/src/common/ansi_test.ts @@ -0,0 +1,33 @@ +import { getTestRunner } from './mod.ts'; +import Ansi, { KeyCommand, readKey } from './ansi.ts'; + +getTestRunner().then((t) => { + t.test('Ansi.moveCursor', () => { + t.assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); + }); + + t.test('Ansi.moveCursorForward', () => { + t.assertEquals(Ansi.moveCursorForward(2), '\x1b[2C'); + }); + + t.test('Ansi.moveCursorDown', () => { + t.assertEquals(Ansi.moveCursorDown(7), '\x1b[7B'); + }); + + t.test('readKey', () => { + // Ignore unhandled escape sequences + t.assertEquals(readKey('\x1b[]'), '\x1b[]'); + + // Pass explicitly mapped values right through + t.assertEquals(readKey(KeyCommand.ArrowUp), KeyCommand.ArrowUp); + t.assertEquals(readKey(KeyCommand.Home), KeyCommand.Home); + + ['\x1bOH', '\x1b[7~', '\x1b[1~', '\x1b[H'].forEach((code) => { + t.assertEquals(readKey(code), KeyCommand.Home); + }); + + ['\x1bOF', '\x1b[8~', '\x1b[4~', '\x1b[F'].forEach((code) => { + t.assertEquals(readKey(code), KeyCommand.End); + }); + }); +}); diff --git a/src/common/buffer.ts b/src/common/buffer.ts index 22f4ccf..0b9b8e2 100644 --- a/src/common/buffer.ts +++ b/src/common/buffer.ts @@ -1,5 +1,5 @@ import { strlen, truncate } from './utils.ts'; -import { getRuntime } from './runtime.ts'; +import { getRuntime, importForRuntime } from './runtime.ts'; class Buffer { #b = ''; @@ -12,7 +12,7 @@ class Buffer { } public appendLine(s = ''): void { - this.#b += (s ?? '') + '\r\n'; + this.#b += s + '\r\n'; } public clear(): void { @@ -23,7 +23,7 @@ class Buffer { * Output the contents of the buffer into stdout */ public async flush() { - const { term } = await getRuntime(); + const term = await importForRuntime('terminal_io'); await term.writeStdout(this.#b); this.clear(); } diff --git a/src/common/buffer_test.ts b/src/common/buffer_test.ts new file mode 100644 index 0000000..488e762 --- /dev/null +++ b/src/common/buffer_test.ts @@ -0,0 +1,45 @@ +import { getTestRunner } from './runtime.ts'; +import Buffer from './buffer.ts'; + +getTestRunner().then((t) => { + t.test('Buffer exists', () => { + const b = new Buffer(); + t.assertInstanceOf(b, Buffer); + t.assertEquals(b.strlen(), 0); + }); + + t.test('Buffer.appendLine', () => { + const b = new Buffer(); + + // Carriage return and line feed + b.appendLine(); + t.assertEquals(b.strlen(), 2); + + b.clear(); + t.assertEquals(b.strlen(), 0); + + b.appendLine('foo'); + t.assertEquals(b.strlen(), 5); + }); + + t.test('Buffer.append', () => { + const b = new Buffer(); + + b.append('foobar'); + t.assertEquals(b.strlen(), 6); + b.clear(); + + b.append('foobar', 3); + t.assertEquals(b.strlen(), 3); + }); + + t.test('Buffer.flush', async () => { + const b = new Buffer(); + b.append('foobarbaz'); + t.assertEquals(b.strlen(), 9); + + await b.flush(); + + t.assertEquals(b.strlen(), 0); + }); +}); diff --git a/src/common/editor.ts b/src/common/editor.ts index 2a4c1f7..dddb71a 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -1,7 +1,7 @@ import Ansi, { KeyCommand } from './ansi.ts'; import Buffer from './buffer.ts'; import Document from './document.ts'; -import { IPoint, ITerminalSize, VERSION } from './mod.ts'; +import { IPoint, ITerminalSize, logToFile, VERSION } from './mod.ts'; import { ctrl_key } from './utils.ts'; export class Editor { @@ -152,12 +152,17 @@ export class Editor { private drawFileRow(y: number): void { const row = this.#document.row(y); - let len = row?.chars.length ?? 0; + if (row === null) { + logToFile(`Warning: trying to draw non-existent row '${y}'`); + return this.drawPlaceholderRow(y); + } + + let len = row.chars.length ?? 0; if (len > this.#screen.cols) { len = this.#screen.cols; } - this.#buffer.append(row!.toString(), len); + this.#buffer.append(row.toString(), len); this.#buffer.appendLine(Ansi.ClearLine); } diff --git a/src/common/main.ts b/src/common/main.ts index 152ecee..6666c93 100644 --- a/src/common/main.ts +++ b/src/common/main.ts @@ -4,6 +4,7 @@ import { getTermios } from './termios.ts'; import { Editor } from './editor.ts'; export async function main() { + const decoder = new TextDecoder(); const runTime = await getRuntime(); const { term, file, onExit, onEvent } = runTime; @@ -12,14 +13,13 @@ export async function main() { t.enableRawMode(); onExit(() => { t.disableRawMode(); + t.cleanup(); }); // Setup error handler to log to file onEvent('error', (error) => { t.disableRawMode(); - if (error instanceof ErrorEvent) { - file.appendFileSync('./scroll.err', JSON.stringify(error, null, 2)); - } + file.appendFileSync('./scroll.err', JSON.stringify(error, null, 2)); }); const terminalSize = await term.getTerminalSize(); @@ -39,7 +39,7 @@ export async function main() { // The main event loop for await (const chunk of term.inputLoop()) { // Process input - const char = readKey(chunk); + const char = readKey(decoder.decode(chunk)); const shouldLoop = editor.processKeyPress(char); if (!shouldLoop) { return 0; diff --git a/src/common/runtime.ts b/src/common/runtime.ts index 5f6c004..2e88040 100644 --- a/src/common/runtime.ts +++ b/src/common/runtime.ts @@ -1,14 +1,25 @@ import { getTermios } from './termios.ts'; +import { noop } from './utils.ts'; +import { ITestBase } from './types.ts'; -// ---------------------------------------------------------------------------- -// Runtime adapter interfaces -// ---------------------------------------------------------------------------- export enum RunTimeType { Bun = 'bun', Deno = 'deno', Unknown = 'common', } +// ---------------------------------------------------------------------------- +// Runtime adapter interfaces +// ---------------------------------------------------------------------------- + +/** + * The size of terminal in rows and columns + */ +export interface ITerminalSize { + rows: number; + cols: number; +} + /** * The native functions for getting/setting terminal settings */ @@ -33,14 +44,11 @@ export interface IFFI { */ // deno-lint-ignore no-explicit-any getPointer(ta: any): unknown; -} -/** - * The size of terminal in rows and columns - */ -export interface ITerminalSize { - rows: number; - cols: number; + /** + * Closes the FFI handle + */ + close(): void; } /** @@ -51,10 +59,11 @@ 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; + inputLoop(): AsyncGenerator; /** * Get the size of the terminal @@ -70,7 +79,7 @@ export interface ITerminal { /** * Runtime-specific file handling */ -export interface IFIO { +export interface IFileIO { openFile(path: string): Promise; openFileSync(path: string): string; appendFile(path: string, contents: string): Promise; @@ -99,7 +108,7 @@ export interface IRuntime { /** * Runtime-specific file system io */ - file: IFIO; + file: IFileIO; /** * Set up an event handler @@ -132,6 +141,15 @@ export interface IRuntime { let scrollRuntime: IRuntime | null = null; +export function logToFile(s: unknown) { + importForRuntime('file_io').then((f) => { + const raw = (typeof s === 'string') ? s : JSON.stringify(s, null, 2); + const output = raw + '\n'; + + f.appendFile('./scroll.log', output).then(noop); + }); +} + /** * Kill program, displaying an error message * @param s @@ -148,7 +166,7 @@ export function die(s: string | Error): void { /** * Determine which Typescript runtime we are operating under */ -export function getRuntimeType(): RunTimeType { +export function runtimeType(): RunTimeType { let runtime = RunTimeType.Unknown; if ('Deno' in globalThis) { @@ -166,7 +184,7 @@ export function getRuntimeType(): RunTimeType { */ export async function getRuntime(): Promise { if (scrollRuntime === null) { - const runtime = getRuntimeType(); + const runtime = runtimeType(); const path = `../${runtime}/mod.ts`; const pkg = await import(path); @@ -178,16 +196,30 @@ export async function getRuntime(): Promise { return Promise.resolve(scrollRuntime!); } +/** + * Get the common test interface object + */ +export async function getTestRunner(): Promise { + 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 * - * eg. to load "src/bun/mod.ts", if the runtime is bun, + * 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 = getRuntimeType(); + const runtime = runtimeType(); const suffix = '.ts'; const base = `../${runtime}/`; @@ -198,20 +230,10 @@ export const importForRuntime = async (path: string) => { const cleanedPath = pathParts.join('/'); const importPath = base + cleanedPath + suffix; - return await import(importPath); -}; - -/** - * Import the default export for a runtime-specific module - * (this is just a simple wrapper of `importForRuntime`) - * - * @param path - the path within the runtime module - */ -export const importDefaultForRuntime = async (path: string) => { - const pkg = await importForRuntime(path); + const pkg = await import(importPath); if ('default' in pkg) { return pkg.default; } - return null; + return pkg; }; diff --git a/src/common/termios.ts b/src/common/termios.ts index e38428f..4b94765 100644 --- a/src/common/termios.ts +++ b/src/common/termios.ts @@ -31,13 +31,13 @@ class Termios { * The data for the termios struct we are manipulating * @private */ - readonly #termios: Uint8Array; + #termios: Uint8Array; /** * The pointer to the termios struct * @private */ - readonly #ptr: any; + #ptr; constructor(ffi: IFFI) { this.#ffi = ffi; @@ -51,6 +51,13 @@ class Termios { this.#ptr = ffi.getPointer(this.#termios); } + cleanup() { + this.#ptr = null; + this.#cookedTermios = new Uint8Array(0); + this.#termios = new Uint8Array(0); + this.#ffi.close(); + } + enableRawMode() { if (this.#inRawMode) { throw new Error('Can not enable raw mode when in raw mode'); diff --git a/src/common/utils.ts b/src/common/utils.ts index db2fc22..c4d2503 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,11 +1,30 @@ -export function noop() {} +// ---------------------------------------------------------------------------- +// Misc +// ---------------------------------------------------------------------------- + +export const noop = () => {}; // ---------------------------------------------------------------------------- // Strings // ---------------------------------------------------------------------------- +/** + * Get the codepoint of the first byte of a string. If the string + * is empty, this will return 256 + * + * @param s - the string + */ +export function ord(s: string): number { + if (s.length > 0) { + return s.codePointAt(0)!; + } + + return 256; +} + /** * Split a string by graphemes, not just bytes + * * @param s - the string to split into 'characters' */ export function chars(s: string): string[] { @@ -14,6 +33,7 @@ export function chars(s: string): string[] { /** * Get the 'character length' of a string, not its UTF16 byte count + * * @param s - the string to check */ export function strlen(s: string): number { @@ -21,19 +41,12 @@ export function strlen(s: string): number { } /** - * Is the character part of ascii? + * Are all the characters in the string in ASCII range? * - * @param char - a one character string to check + * @param char - string to check */ export function is_ascii(char: string): boolean { - return chars(char).every((char) => { - const point = char.codePointAt(0); - if (point === undefined) { - return false; - } - - return point < 0x80; - }); + return chars(char).every((char) => ord(char) < 0x80); } /** @@ -42,22 +55,19 @@ export function is_ascii(char: string): boolean { * @param char - a one character string to check */ export function is_control(char: string): boolean { - const code = char.codePointAt(0); - if (code === undefined) { - return false; - } - + const code = ord(char); return is_ascii(char) && (code === 0x7f || code < 0x20); } /** * Get the key code for a ctrl chord + * * @param char - a one character string */ export function ctrl_key(char: string): string { // This is the normal use case, of course if (is_ascii(char)) { - const point = char.codePointAt(0)!; + const point = ord(char); return String.fromCodePoint(point & 0x1f); } diff --git a/src/common/utils_test.ts b/src/common/utils_test.ts index b2ee1b2..a4900d8 100644 --- a/src/common/utils_test.ts +++ b/src/common/utils_test.ts @@ -1,59 +1,74 @@ -import { importDefaultForRuntime, ITestBase } from './mod.ts'; +import { getTestRunner } from './mod.ts'; import { chars, ctrl_key, is_ascii, is_control, + noop, + ord, strlen, truncate, } from './utils.ts'; -const t: ITestBase = await importDefaultForRuntime('test_base'); +getTestRunner().then((t) => { + t.test('noop fn', () => { + t.assertExists(noop); + t.assertEquals(noop(), undefined); + }); -// --------------------------------------------------------------------------------------------------------------------- -// Strings -// --------------------------------------------------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // Strings + // --------------------------------------------------------------------------- + t.test('ord fn returns 256 on invalid string', () => { + t.assertEquals(ord(''), 256); + }); -t.test('chars fn properly splits strings into unicode characters', () => { - t.assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']); -}); - -t.test('ctrl_key fn returns expected values', () => { - const ctrl_a = ctrl_key('a'); - t.assertTrue(is_control(ctrl_a)); - t.assertEquals(ctrl_a, String.fromCodePoint(0x01)); - - const invalid = ctrl_key('😺'); - t.assertFalse(is_control(invalid)); - t.assertEquals(invalid, '😺'); -}); - -t.test('is_ascii properly discerns ascii chars', () => { - t.assertTrue(is_ascii('asjyverkjhsdf1928374')); - t.assertFalse(is_ascii('😺acalskjsdf')); -}); - -t.test('is_control fn works as expected', () => { - t.assertFalse(is_control('abc')); - t.assertTrue(is_control(String.fromCodePoint(0x01))); - t.assertFalse(is_control('😺')); -}); - -t.test('strlen fn returns expected length for multibyte characters', () => { - t.assertEquals(strlen('😺😸😹'), 3); - t.assertNotEquals('😺😸😹'.length, strlen('😺😸😹')); - - // Skin tone modifier + base character - t.assertEquals(strlen('🀰🏼'), 2); - t.assertNotEquals('🀰🏼'.length, strlen('🀰🏼')); - - // This has 4 sub-characters, and 3 zero-width-joiners - t.assertEquals(strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'), 7); - t.assertNotEquals('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'.length, strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦')); -}); - -t.test('truncate shortens strings', () => { - t.assertEquals(truncate('😺😸😹', 1), '😺'); - t.assertEquals(truncate('😺😸😹', 5), '😺😸😹'); - t.assertEquals(truncate('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 5), 'πŸ‘¨β€πŸ‘©β€πŸ‘§'); + t.test('chars fn properly splits strings into unicode characters', () => { + t.assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']); + }); + + t.test('ctrl_key fn returns expected values', () => { + const ctrl_a = ctrl_key('a'); + t.assertTrue(is_control(ctrl_a)); + t.assertEquals(ctrl_a, String.fromCodePoint(0x01)); + + const invalid = ctrl_key('😺'); + t.assertFalse(is_control(invalid)); + t.assertEquals(invalid, '😺'); + }); + + t.test('is_ascii properly discerns ascii chars', () => { + t.assertTrue(is_ascii('asjyverkjhsdf1928374')); + t.assertFalse(is_ascii('😺acalskjsdf')); + t.assertFalse(is_ascii('ab😺ac')); + }); + + t.test('is_control fn works as expected', () => { + t.assertFalse(is_control('abc')); + t.assertTrue(is_control(String.fromCodePoint(0x01))); + t.assertFalse(is_control('😺')); + }); + + t.test('strlen fn returns expected length for ascii strings', () => { + t.assertEquals(strlen('abc'), 'abc'.length); + }); + + t.test('strlen fn returns expected length for multibyte characters', () => { + t.assertEquals(strlen('😺😸😹'), 3); + t.assertNotEquals('😺😸😹'.length, strlen('😺😸😹')); + + // Skin tone modifier + base character + t.assertEquals(strlen('🀰🏼'), 2); + t.assertNotEquals('🀰🏼'.length, strlen('🀰🏼')); + + // This has 4 sub-characters, and 3 zero-width-joiners + t.assertEquals(strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'), 7); + t.assertNotEquals('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'.length, strlen('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦')); + }); + + t.test('truncate shortens strings', () => { + t.assertEquals(truncate('😺😸😹', 1), '😺'); + t.assertEquals(truncate('😺😸😹', 5), '😺😸😹'); + t.assertEquals(truncate('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 5), 'πŸ‘¨β€πŸ‘©β€πŸ‘§'); + }); }); diff --git a/src/deno/deps.ts b/src/deno/deps.ts new file mode 100644 index 0000000..bca48ad --- /dev/null +++ b/src/deno/deps.ts @@ -0,0 +1 @@ +export * as stdAssert from 'https://deno.land/std@0.205.0/assert/mod.ts'; diff --git a/src/deno/ffi.ts b/src/deno/ffi.ts index f2c03ff..01c8786 100644 --- a/src/deno/ffi.ts +++ b/src/deno/ffi.ts @@ -39,6 +39,7 @@ const DenoFFI: IFFI = { tcsetattr, cfmakeraw, getPointer: Deno.UnsafePointer.of, + close: cStdLib.close, }; export default DenoFFI; diff --git a/src/deno/file_io.ts b/src/deno/file_io.ts index 69d38ef..13a8727 100644 --- a/src/deno/file_io.ts +++ b/src/deno/file_io.ts @@ -1,6 +1,6 @@ -import { IFIO } from '../common/runtime.ts'; +import { IFileIO } from '../common/runtime.ts'; -const DenoFileIO: IFIO = { +const DenoFileIO: IFileIO = { openFile: async function (path: string): Promise { const decoder = new TextDecoder('utf-8'); const data = await Deno.readFile(path); diff --git a/src/deno/test_base.ts b/src/deno/test_base.ts index 852e809..d6070fb 100644 --- a/src/deno/test_base.ts +++ b/src/deno/test_base.ts @@ -1,13 +1,13 @@ import { ITestBase } from '../common/mod.ts'; - -import { +import { stdAssert } from './deps.ts'; +const { assertEquals, assertExists, assertInstanceOf, AssertionError, assertNotEquals, assertStrictEquals, -} from 'https://deno.land/std/assert/mod.ts'; +} = stdAssert; class TestBase implements ITestBase { test(name: string, fn: () => void): void {