Support calling JS from Forth

Initial attempt at an API.

Resolves: #13
This commit is contained in:
Remko Tronçon 2022-05-05 20:19:03 +02:00
parent 674dce6514
commit a6d71029dc
17 changed files with 3100 additions and 2829 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ src/waforth.bulkmem.wat
src/waforth.vanilla.wat
src/web/benchmarks/sieve/sieve-c.js
build/
dist/
*.wasm
*.wasm.hex
*.tmp

View file

@ -22,8 +22,60 @@ Words](http://lars.nocrew.org/dpans/dpans6.htm#6.1), and passes most of the
[Forth 200x Test Suite](https://forth-standard.org/standard/testsuite)
core word tests.
![WAForth Console](https://mko.re/waforth/console.gif "WAForth Console")
## Install Dependencies
## Using WAForth in an application
You can embed WAForth in any JavaScript application.
A simple example to illustrate starting WAForth, and binding JavaScript functions:
```typescript
import WAForth from "waforth";
(async () => {
// Create the UI
document.body.innerHTML = `<button>Go!</button><pre></pre>`;
const btn = document.querySelector("button");
const log = document.querySelector("pre");
// Initialize WAForth
const forth = new WAForth();
forth.onEmit = (c) =>
log.appendChild(document.createTextNode(String.fromCharCode(c)));
await forth.load();
// Bind "prompt" call to a function that pops up a JavaScript prompt, and pushes the entered number back on the stack
forth.bind("prompt", (stack) => {
const message = stack.popString();
const result = window.prompt(message);
stack.push(parseInt(result));
});
// Load Forth code to bind the "prompt" call to a word, and call the word
forth.interpret(`
( Call "prompt" with the given string )
: PROMPT ( c-addr u -- n )
S" prompt" SCALL
;
( Prompt the user for a number, and write it to output )
: ASK-NUMBER ( -- )
S" Please enter a number" PROMPT
." The number was" SPACE .
;
`);
btn.addEventListener("click", () => {
forth.interpret("ASK-NUMBER");
});
})();
```
## Development
### Install Dependencies
The build uses the [WebAssembly Binary
Toolkit](https://github.com/WebAssembly/wabt) for converting raw WebAssembly
@ -34,7 +86,7 @@ managing the dependencies of the shell.
yarn
## Building & Running
### Building & Running
To build everything:
@ -44,7 +96,7 @@ To run the development server:
make dev
## Testing
### Testing
The tests are served from `/waforth/tests` by the development server.
@ -116,11 +168,10 @@ The shell is [a JavaScript
class](https://github.com/remko/waforth/blob/master/src/shell/WAForth.js) that
wraps the WebAssembly module, and loads it in the browser. It provides the I/O
primitives to the WebAssembly module to read and write characters to a
terminal, and externally provides a `run()` function to execute a fragment of
terminal, and externally provides a `interpret()` function to execute a fragment of
Forth code.
To tie everything together into an interactive system, there's a small
console-based interface around this shell to type Forth code, which you can see
in action [here](https://mko.re/waforth/).
![WAForth Console](https://mko.re/waforth/console.gif "WAForth Console")

28
build-package.js Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env node
/* eslint-env node */
const esbuild = require("esbuild");
const path = require("path");
const { execSync } = require("child_process");
const { wasmTextPlugin } = require("./scripts/esbuild/wasm-text");
let buildConfig = {
bundle: true,
logLevel: "info",
entryPoints: [path.join(__dirname, "src", "web", "WAForth")],
outfile: path.join(__dirname, "dist", "index.js"),
minify: true,
format: "cjs",
loader: {
".wasm": "binary",
},
sourcemap: true,
plugins: [wasmTextPlugin({ debug: false })],
};
esbuild.build(buildConfig).then(
() => {
execSync("./node_modules/.bin/tsc --project tsconfig.package.json");
},
() => process.exit(1)
);

View file

@ -61,6 +61,7 @@ let buildConfig = {
path.join(__dirname, "src", "web", "shell", "shell"),
path.join(__dirname, "src", "web", "tests", "tests"),
path.join(__dirname, "src", "web", "benchmarks", "benchmarks"),
path.join(__dirname, "src", "web", "examples", "prompt", "prompt"),
],
entryNames: dev ? "[name]" : "[name]-c$[hash]",
assetNames: "[name]-c$[hash]",
@ -99,6 +100,7 @@ async function handleBuildFinished(result) {
let index = INDEX_TEMPLATE.replace(/\$BASE/g, "shell");
let testIndex = INDEX_TEMPLATE.replace(/\$BASE/g, "tests");
let benchmarksIndex = INDEX_TEMPLATE.replace(/\$BASE/g, "benchmarks");
let promptIndex = INDEX_TEMPLATE.replace(/\$BASE/g, "prompt");
// console.log(JSON.stringify(result.metafile.outputs, undefined, 2));
for (const [out] of Object.entries(result.metafile.outputs)) {
const outfile = path.basename(out);
@ -107,6 +109,7 @@ async function handleBuildFinished(result) {
index = index.replace(`/${sourcefile}`, `/${outfile}`);
testIndex = testIndex.replace(`/${sourcefile}`, `/${outfile}`);
benchmarksIndex = benchmarksIndex.replace(`/${sourcefile}`, `/${outfile}`);
promptIndex = promptIndex.replace(`/${sourcefile}`, `/${outfile}`);
}
await fs.promises.writeFile("public/waforth/index.html", index);
await fs.promises.mkdir("public/waforth/tests", { recursive: true });
@ -116,6 +119,13 @@ async function handleBuildFinished(result) {
"public/waforth/benchmarks/index.html",
benchmarksIndex
);
await fs.promises.mkdir("public/waforth/examples/prompt", {
recursive: true,
});
await fs.promises.writeFile(
"public/waforth/examples/prompt/index.html",
promptIndex
);
}
if (watch) {

View file

@ -1,8 +1,11 @@
{
"private": true,
"name": "waforth",
"version": "0.2.0",
"license": "MIT",
"repository": "github:remko/waforth",
"dependencies": {},
"devDependencies": {
"@types/node": "^17.0.31",
"chai": "^4.3.6",
"esbuild": "^0.14.36",
"eslint": "^8.13.0",
@ -14,13 +17,23 @@
"mocha": "^9.2.2",
"prettier": "^2.6.2",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react-dom": "^18.0.0",
"typescript": "^4.6.4"
},
"types": "dist/WAForth.d.ts",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"build": "./build-web.js",
"dev": "./build-web.js --watch --development",
"test": "./test-web.js",
"test-watch": "./test-web.js --watch",
"lint": "eslint ."
}
"lint": "eslint .",
"prepare": "./build-package.js"
},
"keywords": [
"forth"
]
}

View file

@ -7,11 +7,11 @@ const process = require("process");
function encodeLE(n, align) {
return (
"\\u00" +
"\\" +
_.padStart(n.toString(16), align * 2, "0")
.match(/.{2}/g)
.reverse()
.join("\\u00")
.join("\\")
);
}
@ -25,7 +25,7 @@ const nextTableIndex = parseInt(process.argv[7]);
const dictionaryEntry = [
encodeLE(latest, 4),
encodeLE(name.length | flags, 1),
_.padEnd(name, 4 * Math.floor((name.length + 4) / 4) - 1, "0"),
_.padEnd(name, 6 * Math.floor((name.length + 4) / 4) - 1, "\\00"),
encodeLE(nextTableIndex, 4),
];
console.log(
@ -41,8 +41,6 @@ console.log(
console.log("latest: 0x" + here.toString(16));
console.log(
"here: 0x" +
(here + dictionaryEntry.join("").replace(/\\u..../g, "_").length).toString(
16
)
(here + dictionaryEntry.join("").replace(/\\../g, "_").length).toString(16)
);
console.log("!nextTableIndex: 0x" + (nextTableIndex + 1).toString(16));

View file

@ -23,6 +23,9 @@
;; be loaded.
(import "shell" "load" (func $shell_load (param i32 i32 i32)))
;; Generic signal to shell
(import "shell" "call" (func $shell_call))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Function types
@ -60,9 +63,9 @@
;; TYPE_INDEX := 0x85
;; ABORT_INDEX := 0x39
;; CONSTANT_INDEX := 0x4c
;; NEXT_TABLE_INDEX := 0xaa (; Next available table index for a compiled word ;)
;; NEXT_TABLE_INDEX := 0xab (; Next available table index for a compiled word ;)
(table (export "table") 0xaa (; = NEXT_TABLE_INDEX ;) funcref)
(table (export "table") 0xab (; = NEXT_TABLE_INDEX ;) funcref)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -1930,6 +1933,12 @@
(data (i32.const 0x2189c) "\90\18\02\00" "\05" "WORDS00" "\a8\00\00\00")
(elem (i32.const 0xa8) $WORDS)
(func $SCALL (param $tos i32) (result i32)
(global.set $tos (local.get $tos))
(call $shell_call)
(global.get $tos))
(data (i32.const 0x218c4) "\ac\18\02\00" "\05" "SCALL\00\00" "\aa\00\00\00")
(elem (i32.const 0xaa) $SCALL)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Interpreter
@ -2102,9 +2111,9 @@
(global $sourceID (mut i32) (i32.const 0))
;; Dictionary pointers
(global $latest (mut i32) (i32.const 0x218ac))
(global $here (mut i32) (i32.const 0x218c4))
(global $nextTableIndex (mut i32) (i32.const 0xaa (; = NEXT_TABLE_INDEX ;)))
(global $latest (mut i32) (i32.const 0x218c4))
(global $here (mut i32) (i32.const 0x218d4))
(global $nextTableIndex (mut i32) (i32.const 0xab (; = NEXT_TABLE_INDEX ;)))
;; Pictured output pointer
(global $po (mut i32) (i32.const -1))
@ -2576,11 +2585,11 @@
;; Helper functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(func $push (export "push") (param $tos i32) (param $v i32) (result i32)
(func $push (param $tos i32) (param $v i32) (result i32)
(i32.store (local.get $tos) (local.get $v))
(i32.add (local.get $tos) (i32.const 4)))
(func $pop (export "pop") (param $tos i32) (result i32) (result i32)
(func $pop (param $tos i32) (result i32) (result i32)
(local.tee $tos (i32.sub (local.get $tos) (i32.const 4)))
(i32.load (local.get $tos)))
@ -2779,6 +2788,15 @@
(call $shell_emit (i32.const 10))
(local.get $result))
(func (export "push") (param $v i32)
(global.set $tos (call $push (global.get $tos) (local.get $v))))
(func (export "pop") (result i32)
(local $result i32)
(local.set $result (call $pop (global.get $tos)))
(global.set $tos)
(local.get $result))
;; Used for experiments
(func (export "set_state") (param $latest i32) (param $here i32)
(global.set $latest (local.get $latest))

View file

@ -7,7 +7,7 @@ const isSafari =
// eslint-disable-next-line no-unused-vars
const arrayToBase64 =
typeof Buffer === "undefined"
? function arrayToBase64(bytes) {
? function arrayToBase64(bytes: Uint8Array) {
var binary = "";
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
@ -15,10 +15,17 @@ const arrayToBase64 =
}
return window.btoa(binary);
}
: function arrayToBase64(s) {
: function arrayToBase64(s: Uint8Array) {
return Buffer.from(s).toString("base64");
};
function loadString(memory: WebAssembly.Memory, addr: number, len: number) {
return String.fromCharCode.apply(
null,
new Uint8Array(memory.buffer, addr, len) as any
);
}
/**
* Small JavaScript shell around the WAForth WebAssembly module.
*
@ -28,20 +35,28 @@ const arrayToBase64 =
* the I/O primitives with the UI.
* */
class WAForth {
constructor() {}
core?: WebAssembly.Instance;
buffer?: number[];
onEmit?: (c: number) => void;
fns: Record<string, (v: Stack) => void>;
stack?: Stack;
start() {
let table;
let memory;
constructor() {
this.fns = {};
}
async load() {
let table: WebAssembly.Table;
let memory: WebAssembly.Memory;
const buffer = (this.buffer = []);
return WebAssembly.instantiate(wasmModule, {
const instance = await WebAssembly.instantiate(wasmModule, {
shell: {
////////////////////////////////////////
// I/O
////////////////////////////////////////
emit: this.onEmit,
emit: this.onEmit ?? (() => {}),
getc: () => {
if (buffer.length === 0) {
@ -50,20 +65,20 @@ class WAForth {
return buffer.pop();
},
debug: (d) => {
debug: (d: number) => {
console.log("DEBUG: ", d, String.fromCharCode(d));
},
key: () => {
let c;
let c: string | null = null;
while (c == null || c == "") {
c = window.prompt("Enter character");
}
return c.charCodeAt(0);
},
accept: (p, n) => {
const input = (window.prompt("Enter text") || "").substr(0, n);
accept: (p: number, n: number) => {
const input = (window.prompt("Enter text") || "").substring(0, n);
const target = new Uint8Array(memory.buffer, p, input.length);
for (let i = 0; i < input.length; ++i) {
target[i] = input.charCodeAt(i);
@ -76,9 +91,9 @@ class WAForth {
// Loader
////////////////////////////////////////
load: (offset, length, index) => {
load: (offset: number, length: number, index: number) => {
let data = new Uint8Array(
this.core.exports.memory.buffer,
(this.core!.exports.memory as WebAssembly.Memory).buffer,
offset,
length
);
@ -105,30 +120,85 @@ class WAForth {
throw e;
}
},
////////////////////////////////////////
// Generic call
////////////////////////////////////////
call: () => {
const len = pop();
const addr = pop();
const fname = loadString(memory, addr, len);
const fn = this.fns[fname];
if (!fn) {
console.error("Unbound SCALL: %s", fname);
} else {
fn(this.stack!);
}
},
},
}).then((instance) => {
this.core = instance.instance;
table = this.core.exports.table;
memory = this.core.exports.memory;
});
this.core = instance.instance;
const pop = (): number => {
return (this.core!.exports.pop as any)();
};
const popString = (): string => {
const len = pop();
const addr = pop();
return loadString(memory, addr, len);
};
const push = (n: number): void => {
(this.core!.exports.push as any)(n);
};
this.stack = {
pop,
popString,
push,
};
table = this.core.exports.table as WebAssembly.Table;
memory = this.core.exports.memory as WebAssembly.Memory;
}
read(s) {
read(s: string) {
const data = new TextEncoder().encode(s);
for (let i = data.length - 1; i >= 0; --i) {
this.buffer.push(data[i]);
this.buffer!.push(data[i]);
}
}
run(s) {
/**
* Read data `s` into the input buffer, and interpret.
*/
interpret(s: string) {
this.read(s);
try {
return this.core.exports.interpret();
return (this.core!.exports.interpret as any)();
} catch (e) {
// Exceptions thrown from the core means QUIT or ABORT is called, or an error
// has occurred. Assume what has been done has been done, and ignore here.
}
}
/**
* Bind `name` to SCALL in Forth.
*
* When an SCALL is done with `name` on the top of the stack, `fn` will be called (with the name popped off the stack).
* Use `stack` to pop parameters off the stack, and push results back on the stack.
*/
bind(name: string, fn: (stack: Stack) => void) {
this.fns[name] = fn;
}
}
interface Stack {
push(n: number): void;
pop(): number;
popString(): string;
}
export default WAForth;

View file

@ -19,8 +19,8 @@ forth.onEmit = (c) => {
outputBuffer.push(String.fromCharCode(c));
};
setup.push(
forth.start().then(() => {
forth.run(sieve);
forth.load().then(() => {
forth.interpret(sieve);
})
);
@ -44,7 +44,7 @@ const benchmarks = [
name: "sieve",
fn: () => {
outputBuffer = [];
forth.run(`${LIMIT} sieve`);
forth.interpret(`${LIMIT} sieve`);
return outputBuffer.join("");
},
},

View file

View file

@ -0,0 +1,40 @@
import "./prompt.css";
import WAForth from "../../WAForth";
(async () => {
// Create the UI
document.body.innerHTML = `<button>Go!</button><pre></pre>`;
const btn = document.querySelector("button");
const log = document.querySelector("pre");
// Initialize WAForth
const forth = new WAForth();
forth.onEmit = (c) =>
log.appendChild(document.createTextNode(String.fromCharCode(c)));
await forth.load();
// Bind "prompt" call to a function that pops up a JavaScript prompt, and pushes the entered number back on the stack
forth.bind("prompt", (stack) => {
const message = stack.popString();
const result = window.prompt(message);
stack.push(parseInt(result));
});
// Load Forth code to bind the "prompt" call to a word, and call the word
forth.interpret(`
( Call "prompt" with the given string )
: PROMPT ( c-addr u -- n )
S" prompt" SCALL
;
( Prompt the user for a number, and write it to output )
: ASK-NUMBER ( -- )
S" Please enter a number" PROMPT
." The number was" SPACE .
;
`);
btn.addEventListener("click", () => {
forth.interpret("ASK-NUMBER");
});
})();

View file

@ -51,7 +51,7 @@ function startConsole() {
// console.log("keydown", ev);
if (ev.key === "Enter") {
output(" ", true);
forth.run(inputbuffer.join(""));
forth.interpret(inputbuffer.join(""));
inputbuffer = [];
} else if (ev.key === "Backspace") {
if (inputbuffer.length > 0) {
@ -80,7 +80,7 @@ function startConsole() {
for (const command of commands) {
output(command, true);
output(" ", true);
forth.run(inputbuffer.join("") + command);
forth.interpret(inputbuffer.join("") + command);
inputbuffer = [];
}
if (newInputBuffer.length > 0) {
@ -102,14 +102,15 @@ forth.onEmit = (c) => {
clearConsole();
output("Loading core ... ", false);
forth.start().then(
forth.load().then(
() => {
output("ok\nLoading sieve ... ", false);
forth.run(sieve);
forth.interpret(sieve);
clearConsole();
startConsole();
},
() => {
(e) => {
console.error(e);
const errorEl = document.createElement("span");
errorEl.className = "error";
errorEl.innerText = "error";

View file

@ -14,7 +14,7 @@ function loadTests() {
output = output + String.fromCharCode(c);
// console.log(output);
};
const x = forth.start().then(
const x = forth.load().then(
() => {
core = forth.core.exports;
@ -82,7 +82,7 @@ function loadTests() {
function run(ss, expectErrors = false) {
ss.split("\n").forEach((s) => {
// console.log("Running: ", s);
const r = forth.run(s);
const r = forth.interpret(s);
if (expectErrors) {
expect(r).to.be.undefined;
output = output.substr(0, output.length);
@ -1087,6 +1087,7 @@ function loadTests() {
it("should fetch", () => {
const ptr = here();
memory[ptr / 4] = 123456;
console.log("SET", ptr.toString(), memory[ptr / 8]);
run(ptr.toString());
run("@ 5");
expect(stackValues()[0]).to.eql(123456);
@ -1480,19 +1481,19 @@ function loadTests() {
it("should work", () => {
run(': FOO 0 0 S" 123AB" >NUMBER ;');
run("FOO");
expect(stackValues()).to.eql([123, 0, 137427, 2]);
expect(stackValues()).to.eql([123, 0, 137443, 2]);
});
it("should work with init", () => {
run(': FOO 1 0 S" 1" >NUMBER ;');
run("FOO");
expect(stackValues()).to.eql([11, 0, 137425, 0]);
expect(stackValues()).to.eql([11, 0, 137441, 0]);
});
it("should not parse sign", () => {
run(': FOO 0 0 S" -" >NUMBER ;');
run("FOO");
expect(stackValues()).to.eql([0, 0, 137424, 1]);
expect(stackValues()).to.eql([0, 0, 137440, 1]);
});
});

4
src/web/types/waforth.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.wat" {
const value: Uint8Array;
export default value;
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"outDir": "dist",
"noImplicitAny": true,
"strict": true,
"incremental": true,
"typeRoots": ["./src/web/types"],
"types": ["node"]
},
"include": ["src/web"],
"exclude": ["node_modules", "dist", "build"]
}

14
tsconfig.package.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"outDir": "dist",
"noImplicitAny": true,
"strict": true,
"incremental": true,
"typeRoots": ["./src/web/types"],
"types": ["node"],
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src/web/WAForth.ts", "src/web/types"],
"exclude": ["node_modules", "dist", "build"]
}

View file

@ -31,6 +31,11 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
"@types/node@^17.0.31":
version "17.0.31"
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.31.tgz#a5bb84ecfa27eec5e1c802c6bbf8139bdb163a5d"
integrity sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==
"@ungap/promise-all-settled@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
@ -1475,6 +1480,11 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@^4.6.4:
version "4.6.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9"
integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==
unbox-primitive@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"