|
| 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