From 7502a5c3362685cfb237d17f57c92d96cac7e700 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 12 Jun 2026 11:41:42 -0500 Subject: [PATCH] feat: add JS readers for structured values --- crates/core/src/adapter.js | 240 ++++++++++++++++++++ docs/internal/tasks/15_js_host_abi.md | 12 +- docs/website/.vitepress/theme/custom.css | 56 +---- docs/website/development/js_abi_contract.md | 106 +++++++-- 4 files changed, 349 insertions(+), 65 deletions(-) diff --git a/crates/core/src/adapter.js b/crates/core/src/adapter.js index 6388185..5f03b5b 100644 --- a/crates/core/src/adapter.js +++ b/crates/core/src/adapter.js @@ -144,6 +144,154 @@ export function readString(ptr) { return decoder.decode(bytes); } +/** + * Read a borrowed managed pointer using a structured JS ABI shape. + * + * Shapes may be scalar names or objects such as `{ kind: "List", item }`, + * `{ kind: "Tuple", items }`, `{ kind: "Record", fields }`, + * `{ kind: "Result", ok, error }`, and `{ kind: "Option", some }`. + * + * @param {number} ptr Borrowed managed pointer. + * @param {string|object} shape ABI reader shape. + * @returns {unknown} Decoded JavaScript value. + */ +export function readValue(ptr, shape) { + return fromWasmValue(shape, ptr); +} + +/** + * Read a tuple pointer as a JavaScript array. + * + * @param {number} ptr Borrowed tuple pointer. + * @param {Array} items Field shapes in tuple order. + * @returns {unknown[]} Tuple fields. + */ +export function readTuple(ptr, items) { + ensureValueTag(ptr, 3, "tuple"); + const arity = instance.exports.__regulus_value_arity(ptr); + if (arity !== items.length) { + throw new Error(`Regulus tuple arity ${arity} does not match shape arity ${items.length}`); + } + return items.map((item, index) => readField(ptr, index, item)); +} + +/** + * Read a record pointer as a JavaScript object keyed by field name. + * + * @param {number} ptr Borrowed record pointer. + * @param {Array<{ name: string, type: string|object }>} fields Field shapes in + * declaration order. + * @returns {Record} Record object. + */ +export function readRecord(ptr, fields) { + ensureValueTag(ptr, 4, "record"); + const arity = instance.exports.__regulus_value_arity(ptr); + if (arity !== fields.length) { + throw new Error(`Regulus record arity ${arity} does not match shape arity ${fields.length}`); + } + const out = {}; + fields.forEach((field, index) => { + out[field.name] = readField(ptr, index, field.type); + }); + return out; +} + +/** + * Read a custom value pointer as `{ tag, fields }`. + * + * If `variants` maps constructor names to field shapes, the returned `tag` is + * the constructor name. Otherwise `tag` is the numeric constructor tag. + * + * @param {number} ptr Borrowed custom value pointer. + * @param {Record }}=} variants + * Optional variant shape map. + * @returns {{ tag: string|number, fields: unknown[]|Record }} + * Decoded custom value. + */ +export function readCustom(ptr, variants = {}) { + ensureValueTag(ptr, 5, "custom value"); + const constructorTag = instance.exports.__regulus_value_constructor(ptr) >>> 0; + const entry = Object.entries(variants).find(([name]) => constructorHash(name) === constructorTag); + const name = entry?.[0]; + const variant = entry?.[1] ?? {}; + const fieldShapes = variant.fields ?? []; + const arity = instance.exports.__regulus_value_arity(ptr); + if (fieldShapes.length && arity !== fieldShapes.length) { + throw new Error(`Regulus custom arity ${arity} does not match shape arity ${fieldShapes.length}`); + } + const shapes = fieldShapes.length ? fieldShapes : Array.from({ length: arity }, () => "Int"); + const hasNamedFields = shapes.some((field) => typeof field === "object" && "name" in field); + const fields = hasNamedFields ? {} : []; + shapes.forEach((field, index) => { + const fieldShape = field && typeof field === "object" && "type" in field ? field.type : field; + const value = readField(ptr, index, fieldShape); + if (hasNamedFields) { + fields[field.name ?? String(index)] = value; + } else { + fields.push(value); + } + }); + return { tag: name ?? constructorTag, fields }; +} + +/** + * Read a Gleam list pointer as a JavaScript array. + * + * @param {number} ptr Borrowed list pointer, or `0` for the empty list. + * @param {string|object} item Item shape. Use `"String"` for lists of strings. + * @returns {unknown[]} List items. + */ +export function readList(ptr, item) { + const out = []; + let cursor = ptr; + while (cursor !== 0) { + ensureValueTag(cursor, 2, "list cons cell"); + out.push(readField(cursor, 0, item)); + cursor = pointerFromSlot(instance.exports.__regulus_value_field(cursor, 1)); + } + return out; +} + +/** + * Read a Gleam `Result(a, e)` pointer. + * + * @param {number} ptr Borrowed custom value pointer. + * @param {string|object} ok Shape for the `Ok` payload. + * @param {string|object} error Shape for the `Error` payload. + * @returns {{ tag: "Ok", value: unknown }|{ tag: "Error", value: unknown }} + * Decoded result. + */ +export function readResult(ptr, ok, error) { + ensureValueTag(ptr, 5, "Result"); + const tag = instance.exports.__regulus_value_constructor(ptr) >>> 0; + if (tag === constructorHash("Ok")) { + return { tag: "Ok", value: readField(ptr, 0, ok) }; + } + if (tag === constructorHash("Error")) { + return { tag: "Error", value: readField(ptr, 0, error) }; + } + throw new Error(`Regulus custom value is not a Result constructor: ${tag}`); +} + +/** + * Read a Gleam `Option(a)` pointer. + * + * @param {number} ptr Borrowed custom value pointer. + * @param {string|object} some Shape for the `Some` payload. + * @returns {{ tag: "Some", value: unknown }|{ tag: "None" }} Decoded option. + */ +export function readOption(ptr, some) { + ensureValueTag(ptr, 5, "Option"); + const tag = instance.exports.__regulus_value_constructor(ptr) >>> 0; + if (tag === constructorHash("Some")) { + return { tag: "Some", value: readField(ptr, 0, some) }; + } + if (tag === constructorHash("None")) { + return { tag: "None" }; + } + throw new Error(`Regulus custom value is not an Option constructor: ${tag}`); +} + /** * Convert host imports into raw Wasm functions using compiler metadata. * @@ -255,6 +403,24 @@ function toWasmValue(type, value) { * @returns {unknown} JavaScript value. */ function fromWasmValue(type, value) { + if (type && typeof type === "object") { + switch (type.kind) { + case "Tuple": + return readTuple(value, type.items ?? []); + case "Record": + return readRecord(value, type.fields ?? []); + case "Custom": + return readCustom(value, type.variants ?? {}); + case "List": + return readList(value, type.item); + case "Result": + return readResult(value, type.ok, type.error); + case "Option": + return readOption(value, type.some); + default: + throw new Error(`Unsupported Regulus JS ABI shape "${type.kind}"`); + } + } switch (type) { case "Int": return value; @@ -271,6 +437,80 @@ function fromWasmValue(type, value) { } } +/** + * Read one raw object field and convert it through a shape. + * + * @param {number} ptr Borrowed managed pointer. + * @param {number} index Field index. + * @param {string|object} shape Field shape. + * @returns {unknown} Converted field value. + */ +function readField(ptr, index, shape) { + const slot = instance.exports.__regulus_value_field(ptr, index); + if (shape === "String" || (shape && typeof shape === "object")) { + return fromWasmValue(shape, pointerFromSlot(slot)); + } + if (shape === "Float") { + return floatFromSlot(slot); + } + return fromWasmValue(shape, slot); +} + +/** + * Assert that a pointer has the expected runtime object tag. + * + * @param {number} ptr Borrowed managed pointer. + * @param {number} expected Expected runtime object tag. + * @param {string} name Human-readable shape name. + * @returns {void} + */ +function ensureValueTag(ptr, expected, name) { + if (ptr === 0) { + throw new Error(`Regulus ${name} reader received null pointer`); + } + const actual = instance.exports.__regulus_value_tag(ptr); + if (actual !== expected) { + throw new Error(`Regulus ${name} reader expected tag ${expected}, got ${actual}`); + } +} + +/** + * Convert an i64 field slot containing a pointer in the low bits to a number. + * + * @param {bigint} slot Raw field slot. + * @returns {number} Managed pointer. + */ +function pointerFromSlot(slot) { + return Number(slot & 0xffff_ffffn); +} + +/** + * Reinterpret an i64 field slot as an IEEE-754 Float. + * + * @param {bigint} slot Raw field slot. + * @returns {number} JavaScript number. + */ +function floatFromSlot(slot) { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setBigUint64(0, BigInt.asUintN(64, slot), true); + return view.getFloat64(0, true); +} + +/** + * Compute the stable constructor tag used by the Wasm backend. + * + * @param {string} name Constructor name. + * @returns {number} Unsigned 32-bit constructor tag. + */ +function constructorHash(name) { + let hash = 0x811c_9dc5; + for (const char of new TextEncoder().encode(name)) { + hash = Math.imul(hash, 0x0100_0193) ^ char; + } + return hash >>> 0; +} + /** * Load Wasm bytes from a buffer, typed array, URL, or path-like string. * diff --git a/docs/internal/tasks/15_js_host_abi.md b/docs/internal/tasks/15_js_host_abi.md index 496614e..2f74056 100644 --- a/docs/internal/tasks/15_js_host_abi.md +++ b/docs/internal/tasks/15_js_host_abi.md @@ -44,11 +44,11 @@ The public ABI contract lives in - [x] Export stable helpers for reading managed value tags, arity, and fields. - [x] Define the JS reader contract for tuples, records, and custom types. -- [ ] Define the JS reader contract for lists and lists of strings. -- [ ] Define the JS reader contract for `Result` and `Option` values. -- [ ] Implement JS adapter readers for tuples, records, and custom types. -- [ ] Implement JS adapter readers for lists and lists of strings. -- [ ] Implement JS adapter readers for `Result` and `Option` values. +- [x] Define the JS reader contract for lists and lists of strings. +- [x] Define the JS reader contract for `Result` and `Option` values. +- [x] Implement JS adapter readers for tuples, records, and custom types. +- [x] Implement JS adapter readers for lists and lists of strings. +- [x] Implement JS adapter readers for `Result` and `Option` values. - [ ] Add export metadata for structured values consumed by JS readers. - [ ] Allow supported structured return shapes for JS exports once typed readers are available. @@ -58,8 +58,8 @@ The public ABI contract lives in ### Opaque JS handles - [ ] Define the runtime representation for opaque host handles. -- [ ] Define ownership and lifetime rules for JS handles passed to Gleam. - [ ] Implement the runtime representation for opaque host handles. +- [ ] Define ownership and lifetime rules for JS handles passed to Gleam. - [ ] Implement JS handle table ownership and release behavior. - [ ] Add ABI validation for externals that accept or return opaque handles. - [ ] Add JS adapter conversion for opaque handle imports and exports. diff --git a/docs/website/.vitepress/theme/custom.css b/docs/website/.vitepress/theme/custom.css index 64586cb..600c880 100644 --- a/docs/website/.vitepress/theme/custom.css +++ b/docs/website/.vitepress/theme/custom.css @@ -1,31 +1,9 @@ :root { - --vp-font-family-base: - "IBM Plex Sans Variable", ui-sans-serif, system-ui, -apple-system, - BlinkMacSystemFont, "Segoe UI", sans-serif; - --vp-font-family-mono: "IBM Plex Mono", "SFMono-Regular", monospace; - --regulus-font-heading: - "Lexend Variable", "IBM Plex Sans Variable", ui-sans-serif, system-ui, - sans-serif; - --vp-c-brand-1: #8aadf4; - --vp-c-brand-2: #7dc4e4; - --vp-c-brand-3: #91d7e3; - --vp-home-hero-name-color: #8aadf4; - --vp-layout-max-width: 1500px; -} - -.VPHome { - background: - linear-gradient(180deg, rgba(36, 39, 58, 0.18), transparent 36rem), - radial-gradient(circle at top left, rgba(138, 173, 244, 0.18), transparent 32rem); -} - -.VPHomeHero .text { - max-width: 48rem; - letter-spacing: 0; -} - -.VPHomeHero .tagline { - max-width: 43rem; + --system-sans-fonts: + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --vp-font-family-base: "IBM Plex Sans Variable", var(--system-sans-fonts); + --vp-font-family-mono: "IBM Plex Mono", "SFMono-Regular", monospace; + --regulus-font-heading: "Lexend Variable", var(--vp-font-family-base); } .VPHomeHero .name, @@ -34,32 +12,20 @@ .vp-doc h2, .vp-doc h3, .vp-doc h4 { - font-family: var(--regulus-font-heading); - letter-spacing: 0; + font-family: var(--regulus-font-heading); + letter-spacing: 0; } .VPHomeHero .name { - font-weight: 650; + font-weight: 650; } .vp-doc h1, .vp-doc h2 { - font-weight: 620; -} - -.VPFeature { - border-radius: 8px; -} - -.VPDoc { - letter-spacing: 0; -} - -.vp-doc h2 { - border-top: 0; + font-weight: 600; } .vp-doc table { - display: table; - width: 100%; + display: table; + width: 100%; } diff --git a/docs/website/development/js_abi_contract.md b/docs/website/development/js_abi_contract.md index 2e2e131..379432b 100644 --- a/docs/website/development/js_abi_contract.md +++ b/docs/website/development/js_abi_contract.md @@ -30,13 +30,13 @@ reset point. | tuples | managed `i32` pointer | Read through value helpers. | | records | managed `i32` pointer | Read through value helpers. | | custom types | managed `i32` pointer | Read through value helpers. | -| lists | managed `i32` pointer | Planned | +| lists | managed `i32` pointer | Read as JavaScript arrays. | | opaque externals | managed or host handle pointer | Deferred to the opaque-handle ABI. | | functions | none | Unsupported across the JS host ABI. | -The first stable ergonomic conversion layer is scalar and string focused. -Managed structured values are represented as borrowed pointers until the reader -helper contract is complete. +The first stable call conversion layer is scalar and string focused. Managed +structured values can be read from borrowed pointers with explicit reader +shapes. Writing structured JavaScript values into Gleam is deferred. ## Runtime helpers @@ -87,9 +87,82 @@ after the object header. For custom, error, and panic payloads, field index `0` starts after the constructor or reason tag. Scalar `Int` fields are the signed `i64` value. `Bool` fields use `0n` and -`1n`. Managed fields store a borrowed pointer in the low 32 bits. Glue should +`1n`. `Float` fields are the raw IEEE-754 bits reinterpreted from the `i64` +slot. Managed fields store a borrowed pointer in the low 32 bits. Glue should convert those pointer fields before recursively reading them. +## Structured readers + +Generated or packaged JS glue exposes reader helpers over borrowed managed +pointers. All readers require an explicit shape because raw runtime objects do +not store source field names or type parameters. + +Tuple readers return arrays. Tuple shape items are in tuple field order: + +```js +readTuple(ptr, ["Int", "String"]) +// => [1n, "text"] +``` + +Record readers return plain objects. Record field shapes are in constructor +declaration order: + +```js +readRecord(ptr, [ + { name: "status", type: "Int" }, + { name: "body", type: "String" }, +]) +// => { status: 200n, body: "ok" } +``` + +Custom-type readers return `{ tag, fields }`. When the shape includes variant +names, `tag` is the constructor name. Otherwise `tag` is the numeric constructor +tag. Positional variant fields return an array. Named variant fields return an +object. + +```js +readCustom(ptr, { + Created: { fields: [{ name: "id", type: "String" }] }, + Deleted: { fields: ["String"] }, +}) +// => { tag: "Created", fields: { id: "abc" } } +``` + +List readers follow tag-2 cons cells until the null pointer `0`, which is the +empty list. They return JavaScript arrays and recursively read each head with +the item shape. A list of strings uses the normal string shape: + +```js +readList(ptr, "String") +// => ["a", "b", "c"] +``` + +`Result(a, e)` values are tag-5 custom objects with `Ok` or `Error` +constructor tags. JS readers return tagged objects: + +```js +readResult(ptr, "String", "Int") +// => { tag: "Ok", value: "done" } +// => { tag: "Error", value: 404n } +``` + +`Option(a)` values are tag-5 custom objects with `Some` or `None` constructor +tags. JS readers return tagged objects: + +```js +readOption(ptr, "String") +// => { tag: "Some", value: "found" } +// => { tag: "None" } +``` + +The generic `readValue(ptr, shape)` helper accepts scalar names and structured +shape objects: + +```js +readValue(ptr, { kind: "List", item: "String" }) +readValue(ptr, { kind: "Result", ok: "String", error: "Int" }) +``` + ## Import modules The shared JS import namespace is `regulus/js`. Profile-specific modules are @@ -128,10 +201,11 @@ Supported imported return shapes are: - `String` - `Nil` -Structured managed values may lower as borrowed pointers internally, but stable -JS conversion is not part of this milestone. Opaque types, generic values, and -function values are unsupported across JS host imports until their ABI contracts -are defined. +Structured managed values may lower as borrowed pointers internally. Stable JS +conversion for imported structured parameters and returns is deferred until +structured writers and generated import metadata are complete. Opaque types, +generic values, and function values are unsupported across JS host imports until +their ABI contracts are defined. ## Exported functions @@ -145,7 +219,7 @@ Supported exported parameter shapes are: - `Bool` - `String` -Supported exported return shapes are: +Supported exported return shapes for checked call wrappers are: - `Int` - `Float` @@ -153,8 +227,12 @@ Supported exported return shapes are: - `String` - `Nil` -Glue should expose checked wrappers for these shapes so application code does -not perform pointer arithmetic. +Structured exported values may be read by calling a raw export and then passing +the returned pointer to the reader helpers. Checked wrappers will accept +structured return metadata once structured export metadata is generated. + +Glue should expose checked wrappers for stable call shapes so application code +does not perform pointer arithmetic. ## Diagnostics @@ -177,8 +255,8 @@ type, or public function annotation that caused the unsupported shape. This contract intentionally does not define: -- structured managed value readers -- `Result` and `Option` conversion +- writing structured JavaScript values into Gleam +- generated metadata for structured exported values - opaque JS handle representation and lifetime - browser API semantics - Node.js loading semantics -- 2.51.2