Skip to content

feat(wasm): emit extern fn imports + direct calls (clean — replaces #51)#56

Closed
hyperpolymath wants to merge 1 commit into
feat/extern-typecheck-freshfrom
feat/extern-wasm-fresh
Closed

feat(wasm): emit extern fn imports + direct calls (clean — replaces #51)#56
hyperpolymath wants to merge 1 commit into
feat/extern-typecheck-freshfrom
feat/extern-wasm-fresh

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Clean replacement for #51 (blocked by cascade-damaged diff). Cherry-picked f7f289b onto a fresh branch stacked on feat/extern-typecheck-fresh.

Close #51 alongside merging this.

Closes part of #43 (v2 grammar phase 2B-ii) — the wasm-output unblocker for hypatia's bridge.eph.

…B-ii)

Fourth slice of #43. Phase 2B-i wired the typechecker to accept
extern items; phase 2B-ii makes the wasm codegen actually emit
`(import "<abi>" "<name>" (func ...))` directives for each extern
fn and route calls to them at the correct import index.

This is the slice that finally produces a wasm artifact for
`hyperpolymath/hypatia`'s `bridge.eph` — combined with #46/#47/#48,
the file now parses, type-checks, AND lowers to a valid wasm
module with Gossamer host imports.

### Architectural change: dynamic function indices

The wasm function index space is `[imports..., function section...]`,
so adding extern imports shifts every runtime helper and user-fn
index forward by N. The pre-existing layout hard-coded
`NUM_IMPORTS = 2` plus `FN_BUMP_ALLOC = 2`, `FN_STRING_NEW = 3`,
... `FIRST_USER_FN = 12` as `const u32`. This commit:

* Replaces the absolute constants (`FN_BUMP_ALLOC`, `FIRST_USER_FN`,
  …) with **per-helper offset constants** (`OFFSET_BUMP_ALLOC`,
  `OFFSET_STRING_NEW`, …) plus accessor methods on `Codegen`
  (`fn_bump_alloc(&self) -> u32`, `first_user_fn(&self) -> u32`,
  …) that compute `import_count() + OFFSET_*` at call sites.
* Adds `import_count()` = `NUM_BUILTIN_IMPORTS + extern_imports.len()`.
* Touches 51 call sites mechanically — every `FN_*` reference is
  now `self.fn_*()`, every `FIRST_USER_FN` is `self.first_user_fn()`,
  and the closure lambda index calculation
  (`NUM_IMPORTS + NUM_RUNTIME_FNS + …`) is now
  `self.first_user_fn() + …`.

### New collection pass

`collect_extern_imports(&mut self, ast)` runs BEFORE
`collect_user_fns`. It walks `Decl::Extern { items }`:

* For each `ExternItem::Fn`, registers the wasm type signature,
  appends to `extern_imports`, and records the import index in
  `extern_fn_indices: HashMap<String, u32>`.
* For each `ExternItem::Type`, records the name in
  `extern_type_names` (currently only used for diagnostics —
  type items have no wasm-level representation; codegen treats
  them as opaque `i32` handles).

### `emit_imports` rewrite

Previously emitted exactly the 2 hard-coded host imports. Now:

1. `(import "env" "print_i32" (func ...))`  — index 0
2. `(import "env" "print_string" (func ...))` — index 1
3. ... iterate `self.extern_imports`, each becomes
   `(import "<abi>" "<name>" (func <type_idx>))` at the next index

### Direct-call routing for extern fns

`compile_app` now uses a new `flatten_app_chain` helper to
recognise curried `App(App(App(f, a), b), c)` chains and emit a
single direct `Call(import_idx)` with all args on the stack when
`f` is an extern fn. The same flattener also handles single-arg
user fn calls (the original direct-call path); multi-arg user fns
continue to take the indirect closure path for now (a separate
follow-up can promote them, with care around closure semantics).

### Out of scope / known gaps

* **FFI imports are still not emitted** — `ensure_ffi_import`
  collects symbols into `ffi_imports: HashMap<String, u32>` but
  `emit_imports` does not iterate that map. The pre-existing
  `next_import_idx = 100` sparse-allocation strategy is left in
  place. Fixing FFI requires a pre-pass over user fn bodies to
  discover all `__ffi(...)` calls before user fn indices are
  assigned. Filed as phase 2C work — not on hypatia's path
  (bridge.eph uses extern blocks, not raw `__ffi`).
* **Linear discipline at extern call sites**. If an extern fn
  returns a linear type, the caller must consume the result
  exactly once. The discipline checker already runs over fn
  bodies; this commit doesn't add any new check, so linear
  extern returns work-by-coincidence today (the type-checker
  sees them via the env binding, and `let!` enforces the linear
  consumption). A targeted test should land in phase 2C.

### Tests

* `extern_block_emits_wasm_imports` — full end-to-end: module
  with `extern "gossamer" { type Window; fn window_open(...): I32;
  fn window_close(...): () }` plus a user fn that calls
  `window_open(1, 2)`, compiled to wasm, validated with
  `wasmparser::Validator`, then the import section is parsed and
  asserted to contain `(env, print_i32)`, `(env, print_string)`,
  `(gossamer, window_open)`, `(gossamer, window_close)` in order.
* `module_without_extern_keeps_two_builtin_imports_only` —
  regression cover: the dynamic-import refactor must not change
  baseline modules. Module with no extern emits exactly the 2
  host imports.
* `extern_fn_call_uses_correct_import_index` — asserts the
  index recorded in `extern_fn_indices` for the first extern fn
  is `NUM_BUILTIN_IMPORTS` (= 2, right after the 2 host imports).

`cargo test -p ephapax-wasm` → 66/66 (+3). Full workspace test
sweep clean.

### Effect on hypatia

Combined with #46 + #47 + #48, `bridge.eph` now lowers to a
valid wasm module: the `extern "gossamer"` block emits real
`(import "gossamer" "<name>")` directives, and the user-fn calls
into those (e.g. `ipc_open(...)`) emit direct `Call(<import_idx>)`
instructions. The `build-gossamer-gui.yml` workflow's `Compile
bridge.eph` step should now produce
`src/ui/public/assets/wasm/hypatia_gui.wasm` instead of firing
the `::warning::` for unsupported grammar.

Once this stack lands, the `compile-eph` / `compile-affine` clap
aliases (original #36) ship as the trivial 3-line addition they
always were.

Stacked on #48. Rebases to `main` automatically once #46, #47,
#48 land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@hyperpolymath
Copy link
Copy Markdown
Owner Author

Superseded by the clean cherry-pick PR opened against current main. The base chain (feat/extern-typecheck-fresh) went stale after the cascade.

hyperpolymath added a commit that referenced this pull request May 15, 2026
…#56) (#58)

Clean cherry-pick of the wasm commit onto a fresh branch off current
main. Replaces #56 (which was based on a now-stale chain).

This is the slice that finally produces a wasm artifact for hypatia's
bridge.eph: extern fns now emit `(import "<abi>" "<name>" (func ...))`
directives and call sites route to the right import indices.

Closes part of #43 (v2 grammar phase 2B-ii).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@hyperpolymath hyperpolymath deleted the feat/extern-wasm-fresh branch May 15, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant