Extract common interfaces out of runtime-specific adapters

This commit is contained in:
Timothy Warren 2023-11-08 15:53:14 -05:00
parent 8155f4dc73
commit 9cca55b101
17 changed files with 255 additions and 166 deletions

View File

@ -1,5 +1,4 @@
{ {
"exclude": ["src/bun/**/*.ts"],
"lint": { "lint": {
"include": ["src/"], "include": ["src/"],
"rules": { "rules": {

View File

@ -3,12 +3,7 @@ default:
@just --list @just --list
# Typescript checking # Typescript checking
check: lint check: deno-check bun-check
deno check --unstable --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts
# Code linting
lint:
deno lint
# Reformat the code # Reformat the code
fmt: fmt:
@ -27,6 +22,10 @@ clean:
# Bun-specific commands # Bun-specific commands
######################################################################################################################## ########################################################################################################################
# Check code with actual Typescript compiler
bun-check:
bunx tsc
# Test with bun # Test with bun
bun-test: bun-test:
bun test --coverage bun test --coverage
@ -39,6 +38,11 @@ bun-run:
# Deno-specific commands # 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 # Test with deno
deno-test: deno-test:
deno test --allow-all deno test --allow-all

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {},
"devDependencies": {
"bun-types": "^1.0.11"
}
}

View File

@ -2,6 +2,7 @@
* This is all the nasty ffi setup for the bun runtime * This is all the nasty ffi setup for the bun runtime
*/ */
import { dlopen, ptr, suffix } from 'bun:ffi'; import { dlopen, ptr, suffix } from 'bun:ffi';
import { IFFI } from '../common/types.ts';
const getLib = (name: string) => { const getLib = (name: string) => {
return dlopen( return dlopen(
@ -23,7 +24,7 @@ const getLib = (name: string) => {
); );
}; };
let cStdLib = { symbols: {} }; let cStdLib: any = { symbols: {} };
try { try {
cStdLib = getLib(`libc.${suffix}`); cStdLib = getLib(`libc.${suffix}`);
@ -35,5 +36,12 @@ try {
} }
} }
export const { tcgetattr, tcsetattr, cfmakeraw } = cStdLib.symbols; const { tcgetattr, tcsetattr, cfmakeraw } = cStdLib.symbols;
export const getPointer = ptr; const BunFFI: IFFI = {
tcgetattr,
tcsetattr,
cfmakeraw,
getPointer: ptr,
};
export default BunFFI;

View File

@ -2,16 +2,8 @@
* The main entrypoint when using Bun as the runtime * The main entrypoint when using Bun as the runtime
*/ */
import { getTermios } from "../common/mod.ts";
export * from './terminal_io.ts'; export * from './terminal_io.ts';
export async function init() { export const onExit = (cb: () => void): void => {
const t = await getTermios(); process.on('beforeExit', cb);
};
t.enableRawMode();
process.on('exit', () => {
console.info('Disabling raw mode');
t.disableRawMode();
})
}

View File

@ -1,6 +1,8 @@
/** /**
* Wrap the runtime-specific hook into stdin * Wrap the runtime-specific hook into stdin
*/ */
import { ITerminalIO } from '../common/mod.ts';
export async function* inputLoop() { export async function* inputLoop() {
for await (const chunk of Bun.stdin.stream()) { for await (const chunk of Bun.stdin.stream()) {
yield chunk; yield chunk;
@ -12,3 +14,10 @@ export async function write(s: string): Promise<void> {
await Bun.write(Bun.stdout, buffer); await Bun.write(Bun.stdout, buffer);
} }
const BunTerminalIO: ITerminalIO = {
inputLoop,
write,
};
export default BunTerminalIO;

View File

@ -1,8 +1,8 @@
/** /**
* Adapt the bun test interface to the shared testing interface * Adapt the bun test interface to the shared testing interface
*/ */
import {test as btest, expect } from 'bun:test'; import { expect, test as btest } from 'bun:test';
import {ITestBase} from "../common/mod"; import { ITestBase } from '../common/mod';
class TestBase implements ITestBase { class TestBase implements ITestBase {
test(name: string, fn: () => void) { test(name: string, fn: () => void) {
@ -32,7 +32,11 @@ class TestBase implements ITestBase {
assertTrue(actual: boolean): void { assertTrue(actual: boolean): void {
return expect(actual).toBe(true); return expect(actual).toBe(true);
} }
assertStrictEquals(actual: unknown, expected: unknown): void {
return expect(actual).toBe(expected);
}
} }
const testBase = new TestBase(); const testBase = new TestBase();
export default testBase; export default testBase;

View File

@ -1,18 +1,23 @@
import { importForRuntime } from './runtime.ts'; import { importForRuntime } from './runtime.ts';
import { Editor } from './editor.ts'; import { Editor } from './editor.ts';
import { getTermios } from './termios.ts';
export * from './runtime.ts'; export * from './runtime.ts';
export * from './strings.ts'; export * from './strings.ts';
export * from './termios.ts'; export type * from './types.ts';
export type { ITestBase } from './test_base.ts';
const decoder = new TextDecoder(); const decoder = new TextDecoder();
export async function main() { 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 // Setup raw mode, and tear down on error or normal exit
await init(); const t = await getTermios();
t.enableRawMode();
onExit(() => {
console.info('Exit handler called, disabling raw mode');
t.disableRawMode();
});
// Create the editor itself // Create the editor itself
const editor = new Editor(); const editor = new Editor();

View File

@ -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'); const t: ITestBase = await importDefaultForRuntime('test_base');

View File

@ -1,4 +1,4 @@
import { die, importForRuntime } from './mod.ts'; import { die, IFFI, importDefaultForRuntime } from './mod.ts';
export const STDIN_FILENO = 0; export const STDIN_FILENO = 0;
export const STOUT_FILENO = 1; export const STOUT_FILENO = 1;
@ -8,26 +8,104 @@ export const TCSAFLUSH = 2;
export const TERMIOS_SIZE = 60; 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 () => { export const getTermios = async () => {
if (termiosSingleton !== null) { if (termiosSingleton !== null) {
@ -35,103 +113,8 @@ export const getTermios = async () => {
} }
// Get the runtime-specific ffi wrappers // Get the runtime-specific ffi wrappers
const { tcgetattr, tcsetattr, cfmakeraw, getPointer } = const ffi: IFFI = await importDefaultForRuntime('ffi');
await importForRuntime('ffi'); termiosSingleton = new Termios(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();
return termiosSingleton; return termiosSingleton;
}; };

View File

@ -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;
}

61
src/common/types.ts Normal file
View File

@ -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<Uint8Array, void, unknown>;
/**
* Pipe a string to stdout
*/
write(s: string): Promise<void>;
}
export interface IRuntime {
/**
* Set a beforeExit/beforeUnload event handler for the runtime
* @param cb - The event handler
*/
onExit(cb: () => void): void;
}

View File

@ -1,8 +1,5 @@
// Deno-specific ffi code // Deno-specific ffi code
import { IFFI } from '../common/types.ts';
// Determine library extension based on
// your OS.
// import { termiosStruct } from "../common/termios.ts";
let libSuffix = ''; let libSuffix = '';
switch (Deno.build.os) { switch (Deno.build.os) {
@ -36,6 +33,12 @@ const cStdLib = Deno.dlopen(
} as const, } 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;

View File

@ -1,17 +1,16 @@
/** /**
* The main entrypoint when using Deno as the runtime * 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 * from './terminal_io.ts';
export async function init() { export const onExit = (cb: () => void): void => {
const t = await getTermios(); globalThis.addEventListener('onbeforeunload', cb);
};
t.enableRawMode(); const DenoRuntime: IRuntime = {
onExit,
};
globalThis.onbeforeunload = (): void => { export default DenoRuntime;
console.info('Disabling raw mode');
t.disableRawMode();
};
}

View File

@ -1,3 +1,5 @@
import { ITerminalIO } from '../common/types.ts';
/** /**
* Wrap the runtime-specific hook into stdin * Wrap the runtime-specific hook into stdin
*/ */
@ -14,3 +16,10 @@ export async function write(s: string): Promise<void> {
await stdout.write(buffer); await stdout.write(buffer);
stdout.releaseLock(); stdout.releaseLock();
} }
const DenoTerminalIO: ITerminalIO = {
inputLoop,
write,
};
export default DenoTerminalIO;

View File

@ -47,5 +47,5 @@ class TestBase implements ITestBase {
} }
} }
export const testBase = new TestBase(); const testBase = new TestBase();
export default testBase; export default testBase;

19
tsconfig.json Normal file
View File

@ -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"]
}