From dd78a615e244cf7a3d9dc1cb8d1b36a92ffe771e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 28 May 2026 12:17:06 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(stdlib/json):=20v0.3=20=E2=80=94=20RSR?= =?UTF-8?q?=20rewire=20to=20hpm-json-rsr=20Zig=20FFI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the long-deferred `parse: String -> Option` bridge to stdlib/json.affine, completing echidna#63's deferred boundary without hand-rolling a parser. The implementation routes through the `hyperpolymath/hpm-json-rsr` Zig FFI exports — 11 in total, declared verbatim as `pub extern fn hpm_json_*`. The new `to_json` walker materialises non-object subtrees (leaves + arrays) into the existing `Json` sum; objects descend lazily via `hpm_json_object_get` (the Zig FFI does not yet export key enumeration, deferred). Deno-ESM lowering (`lib/codegen_deno.ml`) maps each `hpm_json_*` to `__as_hpmJson*` JS shims, with handles as the underlying JS value from `JSON.parse` (free is a no-op; GC reclaims). Native targets will link against the hpm-json-rsr cdylib using the same extern surface. Smoke-verified end-to-end on Deno: object descent (installation.id => 12345), array materialisation (JArray), and malformed-JSON => None all pass. Estate-vocabulary note: "Zig FFI" not "C-ABI" — the wire-level C calling convention is an implementation detail of Zig's `export fn`, not something AffineScript consumers need to think about. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/codegen_deno.ml | 65 ++++++++++++++++++++++++- stdlib/json.affine | 116 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 1dd545c2..3e6c8710 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -193,6 +193,57 @@ const __as_httpHeadersFromResponse = (res) => { res.headers.forEach((value, key) => out.push([key, value])); return out; }; +// ---- hpm-json-rsr Zig FFI shims (stdlib/json.affine v0.3) ---- +// `HpmJsonValue` is opaque to AffineScript; on Deno-ESM it's just the +// underlying JS value from JSON.parse. The shims mirror the sentinel +// conventions of the Zig exports so the AffineScript-side wrappers +// (`to_json`, `parse`) behave identically across backends. +const __as_hpmJsonParse = (s) => { + try { return Some(JSON.parse(String(s))); } catch (_e) { return None; } +}; +const __as_hpmJsonFree = (_v) => 0; +const __as_hpmJsonType = (v) => { + if (v === null || v === undefined) return 0; + if (typeof v === "boolean") return 1; + if (typeof v === "number") return Number.isInteger(v) ? 2 : 3; + if (typeof v === "string") return 4; + if (Array.isArray(v)) return 5; + if (typeof v === "object") return 6; + return -1; +}; +const __as_hpmJsonBool = (v) => (typeof v === "boolean" ? (v ? 1 : 0) : -1); +const __as_hpmJsonInt = (v) => + (typeof v === "number" ? Math.trunc(v) : Number.MIN_SAFE_INTEGER); +const __as_hpmJsonFloat = (v) => (typeof v === "number" ? v : NaN); +const __as_hpmJsonString = (v) => (typeof v === "string" ? v : ""); +const __as_hpmJsonObjectGet = (v, k) => { + if (v === null || typeof v !== "object" || Array.isArray(v)) return None; + return Object.prototype.hasOwnProperty.call(v, String(k)) + ? Some(v[String(k)]) : None; +}; +const __as_hpmJsonArrayLen = (v) => (Array.isArray(v) ? v.length : 0); +const __as_hpmJsonArrayGet = (v, i) => { + if (!Array.isArray(v)) return None; + const idx = Number(i); + return (idx >= 0 && idx < v.length) ? Some(v[idx]) : None; +}; +const __as_hpmJsonEscapeString = (s) => { + let out = ""; + const src = String(s); + for (let i = 0; i < src.length; i++) { + const c = src.charCodeAt(i); + if (c === 0x22) out += "\\\""; + else if (c === 0x5c) out += "\\\\"; + else if (c === 0x0a) out += "\\n"; + else if (c === 0x0d) out += "\\r"; + else if (c === 0x09) out += "\\t"; + else if (c === 0x08) out += "\\b"; + else if (c === 0x0c) out += "\\f"; + else if (c < 0x20) out += "\\u00" + c.toString(16).padStart(2, "0"); + else out += src[i]; + } + return out; +}; const __as_httpFetch = async (url, method, headers, bodyOpt) => { const init = { method, headers: __as_httpHeadersToObject(headers) }; if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value; @@ -299,7 +350,19 @@ let () = (see {!fd_is_async}). *) b "http_request" (fun a -> Printf.sprintf "(await __as_httpFetch(%s, %s, %s, %s))" - (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)) + (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)); + (* ---- hpm-json-rsr Zig FFI surface (stdlib/json.affine v0.3) ---- *) + b "hpm_json_parse" (fun a -> Printf.sprintf "__as_hpmJsonParse(%s)" (arg 0 a)); + b "hpm_json_free" (fun a -> Printf.sprintf "__as_hpmJsonFree(%s)" (arg 0 a)); + b "hpm_json_type" (fun a -> Printf.sprintf "__as_hpmJsonType(%s)" (arg 0 a)); + b "hpm_json_bool" (fun a -> Printf.sprintf "__as_hpmJsonBool(%s)" (arg 0 a)); + b "hpm_json_int" (fun a -> Printf.sprintf "__as_hpmJsonInt(%s)" (arg 0 a)); + b "hpm_json_float" (fun a -> Printf.sprintf "__as_hpmJsonFloat(%s)" (arg 0 a)); + b "hpm_json_string" (fun a -> Printf.sprintf "__as_hpmJsonString(%s)" (arg 0 a)); + b "hpm_json_object_get" (fun a -> Printf.sprintf "__as_hpmJsonObjectGet(%s, %s)" (arg 0 a) (arg 1 a)); + b "hpm_json_array_len" (fun a -> Printf.sprintf "__as_hpmJsonArrayLen(%s)" (arg 0 a)); + b "hpm_json_array_get" (fun a -> Printf.sprintf "__as_hpmJsonArrayGet(%s, %s)" (arg 0 a) (arg 1 a)); + b "hpm_json_escape_string" (fun a -> Printf.sprintf "__as_hpmJsonEscapeString(%s)" (arg 0 a)) (* ============================================================================ Identifier sanitisation (JS reserved words -> trailing underscore) diff --git a/stdlib/json.affine b/stdlib/json.affine index a0fb101b..640f54fa 100644 --- a/stdlib/json.affine +++ b/stdlib/json.affine @@ -16,11 +16,17 @@ // object feeds `dict::get` directly. // // Scope (echidna#63 "What is needed"): the `Json` type, the decode_* -// and encode_* combinators, and `stringify`. The String->Json *parse* -// bridge is deliberately out of scope here — it belongs at the -// echidna#61 `Http` boundary (`Response.json : Async[Json]`), where the -// host fetch result crosses in; tracked there, not duplicated as a -// hand-rolled parser in stdlib. +// and encode_* combinators, and `stringify`. +// +// v0.3 (this revision) — adds the `parse` bridge as a thin wrapper +// over the `hyperpolymath/hpm-json-rsr` Zig FFI surface (11 exports). +// The wrapper does NOT hand-roll a JSON parser: `hpm_json_parse` +// owns parsing + arena allocation, and AffineScript-side functions +// tree-walk the resulting opaque `HpmJsonValue` handle into the AS +// `Json` sum type for leaves + arrays. Object-key enumeration is +// not yet exposed by the Zig FFI — see `to_json` for the gap, and +// prefer the lazy `hpm_json_object_get` + leaf-extract pattern for +// object payloads (e.g. GitHub webhook JSON). module json; @@ -203,6 +209,106 @@ fn escape_string(s: String) -> String { out ++ "\"" } +// ============================================================================ +// RSR rewire (v0.3) — hpm-json-rsr Zig FFI bindings +// +// The 11 `hpm_json_*` externs faithfully mirror the Zig exports at +// `hyperpolymath/hpm-json-rsr/ffi/zig/src/main.zig`. They lower on the +// Deno-ESM backend (lib/codegen_deno.ml) to `JSON.parse` + JS-native +// walks (a handle is just the underlying JS value); on native targets +// they map to FFI into the hpm-json-rsr cdylib. +// +// HpmJsonValue is an opaque, host-managed handle. Pair every Some(h) +// from `parse` / `hpm_json_object_get` / `hpm_json_array_get` with a +// matching `hpm_json_free(h)` to release the arena (no-op on JS, real +// free on native). +// +// Sentinel conventions (faithful to the Zig surface): +// hpm_json_type -> 0=null 1=bool 2=int 3=float 4=string 5=array 6=object; -1 on null val +// hpm_json_bool -> 0/1; -1 on type mismatch +// hpm_json_int -> INT64_MIN on type mismatch +// hpm_json_float -> NaN on type mismatch +// +// Object-key enumeration is NOT yet a Zig export; consequently `to_json` +// returns None for HpmJsonValue roots whose type tag is 6 (object). For +// object payloads, descend lazily via `hpm_json_object_get`. +// ============================================================================ + +pub extern type HpmJsonValue; + +pub extern fn hpm_json_parse(src: String) -> Option; +pub extern fn hpm_json_free(val: HpmJsonValue) -> Int; +pub extern fn hpm_json_type(val: HpmJsonValue) -> Int; +pub extern fn hpm_json_bool(val: HpmJsonValue) -> Int; +pub extern fn hpm_json_int(val: HpmJsonValue) -> Int; +pub extern fn hpm_json_float(val: HpmJsonValue) -> Float; +pub extern fn hpm_json_string(val: HpmJsonValue) -> String; +pub extern fn hpm_json_object_get(val: HpmJsonValue, key: String) -> Option; +pub extern fn hpm_json_array_len(val: HpmJsonValue) -> Int; +pub extern fn hpm_json_array_get(val: HpmJsonValue, idx: Int) -> Option; +pub extern fn hpm_json_escape_string(src: String) -> String; + +/// Parse `src` into an owning RSR handle, or `None` on malformed JSON. +/// Caller MUST pair every returned `Some(h)` with `hpm_json_free(h)` +/// to release the underlying parsed arena. (No-op on Deno-ESM, real +/// arena-free on native.) +pub fn parse(src: String) -> Option { + hpm_json_parse(src) +} + +/// Tree-walk a non-object `HpmJsonValue` into the AS `Json` sum. +/// +/// Returns `None` if the value's root is an object (tag 6) — see the +/// module preamble: object-key enumeration is not yet exported by the +/// Zig FFI. Leaves + arrays of leaves/arrays materialise fully. +/// +/// Recursively `hpm_json_free`s every child handle the walk allocates; +/// the root handle is the caller's responsibility (matches the parse +/// ownership contract). +pub fn to_json(val: HpmJsonValue) -> Option { + let t = hpm_json_type(val); + if t == 0 { + Some(JNull) + } else if t == 1 { + Some(JBool(hpm_json_bool(val) == 1)) + } else if t == 2 { + Some(JInt(hpm_json_int(val))) + } else if t == 3 { + Some(JFloat(hpm_json_float(val))) + } else if t == 4 { + Some(JString(hpm_json_string(val))) + } else if t == 5 { + let n = hpm_json_array_len(val); + let mut acc = []; + let mut i = 0; + while i < n { + match hpm_json_array_get(val, i) { + Some(child) => { + match to_json(child) { + Some(j) => { + acc = acc ++ [j]; + hpm_json_free(child); + }, + None => { + hpm_json_free(child); + return None; + } + } + }, + None => { + return None; + } + }; + i = i + 1; + } + Some(JArray(acc)) + } else { + // tag 6 (object) — no key-enumeration in the Zig FFI yet; + // tag -1 (null val) — defensive: shouldn't reach here. + None + } +} + /// Serialise a `Json` value to a compact JSON string. pub fn stringify(j: Json) -> String { match j { From 7cb91beff5532ad1cba7e90441daeef510fb7add Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 28 May 2026 13:18:56 +0100 Subject: [PATCH 2/2] docs(changelog): record json v0.3 RSR rewire (#421) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d30fe3..0e8dfdac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(stdlib/json): v0.3 — RSR rewire to `hpm-json-rsr` Zig FFI (11 externs + opaque `HpmJsonValue` + `parse` / `to_json`), Deno-ESM lowering via `__as_hpmJson*` shims (#421) - feat(parser): trailing-comma in fn params and expr lists (Refs gitbot-fleet#148) (#370) - feat(lexer): underscore-prefix idents `_key`/`_unused` (Refs gitbot-fleet#148) (#373) - feat(parser): record-update spread at start `#{ ..base, f: v }` (Refs gitbot-fleet#148) (#376)