diff --git a/deno.jsonc b/deno.jsonc index 65ccb9d..d9490e5 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,5 +1,4 @@ { - "exclude": ["src/bun/**/*.ts"], "lint": { "include": ["src/"], "rules": { diff --git a/justfile b/justfile index 48b8680..8958864 100644 --- a/justfile +++ b/justfile @@ -3,12 +3,7 @@ default: @just --list # Typescript checking -check: lint - deno check --unstable --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts - -# Code linting -lint: - deno lint +check: deno-check bun-check # Reformat the code fmt: @@ -27,6 +22,10 @@ clean: # Bun-specific commands ######################################################################################################################## +# Check code with actual Typescript compiler +bun-check: + bunx tsc + # Test with bun bun-test: bun test --coverage @@ -39,6 +38,11 @@ bun-run: # Deno-specific commands ######################################################################################################################## +# Lint code and check types +deno-check: + deno lint + deno check --unstable --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts + # Test with deno deno-test: deno test --allow-all diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ac0b9c --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": {}, + "devDependencies": { + "bun-types": "^1.0.11" + } +} diff --git a/src/bun/ffi.ts b/src/bun/ffi.ts index 3f0b2dc..9bbe5bd 100644 --- a/src/bun/ffi.ts +++ b/src/bun/ffi.ts @@ -2,6 +2,7 @@ * This is all the nasty ffi setup for the bun runtime */ import { dlopen, ptr, suffix } from 'bun:ffi'; +import { IFFI } from '../common/types.ts'; const getLib = (name: string) => { return dlopen( @@ -23,7 +24,7 @@ const getLib = (name: string) => { ); }; -let cStdLib = { symbols: {} }; +let cStdLib: any = { symbols: {} }; try { cStdLib = getLib(`libc.${suffix}`); @@ -35,5 +36,12 @@ try { } } -export const { tcgetattr, tcsetattr, cfmakeraw } = cStdLib.symbols; -export const getPointer = ptr; +const { tcgetattr, tcsetattr, cfmakeraw } = cStdLib.symbols; +const BunFFI: IFFI = { + tcgetattr, + tcsetattr, + cfmakeraw, + getPointer: ptr, +}; + +export default BunFFI; diff --git a/src/bun/mod.ts b/src/bun/mod.ts index c736946..e7382dc 100644 --- a/src/bun/mod.ts +++ b/src/bun/mod.ts @@ -2,16 +2,8 @@ * The main entrypoint when using Bun as the runtime */ -import { getTermios } from "../common/mod.ts"; export * from './terminal_io.ts'; -export async function init() { - const t = await getTermios(); - - t.enableRawMode(); - - process.on('exit', () => { - console.info('Disabling raw mode'); - t.disableRawMode(); - }) -} \ No newline at end of file +export const onExit = (cb: () => void): void => { + process.on('beforeExit', cb); +}; diff --git a/src/bun/terminal_io.ts b/src/bun/terminal_io.ts index 0613273..5ed0798 100644 --- a/src/bun/terminal_io.ts +++ b/src/bun/terminal_io.ts @@ -1,6 +1,8 @@ /** * Wrap the runtime-specific hook into stdin */ +import { ITerminalIO } from '../common/mod.ts'; + export async function* inputLoop() { for await (const chunk of Bun.stdin.stream()) { yield chunk; @@ -12,3 +14,10 @@ export async function write(s: string): Promise { await Bun.write(Bun.stdout, buffer); } + +const BunTerminalIO: ITerminalIO = { + inputLoop, + write, +}; + +export default BunTerminalIO; diff --git a/src/bun/test_base.ts b/src/bun/test_base.ts index c40de78..95347e1 100644 --- a/src/bun/test_base.ts +++ b/src/bun/test_base.ts @@ -1,8 +1,8 @@ /** * Adapt the bun test interface to the shared testing interface */ -import {test as btest, expect } from 'bun:test'; -import {ITestBase} from "../common/mod"; +import { expect, test as btest } from 'bun:test'; +import { ITestBase } from '../common/mod'; class TestBase implements ITestBase { test(name: string, fn: () => void) { @@ -32,7 +32,11 @@ class TestBase implements ITestBase { assertTrue(actual: boolean): void { return expect(actual).toBe(true); } + + assertStrictEquals(actual: unknown, expected: unknown): void { + return expect(actual).toBe(expected); + } } const testBase = new TestBase(); -export default testBase; \ No newline at end of file +export default testBase; diff --git a/src/common/mod.ts b/src/common/mod.ts index 661957f..5743eb6 100644 --- a/src/common/mod.ts +++ b/src/common/mod.ts @@ -1,18 +1,23 @@ import { importForRuntime } from './runtime.ts'; import { Editor } from './editor.ts'; +import { getTermios } from './termios.ts'; export * from './runtime.ts'; export * from './strings.ts'; -export * from './termios.ts'; -export type { ITestBase } from './test_base.ts'; +export type * from './types.ts'; const decoder = new TextDecoder(); export async function main() { - const { inputLoop, init } = await importForRuntime('mod.ts'); + const { inputLoop, onExit } = await importForRuntime('mod.ts'); - // Set up handlers to enable/disable raw mode for each runtime - await init(); + // Setup raw mode, and tear down on error or normal exit + const t = await getTermios(); + t.enableRawMode(); + onExit(() => { + console.info('Exit handler called, disabling raw mode'); + t.disableRawMode(); + }); // Create the editor itself const editor = new Editor(); diff --git a/src/common/strings_test.ts b/src/common/strings_test.ts index 9182bdf..8483ad0 100644 --- a/src/common/strings_test.ts +++ b/src/common/strings_test.ts @@ -1,4 +1,5 @@ -import { chars, importDefaultForRuntime, is_ascii, ITestBase } from './mod.ts'; +import { importDefaultForRuntime, ITestBase } from './mod.ts'; +import { chars, is_ascii } from './strings.ts'; const t: ITestBase = await importDefaultForRuntime('test_base'); diff --git a/src/common/termios.ts b/src/common/termios.ts index 5d5a3d2..48ee7b1 100644 --- a/src/common/termios.ts +++ b/src/common/termios.ts @@ -1,4 +1,4 @@ -import { die, importForRuntime } from './mod.ts'; +import { die, IFFI, importDefaultForRuntime } from './mod.ts'; export const STDIN_FILENO = 0; export const STOUT_FILENO = 1; @@ -8,26 +8,104 @@ export const TCSAFLUSH = 2; export const TERMIOS_SIZE = 60; /** - * Common interface for setting Termios properties + * Implementation to toggle raw mode */ -export interface ITermios { +class Termios { /** - * Are we currently in raw mode? + * The ffi implementation for the current runtime + * @private */ - inRawMode: boolean; + #ffi: IFFI; /** - * Toggles on raw mode + * Are we in raw mode? + * @private */ - enableRawMode(): void; + #inRawMode: boolean; /** - * Restores canonical mode + * The saved version of the termios struct for cooked/canonical mode + * @private */ - disableRawMode(): void; + #cookedTermios: Uint8Array; + + /** + * The data for the termios struct we are manipulating + * @private + */ + readonly #termios: Uint8Array; + + /** + * The pointer to the termios struct + * @private + */ + readonly #ptr: any; + + constructor(ffi: IFFI) { + this.#ffi = ffi; + this.#inRawMode = false; + + // These are the TypedArrays linked to the raw pointer data + this.#cookedTermios = new Uint8Array(TERMIOS_SIZE); + this.#termios = new Uint8Array(TERMIOS_SIZE); + + // The current pointer for C + this.#ptr = ffi.getPointer(this.#termios); + } + + get inRawMode() { + return this.#inRawMode; + } + + enableRawMode() { + if (this.#inRawMode) { + throw new Error('Can not enable raw mode when in raw mode'); + } + + // Get the current termios settings + let res = this.#ffi.tcgetattr(STDIN_FILENO, this.#ptr); + if (res === -1) { + die('Failed to get terminal settings'); + } + + // The #ptr property is pointing to the #termios TypedArray. As the pointer + // is manipulated, the TypedArray is as well. We will use this to save + // the original canonical/cooked terminal settings for disabling raw mode later. + // @ts-ignore: bad type definition + this.#cookedTermios = new Uint8Array(this.#termios, 0, 60); + + // Update termios struct with (most of the) raw settings + this.#ffi.cfmakeraw(this.#ptr); + + // @TODO: Tweak a few more terminal settings + + // Actually set the new termios settings + res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, this.#ptr); + if (res === -1) { + die('Failed to update terminal settings. Can\'t enter raw mode'); + } + + this.#inRawMode = true; + } + + disableRawMode() { + // Don't even bother throwing an error if we try to disable raw mode + // and aren't in raw mode. It just doesn't really matter. + if (!this.#inRawMode) { + return; + } + + const oldTermiosPtr = this.#ffi.getPointer(this.#cookedTermios); + const res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, oldTermiosPtr); + if (res === -1) { + die('Failed to restore canonical mode.'); + } + + this.#inRawMode = false; + } } -let termiosSingleton: ITermios | null = null; +let termiosSingleton: Termios | null = null; export const getTermios = async () => { if (termiosSingleton !== null) { @@ -35,103 +113,8 @@ export const getTermios = async () => { } // Get the runtime-specific ffi wrappers - const { tcgetattr, tcsetattr, cfmakeraw, getPointer } = - await importForRuntime('ffi'); - - /** - * Implementation to toggle raw mode - */ - class Termios implements ITermios { - /** - * Are we in raw mode? - * @private - */ - #inRawMode: boolean; - - /** - * The saved version of the termios struct for cooked/canonical mode - * @private - */ - #cookedTermios: Uint8Array; - - /** - * The data for the termios struct we are manipulating - * @private - */ - readonly #termios: Uint8Array; - - /** - * The pointer to the termios struct - * @private - */ - readonly #ptr: any; - - constructor() { - this.#inRawMode = false; - - // These are the TypedArrays linked to the raw pointer data - this.#cookedTermios = new Uint8Array(TERMIOS_SIZE); - this.#termios = new Uint8Array(TERMIOS_SIZE); - - // The current pointer for C - this.#ptr = getPointer(this.#termios); - } - - get inRawMode() { - return this.#inRawMode; - } - - enableRawMode() { - if (this.#inRawMode) { - throw new Error('Can not enable raw mode when in raw mode'); - } - - // Get the current termios settings - let res = tcgetattr(STDIN_FILENO, this.#ptr); - if (res === -1) { - die('Failed to get terminal settings'); - } - - // The #ptr property is pointing to the #termios TypedArray. As the pointer - // is manipulated, the TypedArray is as well. We will use this to save - // the original canonical/cooked terminal settings for disabling raw mode later. - this.#cookedTermios = new Uint8Array(this.#termios, 0, 60); - - // Update termios struct with (most of the) raw settings - res = cfmakeraw(this.#ptr); - if (res === -1) { - die('Failed to call cfmakeraw'); - } - - // @TODO: Tweak a few more terminal settings - - // Actually set the new termios settings - res = tcsetattr(STDIN_FILENO, TCSANOW, this.#ptr); - if (res === -1) { - die('Failed to update terminal settings. Can\'t enter raw mode'); - } - - this.#inRawMode = true; - } - - disableRawMode() { - // Don't even bother throwing an error if we try to disable raw mode - // and aren't in raw mode. It just doesn't really matter. - if (!this.#inRawMode) { - return; - } - - const oldTermiosPtr = getPointer(this.#cookedTermios); - const res = tcsetattr(STDIN_FILENO, TCSANOW, oldTermiosPtr); - if (res === -1) { - die('Failed to restore canonical mode.'); - } - - this.#inRawMode = false; - } - } - - termiosSingleton = new Termios(); + const ffi: IFFI = await importDefaultForRuntime('ffi'); + termiosSingleton = new Termios(ffi); return termiosSingleton; }; diff --git a/src/common/test_base.ts b/src/common/test_base.ts deleted file mode 100644 index b01e050..0000000 --- a/src/common/test_base.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * The shared test interface, so tests can be run by both runtimes - */ -export interface ITestBase { - test(name: string, fn: () => void): void; - assertEquals(actual: unknown, expected: unknown): void; - assertNotEquals(actual: unknown, expected: unknown): void; - assertStrictEquals(actual: unknown, expected: unknown): void; - assertExists(actual: unknown): void; - assertInstanceOf(actual: unknown, expectedType: any): void; - assertTrue(actual: boolean): void; - assertFalse(actual: boolean): void; -} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..12356dc --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,61 @@ +/** + * The shared test interface, so tests can be run by both runtimes + */ +export interface ITestBase { + test(name: string, fn: () => void): void; + assertEquals(actual: unknown, expected: unknown): void; + assertNotEquals(actual: unknown, expected: unknown): void; + assertStrictEquals(actual: unknown, expected: unknown): void; + assertExists(actual: unknown): void; + assertInstanceOf(actual: unknown, expectedType: any): void; + assertTrue(actual: boolean): void; + assertFalse(actual: boolean): void; +} + +/** + * The native functions for terminal settings + */ +export interface IFFI { + /** + * Get the existing termios settings (for canonical mode) + */ + tcgetattr(fd: number, termiosPtr: unknown): number; + + /** + * Update the termios settings + */ + tcsetattr(fd: number, act: number, termiosPtr: unknown): number; + + /** + * Update the termios pointer with raw mode settings + */ + cfmakeraw(termiosPtr: unknown): void; + + /** + * Convert a TypedArray to an opaque pointer for ffi calls + */ + getPointer(ta: any): unknown; +} + +/** + * Runtime-specific IO streams + */ +export interface ITerminalIO { + /** + * The generator function returning chunks of input from the stdin stream + */ + inputLoop(): AsyncGenerator; + + /** + * Pipe a string to stdout + */ + write(s: string): Promise; +} + +export interface IRuntime { + /** + * Set a beforeExit/beforeUnload event handler for the runtime + * @param cb - The event handler + */ + onExit(cb: () => void): void; +} diff --git a/src/deno/ffi.ts b/src/deno/ffi.ts index d0c9eca..08644f4 100644 --- a/src/deno/ffi.ts +++ b/src/deno/ffi.ts @@ -1,8 +1,5 @@ // Deno-specific ffi code - -// Determine library extension based on -// your OS. -// import { termiosStruct } from "../common/termios.ts"; +import { IFFI } from '../common/types.ts'; let libSuffix = ''; switch (Deno.build.os) { @@ -36,6 +33,12 @@ const cStdLib = Deno.dlopen( } as const, ); -export const { tcgetattr, tcsetattr, cfmakeraw } = cStdLib.symbols; +const { tcgetattr, tcsetattr, cfmakeraw } = cStdLib.symbols; +const DenoFFI: IFFI = { + tcgetattr, + tcsetattr, + cfmakeraw, + getPointer: Deno.UnsafePointer.of, +}; -export const getPointer = Deno.UnsafePointer.of; +export default DenoFFI; diff --git a/src/deno/mod.ts b/src/deno/mod.ts index 2279d07..fc403f2 100644 --- a/src/deno/mod.ts +++ b/src/deno/mod.ts @@ -1,17 +1,16 @@ /** * The main entrypoint when using Deno as the runtime */ -import { getTermios } from '../common/mod.ts'; +import { IRuntime } from '../common/types.ts'; export * from './terminal_io.ts'; -export async function init() { - const t = await getTermios(); +export const onExit = (cb: () => void): void => { + globalThis.addEventListener('onbeforeunload', cb); +}; - t.enableRawMode(); +const DenoRuntime: IRuntime = { + onExit, +}; - globalThis.onbeforeunload = (): void => { - console.info('Disabling raw mode'); - t.disableRawMode(); - }; -} +export default DenoRuntime; diff --git a/src/deno/terminal_io.ts b/src/deno/terminal_io.ts index cab4cdb..ee264b7 100644 --- a/src/deno/terminal_io.ts +++ b/src/deno/terminal_io.ts @@ -1,3 +1,5 @@ +import { ITerminalIO } from '../common/types.ts'; + /** * Wrap the runtime-specific hook into stdin */ @@ -14,3 +16,10 @@ export async function write(s: string): Promise { await stdout.write(buffer); stdout.releaseLock(); } + +const DenoTerminalIO: ITerminalIO = { + inputLoop, + write, +}; + +export default DenoTerminalIO; diff --git a/src/deno/test_base.ts b/src/deno/test_base.ts index f34d188..852e809 100644 --- a/src/deno/test_base.ts +++ b/src/deno/test_base.ts @@ -47,5 +47,5 @@ class TestBase implements ITestBase { } } -export const testBase = new TestBase(); +const testBase = new TestBase(); export default testBase; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a803573 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "module": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "target": "esnext", + "types": ["bun-types"], + "lib": ["ESNext"], + "noEmit": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "composite": true, + "downlevelIteration": true, + "allowSyntheticDefaultImports": true + }, + "exclude": ["src/deno"] +}