feat(wasm): emit extern fn imports + direct calls (clean v2 — replaces #56)#58
Merged
Conversation
…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>
This was referenced May 15, 2026
hyperpolymath
added a commit
that referenced
this pull request
May 15, 2026
Closes #75 and clears the 14 outstanding `TODO/FIXME/XXX/HACK` markers found by the repo-wide sweep after the match-feature thread (#66/#68/#67) landed. ## What changed ### LSP symbols — closes #75 `extract_declarations` in `src/ephapax-lsp/src/main.rs` no longer drops `Decl::Extern` and `Decl::Data` on the floor: - `Decl::Extern { abi, items }` → one symbol per `ExternItem::Fn` (kind `ExternFn`) and per `ExternItem::Type` (kind `ExternType`). - `Decl::Data { name, type_params, constructors }` → one symbol for the data type itself (kind `Data`) plus one nested symbol per constructor (kind `Constructor`). `DeclKind` gains four variants; hover, `document_symbol`, and completion all recognise them. Hover annotates extern items and constructors with the appropriate role. ### Stale `#43 phase 2B` comments cleaned Four comments referenced now-closed work. Replaced with accurate text: | Site | Was | Now | |---|---|---| | `ephapax-typing/src/lib.rs:2339` | "TODO: typecheck extern items" | "extern sigs registered earlier in the signature-collection pass" | | `ephapax-wasm/src/lib.rs:773` | "TODO: emit `(import …)` directives" | "imports emitted via `collect_extern_imports`" | | `ephapax-wasm/src/lib.rs:1118` | "TODO: emit no body for extern fns" | "extern fns have no code-section body" | | `ephapax-lsp/src/main.rs:540` | "TODO: expose extern items" | addressed by new symbol coverage | These extern paths were finished by #58; the TODO comments were just stale. ### Legacy standalone tooling `lib/formatter.rs`, `lib/linter.rs`, `tools/ephapax-lsp/src/main.rs`, `tools/ephapax-dap/src/main.rs` carry eight TODOs that were not tracking gaps — they were bare reminders that the shims aren't full implementations. Each module header documents the rationale. I replaced the bare `// TODO:` lines with explicit "intentional stub — see the compiler-integrated counterpart at `…`" prose, so the gap is documentation rather than a sweep hit. ## Sweep result ``` $ rg -n 'TODO|FIXME|XXX|HACK' --type rust (no matches) ``` ## Test plan - [x] `cargo build --workspace` clean. - [x] `cargo test --workspace --lib --no-fail-fast` — every crate's tests pass (counts unchanged from main; no LSP-specific unit tests exist in this crate). - [x] Manual smoke: confirmed every `DeclKind::*` arm reaches a matching `SymbolKind` / `CompletionItemKind` branch. - [ ] CI green on all checks. ## Out of scope - No new tests in `ephapax-lsp` — this crate lacks a test harness today, and the symbol changes are exercised entirely by the build's exhaustive match coverage. Adding LSP-level integration tests would be its own follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).