Skip to content

Commit

Permalink
fix(trampoline): cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
boneskull committed Aug 15, 2024
1 parent 170d91c commit 41c0d0c
Show file tree
Hide file tree
Showing 13 changed files with 354 additions and 63 deletions.
87 changes: 82 additions & 5 deletions packages/trampoline/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,86 @@
# supertramp
# @endo/trampoline

> Sync and async trampolines, for your pleasure
> Multicolor trampolining using generators (for your pleasure)
## Wat
## Example Usage

From [Wikipedia](https://en.wikipedia.org/wiki/Trampoline_(computing)):
```js
import {trampoline, syncTrampoline} from '@endo/trampoline';

/**
* This function "reads a file synchronously" and returns "a list of its imports"
*
* @param {string} filepath Source file path
* @returns {string[]} List of imports found in source
*/
const findImportsSync = filepath => {
// read a file, parse it for imports, return a list of import specifiers
// (synchronously)
// ...
};

/**
* This function "reads a file asynchronously" and returns "a list of its imports"
*
* @param {string} filepath Source file path
* @returns {Promise<string[]>} List of imports found in source
*/
const findImportsAsync = async filepath => {
// read a file, parse it for imports, return a list of import specifiers
// (asynchronously)
// ...
};

/**
* Recursively crawls a dependency tree to find all dependencies
*
* @template {string[]|Promise<string[]>} TResult
* @param {ThunkFn<string, TResult>} thunk
* @param {string} filename
* @returns {Generator<TResult, string[], string[]>}
*/
function* loadRecursive(thunk, filename) {
let specifiers = yield thunk(filename);

// pretend there's some de-duping, caching,
// scrubbing, etc. happening here!

for (const specifier of specifiers) {
specifiers = [...specifiers, ...(yield* loadRecursive(thunk, specifier))];
}
return specifiers;
}

// results are an array of all imports found in some.js' dependency tree

const asyncResult = await trampoline(loadRecursive, readAsync, './some.js');
const syncResult = syncTrampoline(loadRecursive, readSync, './some.txt');

asyncResult === syncResult; // true
```

In the above example, **@endo/trampoline** allows us to re-use the operations in `loadRecursive` for both sync and async execution. An implementation _without_ **@endo/trampoline** would need to duplicate the operations into two (2) discrete recursive functions—a synchronous-colored function and an asynchronous-colored function. Over time, this situation commonly leads to diverging implementations. If that _doesn't_ sound like a big deal for _whatever you're trying to do here_, then you probably don't need **@endo/trampoline**.

## What is this?

The pattern exposed by this library—known as [trampolining][]—helps manage control flow in a way that avoids deep recursion and potential stack overflows. It effectively "converts" recursive calls into a loop. This is especially helpful in a language like JavaScript [because reasons][proper-tail-calls].

**@endo/trampoline** provides the trampolining pattern, but in such a way that consumer can execute _either_ synchronous _or_ asynchronous operations _paired with operations common to both_.

In other words, **@endo/trampoline** can help _reduce code duplication_ when recursive operations must be executed _in both sync and async_ contexts.

## Install

The usual sort of thing:

```sh
npm install @endo/trampoline
```

## License

Apache-2.0

[trampolining]: https://raganwald.com/2013/03/28/trampolines-in-javascript.html
[proper-tail-calls]: https://www.mgmarlow.com/words/2021-03-27-proper-tail-calls-js/

> A trampoline is a loop that iteratively invokes [thunk](https://en.wikipedia.org/wiki/Thunk_(functional_programming))-returning functions ([continuation-passing style](https://en.wikipedia.org/wiki/Continuation-passing_style)).
22 changes: 16 additions & 6 deletions packages/trampoline/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
{
"name": "@endo/trampoline",
"version": "0.0.0",
"description": "",
"keywords": [],
"version": "0.1.0",
"description": "Multicolor trampolining for recursive operations",
"keywords": [
"trampoline",
"recursive",
"async",
"sync",
"generator"
],
"author": "Endo contributors",
"license": "Apache-2.0",
"homepage": "https://github.com/endojs/endo/blob/master/packages/trampoline/README.md",
Expand All @@ -18,9 +24,13 @@
"main": "./index.js",
"module": "./index.js",
"exports": {
".": "./index.js",
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./package.json": "./package.json"
},
"types": "./index.d.ts",
"scripts": {
"build": "exit 0",
"build:types": "tsc --build tsconfig.build.json",
Expand All @@ -30,7 +40,7 @@
"lint-fix": "eslint --fix .",
"lint:eslint": "eslint .",
"lint:types": "tsc",
"test:types": "tsd -f \"test/*.test-d.ts\"",
"test:types": "yarn build:types && tsd -f \"test/*.test-d.ts\"; yarn clean:types",
"test:ava": "ava",
"test": "yarn test:types && yarn test:ava"
},
Expand Down Expand Up @@ -62,7 +72,7 @@
},
"ava": {
"files": [
"test/**/test-*.js"
"test/**/*.test.js"
],
"timeout": "2m"
},
Expand Down
26 changes: 13 additions & 13 deletions packages/trampoline/src/trampoline.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
/* eslint-disable @jessie.js/safe-await-separator */
/**
* @import {ThunkFn, SyncTrampolineGeneratorFn, TrampolineGeneratorFn } from './types.js'
* @import {ThunkFn, SyncTrampolineGeneratorFn, TrampolineGeneratorFn, AsyncTrampolineGeneratorFn, SyncThunkFn } from './types.js'
*/

/**
* Synchronous trampoline
*
* This trampoline will only accept a `Hook` where `HookResult` is _not_ a `Promise`.
* Trampoline on {@link TrampolineGeneratorFn generatorFn} with a synchronous {@link SyncThunkFn thunk}.
*
* @template TInitial Type of the initial value passed to the `generatorFn`
* @template [TArg=TInitial] Type of the argument passed to the `thunkFn`
* @template [TResult=TArg] Result of the `thunkFn` _and_ the return value of the `generatorFn`
* @param {SyncTrampolineGeneratorFn<TInitial, TArg, TResult>} generatorFn Generator-returning function accepting a thunk and optionally an initial value
* @param {ThunkFn<TArg, TResult>} thunkFn Synchronous thunk which `generatorFn` should call
* @param {SyncThunkFn<TArg, TResult>} thunk Synchronous thunk which `generatorFn` should call
* @param {TInitial} initial Initial value
* @returns {TResult}
*/
export function syncTrampoline(generatorFn, thunkFn, initial) {
const iterator = generatorFn(thunkFn, initial);
export function syncTrampoline(generatorFn, thunk, initial) {
const iterator = generatorFn(thunk, initial);
let result = iterator.next();
while (!result.done) {
result = iterator.next(result.value);
Expand All @@ -25,20 +24,18 @@ export function syncTrampoline(generatorFn, thunkFn, initial) {
}

/**
* Asynchronous trampoline
*
* This trampoline will accept a {@link ThunkFn} where `TResult` _may_ be a `Promise`.
* Trampoline on {@link TrampolineGeneratorFn generatorFn} with a synchronous _or_ asynchronous {@link ThunkFn thunk}.
*
* @template TInitial Type of the initial value passed to the `generatorFn`
* @template [TArg=TInitial] Type of the argument passed to the `thunkFn`
* @template [TResult=TArg] Result of `thunkFn` _and_ the return value of the `generatorFn`
* @param {TrampolineGeneratorFn<TInitial, TArg, TResult>} generatorFn Generator-returning function accepting a thunk and optionally an initial value
* @param {ThunkFn<TArg, TResult>} thunkFn Thunk function
* @param {ThunkFn<TArg, TResult>} thunk Thunk function
* @param {TInitial} initial Initial value passed to `generatorFn`
* @returns {Promise<Awaited<TResult>>} Final value of generator
*/
export async function trampoline(generatorFn, thunkFn, initial) {
const iterator = generatorFn(thunkFn, initial);
export async function trampoline(generatorFn, thunk, initial) {
const iterator = generatorFn(thunk, initial);
let result = iterator.next();
while (!result.done) {
// eslint-disable-next-line no-await-in-loop
Expand All @@ -48,4 +45,7 @@ export async function trampoline(generatorFn, thunkFn, initial) {
return result.value;
}

/**
* Alias for {@link trampoline}
*/
export const asyncTrampoline = trampoline;
62 changes: 46 additions & 16 deletions packages/trampoline/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
export type ThunkFn<HookArg, HookResult> = (arg: HookArg) => HookResult;
/**
* A {@link TrampolineGeneratorFn} will yield the result of calling this
* function
*/
export type ThunkFn<TArg, TResult = TArg> = (arg: TArg) => TResult;

export type TrampolineGeneratorFn<
TInitial,
TArg = TInitial,
TResult = TArg,
Thunk extends ThunkFn<TArg, TResult> = ThunkFn<TArg, TResult>,
> = (
thunkFn: Thunk,
/**
* A {@link SyncTrampolineGeneratorFn} or {@link TrampolineGeneratorFn} will
* yield the result of calling this function
*/
export type SyncThunkFn<TArg, TResult = TArg> =
TResult extends Promise<any> ? never : (arg: TArg) => TResult;

/**
* A function type that represents a generator function for trampolining.
*
* @template TInitial - The type of the initial value.
* @template TArg - The type of the argument passed to the thunk function.
* Defaults to `TInitial`.
* @template TResult - The type of the result produced by the thunk function.
* Defaults to `TArg`.
* @param thunk - The thunk function to be used in the generator.
* @param initial - The initial value to start the generator.
* @returns A generator that yields results of type `TResult`.
*/
export type TrampolineGeneratorFn<TInitial, TArg = TInitial, TResult = TArg> = (
thunk: ThunkFn<TArg, TResult>,
initial: TInitial,
) => Generator<TResult, Awaited<TResult>, Awaited<TResult>>;

/**
* A function type that represents a synchronous generator function for
* trampolining.
*
* This type ensures that the result type (`TResult`) is not a `Promise`. If
* `TResult` extends `Promise`, the type resolves to `never`.
*
* @template TInitial - The type of the initial value.
* @template TArg - The type of the argument passed to the thunk function.
* Defaults to `TInitial`.
* @template TResult - The type of the result produced by the thunk function.
* Defaults to `TArg`.
* @param thunk - The thunk function to be used in the generator.
* @param initial - The initial value to start the generator.
* @returns A generator that yields results of type `TResult`.
*/
export type SyncTrampolineGeneratorFn<
TInitial,
TArg = TInitial,
TResult = TArg,
Thunk extends ThunkFn<TArg, TResult> = ThunkFn<TArg, TResult>,
> =
TResult extends Promise<any>
? never
: (
thunkFn: Thunk,
initial: TInitial,
) => Generator<TResult, TResult, TResult>;
> = (
thunk: ThunkFn<TArg, TResult>,
initial: TInitial,
) => Generator<TResult, TResult, TResult>;
1 change: 1 addition & 0 deletions packages/trampoline/test/fixture/a.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
b.txt
1 change: 1 addition & 0 deletions packages/trampoline/test/fixture/b.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
c.txt
1 change: 1 addition & 0 deletions packages/trampoline/test/fixture/c.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
d.txt
Empty file.
1 change: 1 addition & 0 deletions packages/trampoline/test/fixture/initial.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a.txt
83 changes: 83 additions & 0 deletions packages/trampoline/test/trampoline-example.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* These tests are based on the example from the `README`
* @module
*/

import test from 'ava';
import { syncTrampoline, trampoline } from '../src/trampoline.js';

/**
* @import {ThunkFn} from '../src/types.js'
*/

/**
* Mapping of filesnames to import specifiers. Trust me
*/
const sources = /** @type {const} */ ({
a: ['b', 'c'],
b: ['c', 'd'],
c: ['e'],
e: ['f', 'g'],
});

/**
* This function "inspects the source code and returns a list of specifiers which
* need to be imported.""
*
* @param {string} source
* @returns {string[]}
*/
const findImportsInSource = source => {
return [...(sources[/** @type {keyof typeof sources} */ (source)] || [])];
};

/**
* This function "reads a file synchronously" and returns "a list of its imports"
*
* @param {string} filepath
* @returns {string[]}
*/
const findImportsSync = filepath => findImportsInSource(filepath);

/**
* This function "reads a file asynchronously" and returns "a list of its imports"
*
* @param {string} filepath
* @returns {Promise<string[]>}
*/
const findImportsAsync = async filepath => findImportsInSource(filepath);

/**
* Recursively crawls a dependency tree to find all dependencies
*
* @template {string[]|Promise<string[]>} TResult
* @param {ThunkFn<string, TResult>} thunk
* @param {string} filename
* @returns {Generator<TResult, string[], string[]>}
*/
function* loadRecursive(thunk, filename) {
let specifiers = yield thunk(filename);
// pretend there's some de-duping, caching,
// scrubbing, etc. happening here
for (const specifier of specifiers) {
specifiers = [...specifiers, ...(yield* loadRecursive(thunk, specifier))];
}
return specifiers;
}

const expected = ['b', 'c', 'c', 'd', 'e', 'f', 'g', 'e', 'f', 'g'];

test('asynchronous execution', async t => {
const asyncResult = await trampoline(loadRecursive, findImportsAsync, 'a');
t.deepEqual(asyncResult, expected);
});

test('asynchronous execution w/ sync thunk', async t => {
const asyncResult = await trampoline(loadRecursive, findImportsSync, 'a');
t.deepEqual(asyncResult, expected);
});

test('synchronous execution', t => {
const syncResult = syncTrampoline(loadRecursive, findImportsSync, 'a');
t.deepEqual(syncResult, expected);
});
2 changes: 2 additions & 0 deletions packages/trampoline/test/trampoline.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable no-redeclare */
import { expectAssignable, expectNever, expectNotType, expectType } from 'tsd';
import { trampoline, syncTrampoline } from '../src/trampoline.js';
import {
SyncThunkFn,
SyncTrampolineGeneratorFn,
ThunkFn,
TrampolineGeneratorFn,
Expand Down
Loading

0 comments on commit 41c0d0c

Please sign in to comment.