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": {
"include": ["src/"],
"rules": {

View File

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

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
*/
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;

View File

@ -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();
})
}
export const onExit = (cb: () => void): void => {
process.on('beforeExit', cb);
};

View File

@ -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<void> {
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
*/
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,6 +32,10 @@ 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();

View File

@ -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();

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');

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

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
// 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;

View File

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

View File

@ -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<void> {
await stdout.write(buffer);
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;

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