Skip to content

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

Merged
hyperpolymath merged 1 commit into
mainfrom
feat/extern-wasm-v2
May 15, 2026
Merged

feat(wasm): emit extern fn imports + direct calls (clean v2 — replaces #56)#58
hyperpolymath merged 1 commit into
mainfrom
feat/extern-wasm-v2

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

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

…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 hyperpolymath merged commit 931cae5 into main May 15, 2026
10 checks passed
@hyperpolymath hyperpolymath deleted the feat/extern-wasm-v2 branch May 15, 2026 01:07
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)
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