Skip to content

Commit a29faa8

Browse files
feat(stdlib/json): v0.3 — RSR rewire to hpm-json-rsr Zig FFI (#421)
## Summary Completes the long-deferred `parse: String -> Option<HpmJsonValue>` bridge in `stdlib/json.affine` by routing through the **`hyperpolymath/hpm-json-rsr` Zig FFI** (11 exports) instead of hand-rolling a parser. Closes the echidna#63 "parse bridge" gap and unblocks the OikosBot AffineScript port's webhook payload extraction (oikos#41 + `bot-integration-affine/`). ### What lands - **`stdlib/json.affine` v0.3** — opaque `pub extern type HpmJsonValue` + 11 `hpm_json_*` externs mirroring the Zig exports verbatim, plus: - `pub fn parse(src: String) -> Option<HpmJsonValue>` (handle-returning; caller owns + must free) - `pub fn to_json(val: HpmJsonValue) -> Option<Json>` (tree-walk; materialises leaves + arrays; objects descend lazily via `hpm_json_object_get`) - **`lib/codegen_deno.ml`** — Deno-ESM lowering: 11 entries in `deno_builtins` mapping `hpm_json_*` → `__as_hpmJson*` JS shims (handle == JS value; `JSON.parse` powers `hpm_json_parse`; `hpm_json_free` is a no-op since GC reclaims). ### Object-key enumeration gap The Zig FFI does not (yet) export an `hpm_json_object_keys` function, so `to_json` cannot materialise objects into a full `JObject(...)` sum. This is honest: object payloads (e.g. GitHub webhooks) descend lazily by known key via `hpm_json_object_get` + leaf-extract — exactly how the OikosBot port needs to use it. A follow-up PR to `hpm-json-rsr` will add `hpm_json_object_keys` to close the round-trip. ### Verification - ✅ `affinescript check stdlib/json.affine` — type checks - ✅ `dune build` — clean - ✅ `dune runtest` — same 2 pre-existing failures as `main`, no regressions - ✅ Runtime smoke (Deno-ESM): object descent (`{"installation":{"id":12345}}` → `12345`), array materialisation (`[1,2,3]` → `JArray`), malformed JSON → `None` ### Vocabulary "Zig FFI" not "C-ABI" — the wire-level C calling convention is an implementation detail of Zig's `export fn`. Estate convention is Zig=FFIs / Idris2=ABIs. ## Test plan - [ ] Type check still passes after rebase (`affinescript check stdlib/json.affine`) - [ ] No regression on the test suite delta - [ ] Deno-ESM smoke driver (in PR body / follow-up test) exercises object descent + array materialisation + malformed input 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b95852f commit a29faa8

3 files changed

Lines changed: 176 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Added
2222

23+
- 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)
2324
- feat(parser): trailing-comma in fn params and expr lists (Refs gitbot-fleet#148) (#370)
2425
- feat(lexer): underscore-prefix idents `_key`/`_unused` (Refs gitbot-fleet#148) (#373)
2526
- feat(parser): record-update spread at start `#{ ..base, f: v }` (Refs gitbot-fleet#148) (#376)

lib/codegen_deno.ml

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,57 @@ const __as_httpHeadersFromResponse = (res) => {
209209
res.headers.forEach((value, key) => out.push([key, value]));
210210
return out;
211211
};
212+
// ---- hpm-json-rsr Zig FFI shims (stdlib/json.affine v0.3) ----
213+
// `HpmJsonValue` is opaque to AffineScript; on Deno-ESM it's just the
214+
// underlying JS value from JSON.parse. The shims mirror the sentinel
215+
// conventions of the Zig exports so the AffineScript-side wrappers
216+
// (`to_json`, `parse`) behave identically across backends.
217+
const __as_hpmJsonParse = (s) => {
218+
try { return Some(JSON.parse(String(s))); } catch (_e) { return None; }
219+
};
220+
const __as_hpmJsonFree = (_v) => 0;
221+
const __as_hpmJsonType = (v) => {
222+
if (v === null || v === undefined) return 0;
223+
if (typeof v === "boolean") return 1;
224+
if (typeof v === "number") return Number.isInteger(v) ? 2 : 3;
225+
if (typeof v === "string") return 4;
226+
if (Array.isArray(v)) return 5;
227+
if (typeof v === "object") return 6;
228+
return -1;
229+
};
230+
const __as_hpmJsonBool = (v) => (typeof v === "boolean" ? (v ? 1 : 0) : -1);
231+
const __as_hpmJsonInt = (v) =>
232+
(typeof v === "number" ? Math.trunc(v) : Number.MIN_SAFE_INTEGER);
233+
const __as_hpmJsonFloat = (v) => (typeof v === "number" ? v : NaN);
234+
const __as_hpmJsonString = (v) => (typeof v === "string" ? v : "");
235+
const __as_hpmJsonObjectGet = (v, k) => {
236+
if (v === null || typeof v !== "object" || Array.isArray(v)) return None;
237+
return Object.prototype.hasOwnProperty.call(v, String(k))
238+
? Some(v[String(k)]) : None;
239+
};
240+
const __as_hpmJsonArrayLen = (v) => (Array.isArray(v) ? v.length : 0);
241+
const __as_hpmJsonArrayGet = (v, i) => {
242+
if (!Array.isArray(v)) return None;
243+
const idx = Number(i);
244+
return (idx >= 0 && idx < v.length) ? Some(v[idx]) : None;
245+
};
246+
const __as_hpmJsonEscapeString = (s) => {
247+
let out = "";
248+
const src = String(s);
249+
for (let i = 0; i < src.length; i++) {
250+
const c = src.charCodeAt(i);
251+
if (c === 0x22) out += "\\\"";
252+
else if (c === 0x5c) out += "\\\\";
253+
else if (c === 0x0a) out += "\\n";
254+
else if (c === 0x0d) out += "\\r";
255+
else if (c === 0x09) out += "\\t";
256+
else if (c === 0x08) out += "\\b";
257+
else if (c === 0x0c) out += "\\f";
258+
else if (c < 0x20) out += "\\u00" + c.toString(16).padStart(2, "0");
259+
else out += src[i];
260+
}
261+
return out;
262+
};
212263
const __as_httpFetch = async (url, method, headers, bodyOpt) => {
213264
const init = { method, headers: __as_httpHeadersToObject(headers) };
214265
if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value;
@@ -320,7 +371,19 @@ let () =
320371
(see {!fd_is_async}). *)
321372
b "http_request" (fun a ->
322373
Printf.sprintf "(await __as_httpFetch(%s, %s, %s, %s))"
323-
(arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a))
374+
(arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a));
375+
(* ---- hpm-json-rsr Zig FFI surface (stdlib/json.affine v0.3) ---- *)
376+
b "hpm_json_parse" (fun a -> Printf.sprintf "__as_hpmJsonParse(%s)" (arg 0 a));
377+
b "hpm_json_free" (fun a -> Printf.sprintf "__as_hpmJsonFree(%s)" (arg 0 a));
378+
b "hpm_json_type" (fun a -> Printf.sprintf "__as_hpmJsonType(%s)" (arg 0 a));
379+
b "hpm_json_bool" (fun a -> Printf.sprintf "__as_hpmJsonBool(%s)" (arg 0 a));
380+
b "hpm_json_int" (fun a -> Printf.sprintf "__as_hpmJsonInt(%s)" (arg 0 a));
381+
b "hpm_json_float" (fun a -> Printf.sprintf "__as_hpmJsonFloat(%s)" (arg 0 a));
382+
b "hpm_json_string" (fun a -> Printf.sprintf "__as_hpmJsonString(%s)" (arg 0 a));
383+
b "hpm_json_object_get" (fun a -> Printf.sprintf "__as_hpmJsonObjectGet(%s, %s)" (arg 0 a) (arg 1 a));
384+
b "hpm_json_array_len" (fun a -> Printf.sprintf "__as_hpmJsonArrayLen(%s)" (arg 0 a));
385+
b "hpm_json_array_get" (fun a -> Printf.sprintf "__as_hpmJsonArrayGet(%s, %s)" (arg 0 a) (arg 1 a));
386+
b "hpm_json_escape_string" (fun a -> Printf.sprintf "__as_hpmJsonEscapeString(%s)" (arg 0 a))
324387

325388
(* ============================================================================
326389
Identifier sanitisation (JS reserved words -> trailing underscore)

stdlib/json.affine

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@
1616
// object feeds `dict::get` directly.
1717
//
1818
// Scope (echidna#63 "What is needed"): the `Json` type, the decode_*
19-
// and encode_* combinators, and `stringify`. The String->Json *parse*
20-
// bridge is deliberately out of scope here — it belongs at the
21-
// echidna#61 `Http` boundary (`Response.json : Async[Json]`), where the
22-
// host fetch result crosses in; tracked there, not duplicated as a
23-
// hand-rolled parser in stdlib.
19+
// and encode_* combinators, and `stringify`.
20+
//
21+
// v0.3 (this revision) — adds the `parse` bridge as a thin wrapper
22+
// over the `hyperpolymath/hpm-json-rsr` Zig FFI surface (11 exports).
23+
// The wrapper does NOT hand-roll a JSON parser: `hpm_json_parse`
24+
// owns parsing + arena allocation, and AffineScript-side functions
25+
// tree-walk the resulting opaque `HpmJsonValue` handle into the AS
26+
// `Json` sum type for leaves + arrays. Object-key enumeration is
27+
// not yet exposed by the Zig FFI — see `to_json` for the gap, and
28+
// prefer the lazy `hpm_json_object_get` + leaf-extract pattern for
29+
// object payloads (e.g. GitHub webhook JSON).
2430

2531
module json;
2632

@@ -203,6 +209,106 @@ fn escape_string(s: String) -> String {
203209
out ++ "\""
204210
}
205211

212+
// ============================================================================
213+
// RSR rewire (v0.3) — hpm-json-rsr Zig FFI bindings
214+
//
215+
// The 11 `hpm_json_*` externs faithfully mirror the Zig exports at
216+
// `hyperpolymath/hpm-json-rsr/ffi/zig/src/main.zig`. They lower on the
217+
// Deno-ESM backend (lib/codegen_deno.ml) to `JSON.parse` + JS-native
218+
// walks (a handle is just the underlying JS value); on native targets
219+
// they map to FFI into the hpm-json-rsr cdylib.
220+
//
221+
// HpmJsonValue is an opaque, host-managed handle. Pair every Some(h)
222+
// from `parse` / `hpm_json_object_get` / `hpm_json_array_get` with a
223+
// matching `hpm_json_free(h)` to release the arena (no-op on JS, real
224+
// free on native).
225+
//
226+
// Sentinel conventions (faithful to the Zig surface):
227+
// hpm_json_type -> 0=null 1=bool 2=int 3=float 4=string 5=array 6=object; -1 on null val
228+
// hpm_json_bool -> 0/1; -1 on type mismatch
229+
// hpm_json_int -> INT64_MIN on type mismatch
230+
// hpm_json_float -> NaN on type mismatch
231+
//
232+
// Object-key enumeration is NOT yet a Zig export; consequently `to_json`
233+
// returns None for HpmJsonValue roots whose type tag is 6 (object). For
234+
// object payloads, descend lazily via `hpm_json_object_get`.
235+
// ============================================================================
236+
237+
pub extern type HpmJsonValue;
238+
239+
pub extern fn hpm_json_parse(src: String) -> Option<HpmJsonValue>;
240+
pub extern fn hpm_json_free(val: HpmJsonValue) -> Int;
241+
pub extern fn hpm_json_type(val: HpmJsonValue) -> Int;
242+
pub extern fn hpm_json_bool(val: HpmJsonValue) -> Int;
243+
pub extern fn hpm_json_int(val: HpmJsonValue) -> Int;
244+
pub extern fn hpm_json_float(val: HpmJsonValue) -> Float;
245+
pub extern fn hpm_json_string(val: HpmJsonValue) -> String;
246+
pub extern fn hpm_json_object_get(val: HpmJsonValue, key: String) -> Option<HpmJsonValue>;
247+
pub extern fn hpm_json_array_len(val: HpmJsonValue) -> Int;
248+
pub extern fn hpm_json_array_get(val: HpmJsonValue, idx: Int) -> Option<HpmJsonValue>;
249+
pub extern fn hpm_json_escape_string(src: String) -> String;
250+
251+
/// Parse `src` into an owning RSR handle, or `None` on malformed JSON.
252+
/// Caller MUST pair every returned `Some(h)` with `hpm_json_free(h)`
253+
/// to release the underlying parsed arena. (No-op on Deno-ESM, real
254+
/// arena-free on native.)
255+
pub fn parse(src: String) -> Option<HpmJsonValue> {
256+
hpm_json_parse(src)
257+
}
258+
259+
/// Tree-walk a non-object `HpmJsonValue` into the AS `Json` sum.
260+
///
261+
/// Returns `None` if the value's root is an object (tag 6) — see the
262+
/// module preamble: object-key enumeration is not yet exported by the
263+
/// Zig FFI. Leaves + arrays of leaves/arrays materialise fully.
264+
///
265+
/// Recursively `hpm_json_free`s every child handle the walk allocates;
266+
/// the root handle is the caller's responsibility (matches the parse
267+
/// ownership contract).
268+
pub fn to_json(val: HpmJsonValue) -> Option<Json> {
269+
let t = hpm_json_type(val);
270+
if t == 0 {
271+
Some(JNull)
272+
} else if t == 1 {
273+
Some(JBool(hpm_json_bool(val) == 1))
274+
} else if t == 2 {
275+
Some(JInt(hpm_json_int(val)))
276+
} else if t == 3 {
277+
Some(JFloat(hpm_json_float(val)))
278+
} else if t == 4 {
279+
Some(JString(hpm_json_string(val)))
280+
} else if t == 5 {
281+
let n = hpm_json_array_len(val);
282+
let mut acc = [];
283+
let mut i = 0;
284+
while i < n {
285+
match hpm_json_array_get(val, i) {
286+
Some(child) => {
287+
match to_json(child) {
288+
Some(j) => {
289+
acc = acc ++ [j];
290+
hpm_json_free(child);
291+
},
292+
None => {
293+
hpm_json_free(child);
294+
return None;
295+
}
296+
}
297+
},
298+
None => {
299+
return None;
300+
}
301+
};
302+
i = i + 1;
303+
}
304+
Some(JArray(acc))
305+
} else {
306+
// tag 6 (object) — no key-enumeration in the Zig FFI yet;
307+
// tag -1 (null val) — defensive: shouldn't reach here.
308+
None
309+
}
310+
}
311+
206312
/// Serialise a `Json` value to a compact JSON string.
207313
pub fn stringify(j: Json) -> String {
208314
match j {

0 commit comments

Comments
 (0)