Skip to content

feat(typing,desugar): typecheck extern items (v2 grammar phase 2B-i of #43)#48

Closed
hyperpolymath wants to merge 3 commits into
mainfrom
v2-grammar-extern-typecheck
Closed

feat(typing,desugar): typecheck extern items (v2 grammar phase 2B-i of #43)#48
hyperpolymath wants to merge 3 commits into
mainfrom
v2-grammar-extern-typecheck

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Summary

Third slice of #43. Phase 2A landed parse + AST + desugar for extern "abi" { ... } blocks; phase 2B-i wires the typechecker so regular fn bodies that call extern fns and refer to extern types type-check correctly.

Stacked on #47. Base rebases to main automatically once #46 and #47 merge.

Changes

  • Extern types are nominal opaques (ephapax-desugar). The DataRegistry grows an extern_types: HashSet<SmolStr>; the first pass of desugar_module populates it from each SurfaceDecl::Extern's Type items. desugar_named_type short-circuits names in that set with Ty::Var(name) instead of erroring UnknownType. Two distinct extern type names produce two distinct rigid Ty::Var that never unify — Window and IpcChannel are kept apart.
  • Extern fn signatures enter the type environment (ephapax-typing). type_check_module_inner's first pass now walks Decl::Extern { items }: each ExternItem::Fn gets its params + ret_ty folded into a Ty::Fun chain and registered in tc.ctx. Regular fn bodies that reference an extern fn name resolve through the same Var(...) lookup any other module-level binding uses.
  • Extern fns are public exports (ModuleRegistry::register). Cross-module imports see the extern signatures too, so a dependent module that imports this one type-checks calls into the extern API.
  • Type items remain type-namespace-only. ipc_open is callable; Window itself isn't a value — matching how data declarations behave (constructors are values, the type is not).

Tests

  • desugar_extern_types_resolve_to_ty_varWindow/IpcChannel resolve to distinct Ty::Var rigids.
  • typecheck_extern_nullary_fn_callableentry(): Window = open_handle (where open_handle is a nullary extern fn) type-checks.
  • typecheck_extern_unary_fn_appliedentry(): Window = open(7) with open: I32 -> Window type-checks; argument unifies with parameter.
  • typecheck_extern_fn_arg_mismatch_rejected — calling open: I32 -> Window with Bool(true) is rejected.
  • typecheck_distinct_extern_types_do_not_unify — declaring entry: Window but returning a value of type IpcChannel fails (rigid Ty::Var types never unify with each other).

cargo test --workspace clean: typing 53/53 (+4), desugar 18/18 (+1), no regressions elsewhere.

What this means for hypatia

Combined with #46 + #47, bridge.eph now:

  • ✅ parses (slash-paths, extern blocks)
  • ✅ desugars (extern types resolve to Ty::Var)
  • ✅ type-checks (extern fn calls resolve through the env, signatures enforced)
  • ❌ does not yet emit wasm — that's phase 2B-ii

The build-gossamer-gui.yml workflow's parse-error grep no longer fires (good); but compile bridge.eph -o bridge.wasm still skips the extern bodies at codegen time so no wasm artifact is produced. Phase 2B-ii is the architectural refactor that fixes that.

Out of scope (phase 2B-ii, next PR)

  • Wasm codegen emits (import "<abi>" "<name>" (func ...)) directives. Requires FIRST_USER_FN to become dynamic and emit_imports to walk a collected registry. The pre-existing FFI import machinery has the same gap (FFI imports are collected via ensure_ffi_import but never written to the wasm import section); 2B-ii fixes both.
  • Linear discipline for extern fn return values at call sites. If an extern fn returns a linear type (e.g. Window!), the caller must consume the result exactly once.

After 2B-ii, the compile-eph / compile-affine clap aliases (original #36 ask) ship as the trivial 3-line addition they always were.

Test plan

hyperpolymath and others added 3 commits May 14, 2026 22:52
First slice of the v2-grammar work in #43. The pest grammar's
`qualified_name` rule was dot-separated only — `Foo.Bar.Baz`. The
downstream `hypatia/ui/bridge` style used by `hyperpolymath/hypatia`'s
`bridge.eph` would not parse, which is the first concrete error any
consumer of the v2 grammar hits.

Two changes:

1. **`ephapax.pest`** — `qualified_name` now admits `/` alongside
   `.` and `_`. Both separators are accepted at the grammar level;
   the path is stored verbatim in `Module.name`. No canonicalisation
   yet — that comes when the import resolver lands.

2. **`parse_surface_module`** — previously walked only
   `Rule::declaration`, silently dropping the `module Foo` header.
   Now extracts the module name from `Rule::module_decl` when
   present, falling back to the filename otherwise. Matches the
   pre-existing behaviour of `parse_module` (core parser).

Tests added in both `surface::tests` and `tests::`:

* `parse_module_with_slash_path` — `module hypatia/ui/bridge`
  parses; name preserved.
* `parse_module_with_dot_path` — `module Foo.Bar.Baz` continues to
  parse; covers regression of the historical form.
* `parse_module_without_decl_uses_filename` (surface only) — the
  filename-fallback path the parser already supported.

Out of scope for this commit:

* `extern "abi" { ... }` blocks — separate grammar + AST work, the
  second-largest gap behind `bridge.eph`. Tracked in #43.
* `Rule::match_expr` dispatch in `parse_single_expr_core` (core
  parser only; surface parser already routes it correctly via
  `parse_match_expr`). Tracked in #43.
* `Decl::Data` variant — `data` decls currently piggy-back on
  `parse_type_decl` in the core parser; the surface parser models
  them properly. Cleanup tracked in #43.

After this lands, `bridge.eph`'s `module hypatia/ui/bridge` line
parses; the next blocker the parser hits on that file is the
`extern "gossamer" { ... }` block, which is the natural next PR.

Hypatia's `build-gossamer-gui.yml` probe order is unchanged —
the `::warning::` is still emitted while `extern` remains
unsupported, since the workflow greps for `Parse error|expected EOI`
which the `extern` rule still produces.

Closes part of #43 (item 1 in the re-scoped ordering).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Second slice of #43. After the slash-paths PR (#46), the next concrete
parse error on `hyperpolymath/hypatia`'s `bridge.eph` is the
`extern "gossamer" { type Window; fn window_open(…): Window; … }`
block. This commit makes that parse.

What lands:

* **Pest grammar** (`ephapax-parser/src/ephapax.pest`): new
  `extern_block` / `extern_item` / `extern_type_item` /
  `extern_fn_item` rules. Top-level `declaration` rule extended to
  admit `extern_block`. `"extern"` added to `keyword` and
  `keyword_boundary` so identifiers can't shadow it.

* **Core AST** (`ephapax-syntax`): new `Decl::Extern { abi, items }`
  variant plus `ExternItem { Type { name } | Fn { name, params,
  ret_ty } }` enum. Item params/ret carry core `Ty`.

* **Surface AST** (`ephapax-surface`): mirror
  `SurfaceDecl::Extern { abi, items, span }` and
  `SurfaceExternItem` with the same shape but `SurfaceTy`. The span
  lives on the block, not on individual items, matching how
  `DataDecl` carries one block-level span.

* **Core parser** (`lib.rs`): `parse_declaration` routes
  `Rule::extern_block` to a new `parse_extern_block` →
  `parse_extern_item` pair. The ABI string is unquoted and
  unescaped via a minimal helper that handles `\"` and `\\`
  (sufficient for ABI tags; extend if richer escapes appear).

* **Surface parser** (`surface.rs`): same plumbing against
  `SurfaceExternItem`, types staying in `SurfaceTy` until the
  desugar pass.

* **Desugar** (`ephapax-desugar`): `SurfaceDecl::Extern` lowers to
  `Decl::Extern` by walking each item and mapping its surface
  types through `desugar_ty`. Extern types pass through as-is
  (just the name). The block carries no body so there's no
  expression-level desugaring.

* **Downstream stubs** so the workspace builds: every exhaustive
  match on `Decl` now handles `Decl::Extern` — typecheck
  (no-op for now, registered as TODO), affine/linear discipline
  (no body → nothing to check), wasm codegen (skipped — wasm
  imports are phase 2B), IR s-expr printer (renders the block
  with `extern-type` / `extern-fn` tagged sub-forms), LSP
  symbol extractor (`filter_map` to drop extern blocks from the
  outline — phase 2B will expose them as navigable symbols).

Tests:

* `parse_empty_extern_block` — `extern "gossamer" { }` round-trips
  with empty items.
* `parse_extern_block_with_type_and_fn_items` — full hypatia
  shape: `type Window` + `fn window_open(title, body): Window`.
* `parse_bridge_eph_shaped_prelude` — slash-pathed module +
  extern block + regular fn all in one file (the canonical
  bridge.eph prelude).
* `test_parse_extern_block_core` — same shape through the core
  parser, asserting `ExternItem::Type` / `ExternItem::Fn` and
  param arity.
* `desugar_extern_block` — `SurfaceDecl::Extern` → `Decl::Extern`,
  with `SurfaceTy::String(region)` → `Ty::String(region)` and
  `SurfaceTy::Base(I32)` → `Ty::Base(I32)` covered.

`cargo test --workspace` passes. `cargo check --workspace` clean.

Out of scope (phase 2B follow-up, tracked in #43):

* **Typechecker**: register extern types as opaque nominal types
  and extern fns as ambient bindings in the module env so other
  decls can call them.
* **Wasm codegen**: emit `(import "<abi>" "<name>" (func ...))`
  for fn items, treat type items as opaque (i32) externs. This
  is what actually unblocks `bridge.eph → bridge.wasm`.
* **LSP**: surface extern items as navigable symbols.

After phase 2B, the `compile-eph` / `compile-affine` clap aliases
land as trivial 3-line additions (the original #36 ask), and
hypatia's `build-gossamer-gui.yml` workflow flips from
`::warning::` to an actual wasm artifact.

Stacked on #46. Rebase target switches to `main` when #46 merges.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third slice of #43. Phase 2A landed parse + AST + desugar for
`extern "abi" { ... }` blocks; phase 2B-i wires the typechecker so
regular fn bodies can call extern fns and refer to extern types,
producing correct typing rather than silent no-ops.

What lands:

* **Extern types are nominal opaques** (`ephapax-desugar`). The
  `DataRegistry` grows an `extern_types: HashSet<SmolStr>` that the
  first pass of `desugar_module` populates from each
  `SurfaceDecl::Extern`'s `Type` items. `desugar_named_type` now
  short-circuits names in that set, returning `Ty::Var(name)`
  instead of erroring with `UnknownType`. Two distinct extern type
  names produce two distinct rigid type variables that never unify,
  so `Window` values can't be confused with `IpcChannel` etc.

* **Extern fn signatures enter the type environment**
  (`ephapax-typing`). `type_check_module_inner`'s first pass now
  also walks `Decl::Extern { items }`: each `ExternItem::Fn` gets
  its params + ret_ty folded into a `Ty::Fun` chain and registered
  in `tc.ctx` with `BindingForm::Let`. Regular fn bodies that
  reference an extern fn name (or apply it) resolve through the
  same `Var(...)` lookup path as any other module-level binding,
  with full unification/inference applied.

* **Extern fns are public exports** (`ModuleRegistry::register`).
  Cross-module imports also see the extern signatures, so a
  dependent module that imports this one type-checks calls into
  the extern API.

* **Type items remain in the type namespace only.** Extern types
  have no value-level binding — `ipc_open` is callable, but
  `Window` itself isn't a value. This matches how `data` declarations
  behave (the constructors are values, the type is not).

Tests:

* `desugar_extern_types_resolve_to_ty_var` — `Window` and
  `IpcChannel` declared as extern types both resolve to their
  respective `Ty::Var` rigids; the two are unequal.
* `typecheck_extern_nullary_fn_callable` — `entry()` whose body
  is just a reference to `open_handle` (a nullary extern fn)
  type-checks and the body's type matches the declared return.
* `typecheck_extern_unary_fn_applied` — `entry(): Window = open(7)`
  where `open: I32 -> Window` is an extern fn type-checks; the
  argument I32 unifies with the parameter and the result flows
  back as the return.
* `typecheck_extern_fn_arg_mismatch_rejected` — calling
  `open: I32 -> Window` with `Bool(true)` raises a typing error
  rather than silently succeeding.
* `typecheck_distinct_extern_types_do_not_unify` — declaring
  `entry: Window` but returning a value of type `IpcChannel`
  fails (the two rigid `Ty::Var` types never unify).

`cargo test --workspace` clean.

Out of scope (phase 2B-ii follow-up, still in #43):

* **Wasm codegen emits `(import "<abi>" "<name>" (func ...))`**
  directives for extern fn items. This is the architectural
  refactor — `FIRST_USER_FN` (currently a hardcoded `12`) becomes
  dynamic based on the number of collected imports; the
  `emit_imports` function grows to walk a collected import
  registry rather than hardcoding the 2 host imports. The
  pre-existing FFI import machinery has the same gap (FFI
  imports are collected via `ensure_ffi_import` but never written
  into the wasm import section) — 2B-ii fixes both at once.
* **Linear discipline for extern fns**. If an extern fn returns
  a linear type (e.g. `Window!`), the caller must consume the
  result exactly once. The discipline-checker arms added in
  phase 2A skip extern decls — that remains correct for
  declarations themselves, but call-site enforcement on linear
  extern return types is part of 2B-ii.

After phase 2B-ii lands, `hyperpolymath/hypatia`'s `bridge.eph`
finally compiles to wasm with proper Gossamer-host imports, the
`build-gossamer-gui.yml` workflow flips from `::warning::` to a
real artifact, and the `compile-eph` / `compile-affine` clap
aliases (the original ask in the now-closed #36) ship as the
trivial 3-line addition they always wanted to be.

Stacked on #47 (extern blocks parse+AST+desugar). Base rebases to
`main` automatically once #46 and #47 land.

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

Superseded by #55 (clean cherry-pick of the same commit onto a fresh branch off main, no diff drift). #54 ships extern-blocks (same content as #47/#53); #55 is this one's replacement; #56 ships the wasm half.

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