Skip to content

Commit 4ab0b5f

Browse files
committed
docs(specs): land Zig C-ABI FFI binding patterns doc (bindings #19)
Adds docs/specs/zig-ffi-patterns.adoc — the canonical, task-oriented recipe for binding a Zig C-ABI export to an AffineScript extern fn, the deliverable for bindings #19 in docs/bindings-roadmap.adoc. Scoped to avoid DOC-DEDUP overlap: SPEC §2.10 owns the grammar, STDLIB-EXTERN-AUDIT owns the inventory, codegen-environment owns the wasm codegen mechanics; this doc is the authoring recipe + per-backend host contract that ties them together. Grounded in the actual codegen: - wasm: every extern fn -> (import "env" "<name>") (lib/codegen.ml) - Deno-ESM: deno_builtins intrinsics emit a self-contained __as_* helper; declared externs lower to a same-named host symbol (mangle is identity except JS reserved words) (lib/codegen_deno.ml) Worked example uses the existing hpm-json-rsr binding in stdlib/json.affine. Bumps the #19 row in bindings-roadmap.adoc from a partial-scaffold status to usable per the doc's own 'update the row in the same PR' rule. Unblocks the RSR rewires #11 / #12 / #16.
1 parent fd7dbed commit 4ab0b5f

2 files changed

Lines changed: 252 additions & 3 deletions

File tree

docs/bindings-roadmap.adoc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,9 @@ Surface that other estate repos are actively wanting.
166166

167167
|19
168168
|*Zig FFI canonical patterns* — `extern fn` shape for calling Zig C-ABI exports from AffineScript
169-
|`◐` idiomatic patterns exist
170-
|`stdlib` + ADR-style doc
171-
|"Zig = APIs + FFIs" estate directive. Each `hpm-*-rsr` lib exposes Zig exports; AffineScript needs canonical patterns for binding them.
169+
|`●` usable
170+
|`docs/specs/zig-ffi-patterns.adoc`
171+
|"Zig = APIs + FFIs" estate directive. Each `hpm-*-rsr` lib exposes Zig exports; AffineScript needs canonical patterns for binding them. Canonical authoring recipe + per-backend host contract (wasm `env` import vs Deno-ESM same-named host symbol) landed in `docs/specs/zig-ffi-patterns.adoc` — companion to STDLIB-EXTERN-AUDIT + SPEC §2.10; unblocks the RSR rewires #11 / #12 / #16.
172172

173173
|20
174174
|*Telegram (Grammy)* — already `stdlib/Grammy.affine`

docs/specs/zig-ffi-patterns.adoc

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
= Zig C-ABI FFI Binding Patterns
4+
:toc: macro
5+
:toclevels: 3
6+
:icons: font
7+
8+
Canonical, task-oriented recipe for binding a Zig C-ABI export to an
9+
AffineScript `extern fn`. This is the "how do I wire a new native
10+
function" companion to the language grammar, the live inventory, and the
11+
codegen internals — it does not restate them.
12+
13+
This document is the deliverable for *bindings #19* (Zig FFI canonical
14+
patterns) in link:../bindings-roadmap.adoc[bindings-roadmap.adoc], and
15+
underpins the RSR-convergence rewires (bindings #11 GitHub, #12 HTTP,
16+
#16 Crypto) that consume `hpm-*-rsr` Zig C-ABI crates.
17+
18+
toc::[]
19+
20+
== Scope — and what this doc deliberately is *not*
21+
22+
To respect the estate `DOC-DEDUP` rule, the boundary with adjacent docs
23+
is explicit:
24+
25+
[cols="2,5"]
26+
|===
27+
|Concern |Owning doc
28+
29+
|*Grammar + static semantics* of `extern fn` / `extern type`
30+
|link:SPEC.adoc#_2_10_extern_declarations[SPEC.adoc §2.10]
31+
32+
|*Which externs exist today*, their adapter class + unblock conditions
33+
|link:../STDLIB-EXTERN-AUDIT.adoc[STDLIB-EXTERN-AUDIT.adoc]
34+
35+
|*WebAssembly codegen mechanics* (`ctx`, imports, identifier resolution)
36+
|link:codegen-environment.adoc[codegen-environment.adoc] §4.2–4.3, §5
37+
38+
|*Decision that `extern fn` is the sole FFI surface* (no raw escape)
39+
|link:SETTLED-DECISIONS.adoc[SETTLED-DECISIONS.adoc]
40+
41+
|*Idris2-ABI proof-carrier* convention on the FFI boundary
42+
|link:../reference/ABI-FFI.md[reference/ABI-FFI] (bindings #41 — distinct axis)
43+
44+
|*This doc:* the step-by-step authoring recipe + per-backend host contract
45+
|(here)
46+
|===
47+
48+
This doc adds no new inventory rows and no new grammar; it is the recipe
49+
that ties the above together for the specific case of a Zig C-ABI export.
50+
51+
== The extern surface
52+
53+
`extern fn` declares a function whose implementation the host supplies at
54+
link time; `extern type` declares an opaque host-provided type with no
55+
runtime representation. Grammar (from SPEC §2.10):
56+
57+
[source,ebnf]
58+
----
59+
extern_fn_decl = [visibility] "extern" "fn" LOWER_IDENT
60+
[type_params] "(" [param_list] ")"
61+
["->" type_expr] ["/" effects] ";"
62+
extern_type_decl = [visibility] "extern" "type" UPPER_IDENT
63+
[type_params] ";"
64+
----
65+
66+
Representative real declarations already in the tree:
67+
68+
[source,affine]
69+
----
70+
// stdlib/json.affine — bound to the hpm-json-rsr Zig C-ABI crate
71+
pub extern type HpmJsonValue;
72+
pub extern fn hpm_json_parse(src: String) -> Option<HpmJsonValue>;
73+
pub extern fn hpm_json_free(val: HpmJsonValue) -> Int;
74+
pub extern fn hpm_json_array_get(val: HpmJsonValue, idx: Int) -> Option<HpmJsonValue>;
75+
76+
// stdlib/Http.affine — bound to the http-capability-gateway Zig C-ABI
77+
pub extern fn hpm_http_server_listen(host: String, port: Int)
78+
-> Option<HpmHttpServer> / { Net };
79+
----
80+
81+
The `/ { … }` clause is the effect row (`Net`, `Async`, `Io`, …); it is
82+
part of the type and is checked at every call site like any other
83+
effectful function.
84+
85+
== How an `extern fn` lowers
86+
87+
A single declaration lowers differently on each backend. Knowing both is
88+
what makes the host contract predictable.
89+
90+
=== Linear-memory WebAssembly backend
91+
92+
Every `extern fn name` becomes an import under the conventional `"env"`
93+
module; the slot is registered so call sites resolve through `call k`
94+
exactly like a local function (`lib/codegen.ml`, `gen_decl`):
95+
96+
[source,ocaml]
97+
----
98+
| TopFn fd when fd.fd_body = FnExtern ->
99+
let ft = func_type_of_fn_decl fd in
100+
let (type_idx, types_after) = intern_func_type ctx.types ft in
101+
let import = { i_module = "env";
102+
i_name = fd.fd_name.name;
103+
i_desc = ImportFunc type_idx } in
104+
Ok { ctx with imports = ctx.imports @ [import]; … }
105+
----
106+
107+
Param and result cells are `i32` on this backend; aggregates (`String`,
108+
arrays, records, `Option`) cross as `i32` offsets/handles into linear
109+
memory per the runtime representation — see
110+
link:codegen-environment.adoc[codegen-environment.adoc] §4.2–4.3.
111+
`extern type` emits no artifact (it is an opaque host pointer/handle).
112+
113+
*Host contract:* supply each symbol in the instantiation import object
114+
under `env`, e.g. `WebAssembly.instantiate(mod, { env: { hpm_json_parse,
115+
hpm_json_free, … } })`.
116+
117+
=== Deno-ESM backend
118+
119+
The emitter (`lib/codegen_deno.ml`) splits externs in two:
120+
121+
1. *Intrinsics* registered in the `deno_builtins` table lower to a
122+
self-contained helper emitted inline in the module preamble — no host
123+
wiring required. These are conventionally named `__as_*`, e.g.:
124+
+
125+
[source,javascript]
126+
----
127+
const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || [])));
128+
----
129+
130+
2. *Declared externs not in that table* (the normal case for a new Zig
131+
binding) lower to a call on a **same-named host symbol** — `mangle`
132+
is identity except for JS reserved words:
133+
+
134+
[source,ocaml]
135+
----
136+
| ExprVar id when Hashtbl.mem ctx.externs id.name ->
137+
(* Declared extern with no intrinsic lowering: assume a
138+
same-named host symbol is in scope. *)
139+
mangle id.name ^ "(" ^ String.concat ", " arg_strs ^ ")"
140+
----
141+
+
142+
So `hpm_json_parse(src)` emits `hpm_json_parse(src)`; the host must put a
143+
`hpm_json_parse` symbol in scope (typically `globalThis.hpm_json_parse`,
144+
populated by the consumer or its host wrapper before the generated module
145+
runs). Values cross as their natural JS forms (`Int`/`Float` → number,
146+
`String` → string); ADTs such as `Option<T>` follow the backend's value
147+
encoding.
148+
149+
NOTE: A native Zig C-ABI crate is invoked directly via `"env"` imports on
150+
the wasm backend. On the Deno-ESM backend the same `extern fn` resolves
151+
to a JS host symbol — so a Zig export is reached either by a thin JS shim
152+
or by instantiating the crate's own `.wasm` and exposing its exports
153+
under the matching names.
154+
155+
== Recipe — bind a new Zig C-ABI export
156+
157+
. *Zig side* (lives in the sibling `hpm-*-rsr` / capability-gateway repo,
158+
not here): export with C calling convention so the symbol is stable —
159+
`export fn hpm_json_parse(src_ptr: [*]const u8, src_len: usize) ?*HpmJsonValue`.
160+
Per the estate directive *"Zig = APIs + FFIs"*, the Zig crate owns the
161+
C-ABI surface; AffineScript only declares it.
162+
. *AffineScript side:* declare one `extern fn` per export, matching the
163+
symbol **name exactly** (the name is the wasm import name and the
164+
Deno-ESM host-symbol name). Model returned handles as an
165+
`extern type`; model "absent/failure" as `Option<T>`.
166+
. *Marshal types* per this table:
167+
+
168+
[cols="2,3,3"]
169+
|===
170+
|AffineScript |Wasm (linear-memory) |Deno-ESM
171+
172+
|`Int` |`i32` |`number`
173+
|`Float` |`f64` (host fn), `i32` cell at the import boundary |`number`
174+
|`String` |`i32` ptr+len into linear memory |`string`
175+
|`extern type` handle |`i32` (opaque pointer/index) |opaque host value
176+
|`Option<T>` |per value encoding (null/sentinel) |value or `null`
177+
|===
178+
+
179+
When in doubt, confirm the exact encoding against
180+
link:codegen-environment.adoc[codegen-environment.adoc] rather than
181+
assuming a layout.
182+
. *Pair every allocation with a free.* C-ABI crates that hand back a
183+
pointer expect the caller to release it — declare the matching
184+
`extern fn hpm_json_free(val: HpmJsonValue) -> Int` and call it. The
185+
affine type system does not track the host allocation for you.
186+
. *Annotate effects* (`/ { Net }`, `/ { Async }`, `/ { Io }`) so callers
187+
thread the effect row; a pure transform needs none.
188+
. *Wire the host:* wasm → add the symbol to the `env` import object;
189+
Deno-ESM → expose a same-named global before the module loads.
190+
. *Record + test:* add the rows to
191+
link:../STDLIB-EXTERN-AUDIT.adoc[STDLIB-EXTERN-AUDIT.adoc] (per its
192+
Update protocol) and add a `tests/codegen-deno/<name>_smoke.{affine,harness.mjs}`
193+
pair (the harness provides the host symbols and asserts a round-trip).
194+
195+
== Worked example — `hpm-json-rsr`
196+
197+
`stdlib/json.affine` binds the `hpm-json-rsr` Zig crate. The AffineScript
198+
side is purely declarative:
199+
200+
[source,affine]
201+
----
202+
pub extern type HpmJsonValue;
203+
204+
pub extern fn hpm_json_parse(src: String) -> Option<HpmJsonValue>;
205+
pub extern fn hpm_json_type(val: HpmJsonValue) -> Int;
206+
pub extern fn hpm_json_object_get(val: HpmJsonValue, key: String)
207+
-> Option<HpmJsonValue>;
208+
pub extern fn hpm_json_free(val: HpmJsonValue) -> Int; // ownership: caller frees
209+
----
210+
211+
* `HpmJsonValue` is an opaque handle — an `i32` pointer on wasm, an opaque
212+
JS value on Deno-ESM.
213+
* `parse` returns `Option` because parsing can fail.
214+
* `free` discharges the host allocation; every successful `parse` is
215+
balanced by exactly one `free`.
216+
* No effect row: parsing a string is pure with respect to the affine
217+
effect lattice (the allocation is an implementation detail of the host).
218+
219+
== Conventions and gotchas
220+
221+
* *Name = the symbol.* The declared identifier is both the wasm `env`
222+
import name and the Deno-ESM host-symbol name. Renaming on the affine
223+
side is not a rename of the binding — it breaks the link.
224+
* *Opaque handles, not structs.* Cross the boundary with an `extern type`
225+
handle plus accessor `extern fn`s; do not attempt to mirror a C struct
226+
layout in affine.
227+
* *Ownership is manual.* Pair allocate/free; the borrow checker does not
228+
see host memory.
229+
* *`Option<T>` is the failure channel.* Prefer `Option`/`Result` over
230+
sentinel return values so call sites are total.
231+
* *Namespace flattening.* Where a host API is nested
232+
(`vscode.commands.registerCommand`), bind a flat name
233+
(`registerCommand`) and let the host wrapper resolve it — see
234+
`stdlib/Vscode.affine`.
235+
236+
== Cross-references
237+
238+
* link:SPEC.adoc#_2_10_extern_declarations[SPEC.adoc §2.10] — `extern`
239+
grammar + static semantics.
240+
* link:../STDLIB-EXTERN-AUDIT.adoc[STDLIB-EXTERN-AUDIT.adoc] — live
241+
inventory of every `extern`, adapter class, unblock condition.
242+
* link:codegen-environment.adoc[codegen-environment.adoc] — WebAssembly
243+
realisation of imports + identifier resolution.
244+
* link:SETTLED-DECISIONS.adoc[SETTLED-DECISIONS.adoc] — `extern fn` as the
245+
sole FFI surface.
246+
* link:../bindings-roadmap.adoc[bindings-roadmap.adoc] — bindings #19
247+
(this doc), #11/#12/#16 (the RSR rewires it unblocks).
248+
* link:../reference/ABI-FFI.md[reference/ABI-FFI] — Idris2-ABI proof
249+
carriers on the FFI boundary (bindings #41 — a separate axis).

0 commit comments

Comments
 (0)