From d676a63dd1931114a5011c0aaf4ca9870662ebc8 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 14 May 2026 22:52:14 +0100 Subject: [PATCH 1/3] feat(parser): admit slash-separated module paths (`a/b/c`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ephapax-parser/src/ephapax.pest | 8 +++-- src/ephapax-parser/src/lib.rs | 21 +++++++++++++ src/ephapax-parser/src/surface.rs | 46 +++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/ephapax-parser/src/ephapax.pest b/src/ephapax-parser/src/ephapax.pest index f53c5d6..f9b5634 100644 --- a/src/ephapax-parser/src/ephapax.pest +++ b/src/ephapax-parser/src/ephapax.pest @@ -15,8 +15,12 @@ expression_only = { SOI ~ expression ~ EOI } module_decl = { "module" ~ qualified_name } -// Qualified name: Foo.Bar.Baz -qualified_name = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | ".")* } +// Qualified name: Foo.Bar.Baz OR Foo/Bar/Baz (slash- and dot-segments both +// allowed and treated as equivalent at the AST level — see canonicalisation +// in `parse_module` / `parse_surface_module`). Slash-paths matter for the +// `hypatia/ui/bridge` style used by downstream consumers; dot-paths are +// historical. +qualified_name = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "." | "/")* } // ============================================================================ // Imports diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index 7d3437c..144d7bb 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -1857,6 +1857,27 @@ mod tests { assert_eq!(module.decls.len(), 2); } + /// Slash-separated module paths (`module hypatia/ui/bridge`) must + /// parse; the core parser surfaces the path verbatim as the module + /// name. Companion to the surface-parser test of the same shape. + #[test] + fn test_parse_module_with_slash_path() { + let source = "module hypatia/ui/bridge\n\nfn one(): I32 = 1"; + let module = parse_module(source, "").expect("should parse"); + assert_eq!(module.name.as_str(), "hypatia/ui/bridge"); + assert_eq!(module.decls.len(), 1); + } + + /// Dot-separated module paths continue to work — both separators are + /// accepted by the same `qualified_name` rule. + #[test] + fn test_parse_module_with_dot_path() { + let source = "module Foo.Bar.Baz\n\nfn two(): I32 = 2"; + let module = parse_module(source, "").expect("should parse"); + assert_eq!(module.name.as_str(), "Foo.Bar.Baz"); + assert_eq!(module.decls.len(), 1); + } + // ===== New feature parsing tests ===== #[test] diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index bcc9a4d..e1cad14 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -42,15 +42,24 @@ pub fn parse_surface_module(source: &str, name: &str) -> Result { + if let Some(qn) = inner.into_inner().next() { + module_name = SmolStr::new(qn.as_str()); + } + } + Rule::declaration => { + decls.push(parse_surface_declaration(inner).map_err(|e| vec![e])?); + } + _ => {} } } Ok(SurfaceModule { - name: SmolStr::new(name), + name: module_name, decls, }) } @@ -1572,6 +1581,37 @@ mod tests { assert!(matches!(expr.kind, SurfaceExprKind::LetLin { .. })); } + /// Slash-separated module paths (the `hypatia/ui/bridge` shape used by + /// downstream consumers) must parse alongside the historical dot form. + #[test] + fn parse_module_with_slash_path() { + let source = "module hypatia/ui/bridge\n\nfn one(): I32 = 1"; + let module = parse_surface_module(source, "").unwrap(); + assert_eq!(module.name.as_str(), "hypatia/ui/bridge"); + assert_eq!(module.decls.len(), 1); + } + + /// Dot-separated module paths continue to work — the new rule admits + /// both segment separators and the surface walker preserves whichever + /// the file used. + #[test] + fn parse_module_with_dot_path() { + let source = "module Foo.Bar.Baz\n\nfn two(): I32 = 2"; + let module = parse_surface_module(source, "").unwrap(); + assert_eq!(module.name.as_str(), "Foo.Bar.Baz"); + assert_eq!(module.decls.len(), 1); + } + + /// When the source has no `module` decl the surface walker falls back + /// to the filename passed in, matching the pre-refactor behaviour. + #[test] + fn parse_module_without_decl_uses_filename() { + let source = "fn three(): I32 = 3"; + let module = parse_surface_module(source, "fallback-name").unwrap(); + assert_eq!(module.name.as_str(), "fallback-name"); + assert_eq!(module.decls.len(), 1); + } + #[test] fn parse_full_module_with_data_and_match() { let source = r#" From f9f8657696232b19797d84d2351e723d80d5425b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 14 May 2026 23:33:42 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat(parser,desugar):=20extern=20"abi"=20{?= =?UTF-8?q?=20=E2=80=A6=20}=20blocks=20(v2=20grammar=20phase=202A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "" "" (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 --- ephapax-linear/src/affine.rs | 3 + ephapax-linear/src/linear.rs | 3 + src/ephapax-desugar/src/lib.rs | 87 ++++++++++++++- src/ephapax-ir/src/lib.rs | 39 +++++++ src/ephapax-lsp/src/main.rs | 20 ++-- src/ephapax-parser/src/ephapax.pest | 23 +++- src/ephapax-parser/src/lib.rs | 130 +++++++++++++++++++++- src/ephapax-parser/src/surface.rs | 166 +++++++++++++++++++++++++++- src/ephapax-surface/src/lib.rs | 29 +++++ src/ephapax-syntax/src/lib.rs | 32 ++++++ src/ephapax-typing/src/lib.rs | 12 ++ src/ephapax-wasm/src/lib.rs | 12 ++ 12 files changed, 543 insertions(+), 13 deletions(-) diff --git a/ephapax-linear/src/affine.rs b/ephapax-linear/src/affine.rs index df57c96..c3c4dc9 100644 --- a/ephapax-linear/src/affine.rs +++ b/ephapax-linear/src/affine.rs @@ -80,6 +80,9 @@ impl AffineChecker { } } Decl::Type { .. } | Decl::Const { .. } => {} + // Extern declarations have no body — no affine discipline + // applies. The signatures only exist to inform codegen. + Decl::Extern { .. } => {} } } diff --git a/ephapax-linear/src/linear.rs b/ephapax-linear/src/linear.rs index bad4c06..55072ab 100644 --- a/ephapax-linear/src/linear.rs +++ b/ephapax-linear/src/linear.rs @@ -90,6 +90,9 @@ impl LinearChecker { } } Decl::Type { .. } | Decl::Const { .. } => { /* no discipline check needed */ } + // Extern declarations have no body — no linear discipline + // applies. The signatures only exist to inform codegen. + Decl::Extern { .. } => {} } } diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 415d092..ae8e3a4 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -57,9 +57,9 @@ use std::collections::HashMap; use ephapax_surface::{ ConstructorDef, DataDecl, MatchArm, Pattern, Span, SurfaceDecl, SurfaceExpr, SurfaceExprKind, - SurfaceModule, SurfaceTy, + SurfaceExternItem, SurfaceModule, SurfaceTy, }; -use ephapax_syntax::{BaseTy, Decl, Expr, ExprKind, Literal, Module, Ty, Visibility}; +use ephapax_syntax::{BaseTy, Decl, Expr, ExprKind, ExternItem, Literal, Module, Ty, Visibility}; use smol_str::SmolStr; use thiserror::Error; @@ -236,6 +236,39 @@ impl Desugarer { // as core declarations. The type information lives in the // registry and is used to desugar Construct/Match nodes. SurfaceDecl::Data(_) => {} + // Extern blocks lower their items' surface types to core + // types and keep them as `Decl::Extern`. The type checker + // and codegen pick them up from there. No body to desugar. + SurfaceDecl::Extern { abi, items, .. } => { + let mut core_items: Vec = Vec::with_capacity(items.len()); + for item in items { + match item { + SurfaceExternItem::Type { name } => { + core_items.push(ExternItem::Type { name: name.clone() }); + } + SurfaceExternItem::Fn { + name, + params, + ret_ty, + } => { + let core_params: Vec<(SmolStr, Ty)> = params + .iter() + .map(|(n, t)| Ok((n.clone(), self.desugar_ty(t)?))) + .collect::>()?; + let core_ret = self.desugar_ty(ret_ty)?; + core_items.push(ExternItem::Fn { + name: name.clone(), + params: core_params, + ret_ty: core_ret, + }); + } + } + } + core_decls.push(Decl::Extern { + abi: abi.clone(), + items: core_items, + }); + } } } @@ -1023,7 +1056,7 @@ mod tests { use super::*; use ephapax_surface::{ BaseTy, ConstructorDef, DataDecl, Literal, MatchArm, Pattern, Span, SurfaceDecl, - SurfaceExpr, SurfaceExprKind, SurfaceModule, SurfaceTy, + SurfaceExpr, SurfaceExprKind, SurfaceExternItem, SurfaceModule, SurfaceTy, }; fn se(kind: SurfaceExprKind) -> SurfaceExpr { @@ -1580,4 +1613,52 @@ mod tests { } } } + + /// `SurfaceDecl::Extern` lowers to `Decl::Extern` with item types + /// converted from `SurfaceTy` to core `Ty`. Phase 2A: the + /// declaration survives desugar intact; downstream typecheck and + /// codegen accept it but currently treat it as a no-op (phase 2B + /// will wire those). + #[test] + fn desugar_extern_block() { + let module = SurfaceModule { + name: "test".into(), + decls: vec![SurfaceDecl::Extern { + abi: "gossamer".to_string(), + items: vec![ + SurfaceExternItem::Type { + name: "Window".into(), + }, + SurfaceExternItem::Fn { + name: "window_open".into(), + params: vec![("title".into(), SurfaceTy::String("r".into()))], + ret_ty: SurfaceTy::Base(BaseTy::I32), + }, + ], + span: Span::dummy(), + }], + }; + + let core = desugar(&module).unwrap(); + assert_eq!(core.decls.len(), 1); + match &core.decls[0] { + Decl::Extern { abi, items } => { + assert_eq!(abi, "gossamer"); + assert_eq!(items.len(), 2); + assert!(matches!( + &items[0], + ExternItem::Type { name } if name.as_str() == "Window" + )); + if let ExternItem::Fn { name, params, ret_ty } = &items[1] { + assert_eq!(name.as_str(), "window_open"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].0.as_str(), "title"); + assert_eq!(*ret_ty, Ty::Base(BaseTy::I32)); + } else { + panic!("expected ExternItem::Fn, got {:?}", &items[1]); + } + } + other => panic!("expected Decl::Extern, got {other:?}"), + } + } } diff --git a/src/ephapax-ir/src/lib.rs b/src/ephapax-ir/src/lib.rs index 52216b6..c33b96a 100644 --- a/src/ephapax-ir/src/lib.rs +++ b/src/ephapax-ir/src/lib.rs @@ -277,6 +277,45 @@ fn decl_to_sexpr(decl: &Decl) -> SExpr { ty.as_ref().map(ty_to_sexpr).unwrap_or(SExpr::Atom("_".into())), expr_to_sexpr(value), ]), + // Render `extern "abi" { ... }` as a tagged s-expr so the IR + // round-trip preserves the declaration. Item shape mirrors the + // wasm view that codegen will eventually consume: + // (extern "abi" ((extern-type Foo) (extern-fn name ((p T)...) R))) + Decl::Extern { abi, items } => SExpr::List(vec![ + SExpr::Atom("extern".into()), + SExpr::Atom(format!("{abi:?}")), + SExpr::List( + items + .iter() + .map(|item| match item { + ephapax_syntax::ExternItem::Type { name } => SExpr::List(vec![ + SExpr::Atom("extern-type".into()), + SExpr::Atom(escape_atom(name)), + ]), + ephapax_syntax::ExternItem::Fn { + name, + params, + ret_ty, + } => SExpr::List(vec![ + SExpr::Atom("extern-fn".into()), + SExpr::Atom(escape_atom(name)), + SExpr::List( + params + .iter() + .map(|(p, t)| { + SExpr::List(vec![ + SExpr::Atom(escape_atom(p)), + ty_to_sexpr(t), + ]) + }) + .collect(), + ), + ty_to_sexpr(ret_ty), + ]), + }) + .collect(), + ), + ]), } } diff --git a/src/ephapax-lsp/src/main.rs b/src/ephapax-lsp/src/main.rs index ee1c62d..a0476fa 100644 --- a/src/ephapax-lsp/src/main.rs +++ b/src/ephapax-lsp/src/main.rs @@ -483,7 +483,7 @@ fn extract_declarations(module: &Module, _source: &str) -> Vec { module .decls .iter() - .map(|decl| match decl { + .filter_map(|decl| match decl { Decl::Fn { name, params, @@ -508,24 +508,24 @@ fn extract_declarations(module: &Module, _source: &str) -> Vec { format_ty(ret_ty) ); - DeclInfo { + Some(DeclInfo { name: name.to_string(), kind: DeclKind::Function, span: body.span, signature: sig, params: param_strs, return_type: Some(format_ty(ret_ty)), - } + }) } - Decl::Type { name, visibility: _, ty } => DeclInfo { + Decl::Type { name, visibility: _, ty } => Some(DeclInfo { name: name.to_string(), kind: DeclKind::TypeAlias, span: Span::dummy(), signature: format!("type {} = {}", name, format_ty(ty)), params: Vec::new(), return_type: None, - }, - Decl::Const { name, ty, value } => DeclInfo { + }), + Decl::Const { name, ty, value } => Some(DeclInfo { name: name.to_string(), kind: DeclKind::TypeAlias, // closest existing variant span: value.span, @@ -536,7 +536,13 @@ fn extract_declarations(module: &Module, _source: &str) -> Vec { ), params: Vec::new(), return_type: ty.as_ref().map(|t| format_ty(t)), - }, + }), + // TODO(ephapax#43 phase 2B): expose extern items as + // navigable LSP symbols. For phase 2A the LSP simply + // doesn't index extern declarations; the block parses + // and lives in the AST but isn't visible to hover / + // go-to-definition yet. + Decl::Extern { .. } => None, }) .collect() } diff --git a/src/ephapax-parser/src/ephapax.pest b/src/ephapax-parser/src/ephapax.pest index f9b5634..eac9c98 100644 --- a/src/ephapax-parser/src/ephapax.pest +++ b/src/ephapax-parser/src/ephapax.pest @@ -34,7 +34,26 @@ import_decl = { // Declarations // ============================================================================ -declaration = { data_decl | fn_decl | type_decl | const_decl } +declaration = { extern_block | data_decl | fn_decl | type_decl | const_decl } + +// ============================================================================ +// Extern Blocks +// ============================================================================ +// +// `extern "abi" { items... }` declares foreign types and function +// signatures. Items have no body; codegen lowers fn items to host +// `import` directives and treats type items as opaque externs. +// +// Example: +// extern "gossamer" { +// type Window +// fn window_open(title: String, body: String): Window +// } + +extern_block = { "extern" ~ string ~ "{" ~ extern_item* ~ "}" } +extern_item = { extern_type_item | extern_fn_item } +extern_type_item = { "type" ~ identifier } +extern_fn_item = { "fn" ~ identifier ~ "(" ~ param_list? ~ ")" ~ (":" | "->") ~ ty } // Top-level constant binding: let NAME = expr const_decl = { "let" ~ identifier ~ (":" ~ ty)? ~ "=" ~ expression } @@ -370,6 +389,7 @@ keyword_boundary = { ( "let!" | "let" | "in" | "fn" | "if" | "then" | "else" | "region" | "case" | "of" | "inl" | "inr" | "end" | "fst" | "snd" | "drop" | "copy" | "type" | "data" | "match" + | "extern" | "true" | "false" | "pub" | "import" | "module" | "linear" | "perform" | "handle" | "with" | "return" | "resume" | "once" | "multi" | "Bool" | "I32" | "I64" | "F32" | "F64" | "String" @@ -380,6 +400,7 @@ keyword = { "let" | "let!" | "in" | "fn" | "if" | "then" | "else" | "region" | "case" | "of" | "inl" | "inr" | "end" | "fst" | "snd" | "drop" | "copy" | "type" | "data" | "match" + | "extern" | "true" | "false" | "pub" | "import" | "module" | "linear" | "perform" | "handle" | "with" | "return" | "resume" | "once" | "multi" | "Bool" | "I32" | "I64" | "F32" | "F64" | "String" diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index 144d7bb..44e7e3b 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -16,7 +16,8 @@ //! ``` use ephapax_syntax::{ - BaseTy, BinOp, Decl, Expr, ExprKind, Import, Literal, Module, Span, Ty, UnaryOp, Visibility, + BaseTy, BinOp, Decl, Expr, ExprKind, ExternItem, Import, Literal, Module, Span, Ty, UnaryOp, + Visibility, }; use pest::Parser; use pest_derive::Parser; @@ -145,6 +146,7 @@ fn parse_declaration(pair: pest::iterators::Pair) -> Result parse_type_decl(inner), Rule::data_decl => parse_type_decl(inner), // data decls are a form of type decl Rule::const_decl => parse_const_decl(inner), + Rule::extern_block => parse_extern_block(inner), _ => Err(ParseError::Syntax { message: format!("Unexpected declaration: {:?}", inner.as_rule()), span: span_from_pair(&inner), @@ -152,6 +154,104 @@ fn parse_declaration(pair: pest::iterators::Pair) -> Result) -> Result { + let mut inner = pair.into_inner(); + + let abi_pair = inner + .next() + .ok_or_else(|| ParseError::missing("extern ABI string"))?; + let abi = unquote_string_literal(abi_pair.as_str()); + + let mut items = Vec::new(); + for item_pair in inner { + if item_pair.as_rule() == Rule::extern_item { + items.push(parse_extern_item(item_pair)?); + } + } + + Ok(Decl::Extern { abi, items }) +} + +fn parse_extern_item(pair: pest::iterators::Pair) -> Result { + let inner = pair + .into_inner() + .next() + .ok_or_else(|| ParseError::unexpected_end("extern item"))?; + + match inner.as_rule() { + Rule::extern_type_item => { + let name_pair = inner + .into_inner() + .next() + .ok_or_else(|| ParseError::missing("extern type name"))?; + Ok(ExternItem::Type { + name: parse_identifier(name_pair), + }) + } + Rule::extern_fn_item => { + let mut bits = inner.into_inner(); + let name = parse_identifier( + bits.next() + .ok_or_else(|| ParseError::missing("extern fn name"))?, + ); + let mut params: Vec<(SmolStr, Ty)> = Vec::new(); + let mut ret_ty: Option = None; + for sub in bits { + match sub.as_rule() { + Rule::param_list => { + for p in sub.into_inner() { + if p.as_rule() == Rule::param { + let mut parts = p.into_inner(); + let pn = parse_identifier( + parts + .next() + .ok_or_else(|| ParseError::missing("extern param name"))?, + ); + let pt = parse_type( + parts + .next() + .ok_or_else(|| ParseError::missing("extern param type"))?, + )?; + params.push((pn, pt)); + } + } + } + Rule::ty => { + if ret_ty.is_none() { + ret_ty = Some(parse_type(sub)?); + } + } + _ => {} + } + } + Ok(ExternItem::Fn { + name, + params, + ret_ty: ret_ty.unwrap_or(Ty::Base(BaseTy::Unit)), + }) + } + other => Err(ParseError::Syntax { + message: format!("Unexpected extern item: {other:?}"), + span: Span::dummy(), + }), + } +} + +/// Strip the surrounding double quotes (and unescape) a pest `string` +/// literal. The grammar guarantees the input starts and ends with `"`. +fn unquote_string_literal(raw: &str) -> String { + let trimmed = raw.trim_matches('"'); + // Minimal unescape: only `\"` and `\\` are commonly used in ABI + // tags. Extend if more escape forms become relevant. + trimmed.replace("\\\"", "\"").replace("\\\\", "\\") +} + fn parse_fn_decl(pair: pest::iterators::Pair) -> Result { let inner = pair.into_inner(); @@ -1878,6 +1978,34 @@ mod tests { assert_eq!(module.decls.len(), 1); } + /// `extern "abi" { … }` parses through the core parser too, with + /// items lowered straight to core `ExternItem` (types in the + /// `params`/`ret_ty` are `Ty`, not `SurfaceTy`). + #[test] + fn test_parse_extern_block_core() { + let source = "extern \"gossamer\" { + type Window + fn window_open(title: String, body: String): Window + fn window_close(w: Window): () + }"; + let module = parse_module(source, "").expect("should parse"); + assert_eq!(module.decls.len(), 1); + match &module.decls[0] { + Decl::Extern { abi, items } => { + assert_eq!(abi, "gossamer"); + assert_eq!(items.len(), 3); + assert!(matches!(&items[0], ExternItem::Type { name } if name.as_str() == "Window")); + if let ExternItem::Fn { name, params, .. } = &items[1] { + assert_eq!(name.as_str(), "window_open"); + assert_eq!(params.len(), 2); + } else { + panic!("expected ExternItem::Fn, got {:?}", &items[1]); + } + } + other => panic!("expected Decl::Extern, got {other:?}"), + } + } + // ===== New feature parsing tests ===== #[test] diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index e1cad14..d16328d 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -12,7 +12,7 @@ use ephapax_surface::{ BaseTy, BinOp, ConstructorDef, DataDecl, Literal, MatchArm, Pattern, Span, SurfaceDecl, - SurfaceExpr, SurfaceExprKind, SurfaceModule, SurfaceTy, UnaryOp, + SurfaceExpr, SurfaceExprKind, SurfaceExternItem, SurfaceModule, SurfaceTy, UnaryOp, }; use pest::Parser; use smol_str::SmolStr; @@ -116,6 +116,7 @@ fn parse_surface_declaration(pair: pest::iterators::Pair) -> Result parse_data_decl(inner), Rule::fn_decl => parse_fn_decl(inner), Rule::type_decl => parse_type_decl(inner), + Rule::extern_block => parse_extern_block(inner), _ => Err(ParseError::Syntax { message: format!("Unexpected declaration: {:?}", inner.as_rule()), span: span_from_pair(&inner), @@ -123,6 +124,100 @@ fn parse_surface_declaration(pair: pest::iterators::Pair) -> Result) -> Result { + let span = span_from_pair(&pair); + let mut inner = pair.into_inner(); + + let abi_pair = inner + .next() + .ok_or_else(|| ParseError::missing("extern ABI string"))?; + let abi = unquote_string_literal(abi_pair.as_str()); + + let mut items: Vec = Vec::new(); + for item_pair in inner { + if item_pair.as_rule() == Rule::extern_item { + items.push(parse_extern_item(item_pair)?); + } + } + + Ok(SurfaceDecl::Extern { abi, items, span }) +} + +fn parse_extern_item(pair: pest::iterators::Pair) -> Result { + let inner = pair + .into_inner() + .next() + .ok_or_else(|| ParseError::unexpected_end("extern item"))?; + + match inner.as_rule() { + Rule::extern_type_item => { + let name_pair = inner + .into_inner() + .next() + .ok_or_else(|| ParseError::missing("extern type name"))?; + Ok(SurfaceExternItem::Type { + name: parse_identifier(name_pair), + }) + } + Rule::extern_fn_item => { + let mut bits = inner.into_inner(); + let name = parse_identifier( + bits.next() + .ok_or_else(|| ParseError::missing("extern fn name"))?, + ); + let mut params: Vec<(SmolStr, SurfaceTy)> = Vec::new(); + let mut ret_ty: Option = None; + for sub in bits { + match sub.as_rule() { + Rule::param_list => { + for p in sub.into_inner() { + if p.as_rule() == Rule::param { + let mut parts = p.into_inner(); + let pn = parse_identifier( + parts + .next() + .ok_or_else(|| ParseError::missing("extern param name"))?, + ); + let pt = parse_type( + parts + .next() + .ok_or_else(|| ParseError::missing("extern param type"))?, + )?; + params.push((pn, pt)); + } + } + } + Rule::ty => { + if ret_ty.is_none() { + ret_ty = Some(parse_type(sub)?); + } + } + _ => {} + } + } + Ok(SurfaceExternItem::Fn { + name, + params, + ret_ty: ret_ty.unwrap_or(SurfaceTy::Base(BaseTy::Unit)), + }) + } + other => Err(ParseError::Syntax { + message: format!("Unexpected extern item: {other:?}"), + span: Span::dummy(), + }), + } +} + +/// Strip the surrounding double quotes from a pest `string` literal. +fn unquote_string_literal(raw: &str) -> String { + let trimmed = raw.trim_matches('"'); + trimmed.replace("\\\"", "\"").replace("\\\\", "\\") +} + fn parse_data_decl(pair: pest::iterators::Pair) -> Result { let span = span_from_pair(&pair); let mut inner = pair.into_inner(); @@ -1612,6 +1707,75 @@ mod tests { assert_eq!(module.decls.len(), 1); } + /// Empty extern block — must parse, must carry the ABI tag, no items. + #[test] + fn parse_empty_extern_block() { + let source = "extern \"gossamer\" { }\n"; + let module = parse_surface_module(source, "").unwrap(); + assert_eq!(module.decls.len(), 1); + match &module.decls[0] { + SurfaceDecl::Extern { abi, items, .. } => { + assert_eq!(abi, "gossamer"); + assert!(items.is_empty(), "no items expected"); + } + other => panic!("expected SurfaceDecl::Extern, got {other:?}"), + } + } + + /// Extern block with type + fn items — the canonical hypatia + /// `bridge.eph` shape. Asserts both item kinds round-trip into + /// the surface AST. + #[test] + fn parse_extern_block_with_type_and_fn_items() { + let source = "extern \"gossamer\" { + type Window + fn window_open(title: String, body: String): Window + }"; + let module = parse_surface_module(source, "").unwrap(); + match &module.decls[0] { + SurfaceDecl::Extern { abi, items, .. } => { + assert_eq!(abi, "gossamer"); + assert_eq!(items.len(), 2); + match &items[0] { + SurfaceExternItem::Type { name } => { + assert_eq!(name.as_str(), "Window"); + } + other => panic!("expected Type item, got {other:?}"), + } + match &items[1] { + SurfaceExternItem::Fn { name, params, .. } => { + assert_eq!(name.as_str(), "window_open"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].0.as_str(), "title"); + assert_eq!(params[1].0.as_str(), "body"); + } + other => panic!("expected Fn item, got {other:?}"), + } + } + other => panic!("expected SurfaceDecl::Extern, got {other:?}"), + } + } + + /// Full module shape: slash-pathed module + import + extern block + /// + regular fn declaration. Covers the bridge.eph prelude. + #[test] + fn parse_bridge_eph_shaped_prelude() { + let source = "module hypatia/ui/bridge + +extern \"gossamer\" { + type Window + fn window_open(title: String, body: String): Window + fn window_close(w: Window): () +} + +fn entry(): I32 = 0"; + let module = parse_surface_module(source, "").unwrap(); + assert_eq!(module.name.as_str(), "hypatia/ui/bridge"); + assert_eq!(module.decls.len(), 2, "extern block + fn decl"); + assert!(matches!(&module.decls[0], SurfaceDecl::Extern { .. })); + assert!(matches!(&module.decls[1], SurfaceDecl::Fn { .. })); + } + #[test] fn parse_full_module_with_data_and_match() { let source = r#" diff --git a/src/ephapax-surface/src/lib.rs b/src/ephapax-surface/src/lib.rs index 9da33fb..700385c 100644 --- a/src/ephapax-surface/src/lib.rs +++ b/src/ephapax-surface/src/lib.rs @@ -403,6 +403,35 @@ pub enum SurfaceDecl { /// Data type declaration (surface-only) Data(DataDecl), + + /// Foreign function and type declarations: `extern "abi" { ... }`. + /// + /// Surface mirror of the core `Decl::Extern`. The desugar pass + /// lowers this to the core form by mapping each item's `SurfaceTy` + /// fields through `desugar_ty`. + Extern { + abi: String, + items: Vec, + span: Span, + }, +} + +/// A single declaration inside a surface `extern "abi" { ... }` block. +/// +/// Mirror of the core `ExternItem`. Differs only in that types are +/// `SurfaceTy` rather than `Ty`. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SurfaceExternItem { + /// `type Foo` — declares an opaque foreign type. + Type { name: SmolStr }, + /// `fn name(p1: T1, p2: T2): R` — declares a foreign function + /// signature using surface types. + Fn { + name: SmolStr, + params: Vec<(SmolStr, SurfaceTy)>, + ret_ty: SurfaceTy, + }, } /// A complete surface-level module. diff --git a/src/ephapax-syntax/src/lib.rs b/src/ephapax-syntax/src/lib.rs index 90507a2..05cf728 100644 --- a/src/ephapax-syntax/src/lib.rs +++ b/src/ephapax-syntax/src/lib.rs @@ -579,6 +579,38 @@ pub enum Decl { ty: Option, value: Expr, }, + + /// Foreign function and type declarations: `extern "abi" { ... }`. + /// + /// Items inside the block have no body — they declare signatures + /// that resolve to host imports at codegen time. The `abi` string + /// names the linkage target (e.g. `"gossamer"`, `"c"`, `"wasm"`). + /// Extern types are opaque to the type checker (no constructors, + /// no destructors known); extern fns get an ambient binding with + /// the declared type. + Extern { + abi: String, + items: Vec, + }, +} + +/// A single declaration inside an `extern "abi" { ... }` block. +/// +/// Extern items declare signatures only — no bodies. The checker +/// registers them as ambient bindings; codegen lowers fn items to +/// wasm `import` directives and treats type items as opaque externs. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExternItem { + /// `type Foo` — declares an opaque foreign type. + Type { name: Var }, + /// `fn name(p1: T1, p2: T2): R` — declares a foreign function + /// signature. + Fn { + name: Var, + params: Vec<(Var, Ty)>, + ret_ty: Ty, + }, } /// Helper for serde skip_serializing_if. diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index f17cb8a..b088c39 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -1369,6 +1369,12 @@ impl ModuleRegistry { let const_ty = ty.clone().unwrap_or(Ty::Base(BaseTy::Unit)); entries.push((name.clone(), const_ty, Visibility::Private)); } + // TODO(ephapax#43 phase 2B): register extern items as + // ambient bindings — extern types become opaque nominal + // types, extern fns get their declared signature in the + // module env. For phase 2A we accept the declaration + // syntactically but don't expose it through the registry. + Decl::Extern { .. } => {} } } self.modules.insert(module.name.clone(), entries); @@ -1516,6 +1522,12 @@ fn type_check_module_inner( } Decl::Type { .. } => {} Decl::Const { .. } => {} // Constants are handled in module registration + // TODO(ephapax#43 phase 2B): typecheck extern items — + // register extern types as opaque nominal types and extern + // fns with their declared signatures. For phase 2A the + // declaration parses but does not affect the type + // environment. + Decl::Extern { .. } => {} } } diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 123699d..3bf0645 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -598,6 +598,13 @@ impl Codegen { } Decl::Type { .. } => { /* type aliases are erased at runtime */ } Decl::Const { .. } => { /* constants inlined at compile time */ } + // TODO(ephapax#43 phase 2B): emit `(import "" "" + // (func ...))` directives for `Decl::Extern { abi, items }` + // fn items and treat type items as opaque (i32) externs. + // For phase 2A the declaration is accepted by the parser + // and stored in the AST but codegen does not yet emit + // wasm imports for it. + Decl::Extern { .. } => {} } } Ok(()) @@ -870,6 +877,11 @@ impl Codegen { } Decl::Type { .. } => {} Decl::Const { .. } => {} // constants inlined + // TODO(ephapax#43 phase 2B): emit no body for extern fns + // (they're resolved as `(import …)` directives in the + // import section, not via the code section). Until that + // wiring lands, phase 2A just skips them here. + Decl::Extern { .. } => {} } } Ok(()) From dbdcc92e727ac9134e81de827acd9328aa9e9afc Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 15 May 2026 00:03:13 +0100 Subject: [PATCH 3/3] feat(typing,desugar): typecheck extern items (v2 grammar phase 2B-i) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 "" "" (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 --- src/ephapax-desugar/src/lib.rs | 104 ++++++++++++- src/ephapax-typing/src/lib.rs | 264 +++++++++++++++++++++++++++++---- 2 files changed, 337 insertions(+), 31 deletions(-) diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index ae8e3a4..d74315c 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -122,6 +122,15 @@ pub struct DataRegistry { constructors: HashMap, /// Data type name → (params, constructors) types: HashMap, Vec)>, + /// Names declared via `extern "abi" { type Foo }` blocks. + /// + /// Extern types are nominal and opaque: the desugar pass resolves + /// `SurfaceTy::Named { name: "Foo", args: [] }` to `Ty::Var("Foo")` + /// rather than emitting an `UnknownType` error. The type checker + /// then treats them as rigid type variables that only unify with + /// themselves (so values of type `Window` cannot be confused with + /// `IpcChannel`, etc.). + extern_types: std::collections::HashSet, } impl DataRegistry { @@ -151,6 +160,12 @@ impl DataRegistry { ); } + /// Register an opaque extern type name (from + /// `extern "abi" { type Foo }`). + pub fn register_extern_type(&mut self, name: &SmolStr) { + self.extern_types.insert(name.clone()); + } + /// Look up a constructor by name. fn get_ctor(&self, name: &str) -> Option<&ConstructorInfo> { self.constructors.get(name) @@ -160,6 +175,11 @@ impl DataRegistry { fn get_type_ctors(&self, name: &str) -> Option<&(Vec, Vec)> { self.types.get(name) } + + /// Is this name a registered extern (opaque) type? + fn is_extern_type(&self, name: &str) -> bool { + self.extern_types.contains(name) + } } // ========================================================================= @@ -193,10 +213,20 @@ impl Desugarer { /// First pass: collect all data declarations into the registry. /// Second pass: desugar all declarations. pub fn desugar_module(&mut self, module: &SurfaceModule) -> Result { - // First pass: register all data types + // First pass: register all data types and extern types for decl in &module.decls { - if let SurfaceDecl::Data(data) = decl { - self.registry.register(data); + match decl { + SurfaceDecl::Data(data) => { + self.registry.register(data); + } + SurfaceDecl::Extern { items, .. } => { + for item in items { + if let SurfaceExternItem::Type { name } = item { + self.registry.register_extern_type(name); + } + } + } + _ => {} } } @@ -502,7 +532,18 @@ impl Desugarer { /// /// `Option(I32)` → `() + I32` /// `Result(I32, Bool)` → `I32 + Bool` + /// + /// **Extern types** (registered via `extern "abi" { type Foo }`) are + /// nominal and opaque — they resolve to `Ty::Var(name)` regardless + /// of any args. Type arguments on extern names are accepted but + /// ignored: extern types currently have no parameter abstraction + /// (parameterised externs are a phase-2C concern, post wasm + /// codegen). Two distinct extern types `Window` and `IpcChannel` + /// produce two distinct `Ty::Var` rigids that never unify. fn desugar_named_type(&self, name: &SmolStr, args: &[SurfaceTy]) -> Result { + if self.registry.is_extern_type(name.as_str()) { + return Ok(Ty::Var(name.clone())); + } let (params, ctors) = self.registry.get_type_ctors(name.as_str()).ok_or_else(|| { DesugarError::UnknownType { name: name.to_string(), @@ -1614,6 +1655,63 @@ mod tests { } } + /// An extern type declared inside `extern "abi" { type Foo }` + /// becomes a nominal opaque `Ty::Var("Foo")` when referenced from + /// an extern fn's signature. Two distinct extern types produce + /// two distinct `Ty::Var` names so the type checker keeps them + /// apart. + #[test] + fn desugar_extern_types_resolve_to_ty_var() { + let module = SurfaceModule { + name: "test".into(), + decls: vec![SurfaceDecl::Extern { + abi: "gossamer".to_string(), + items: vec![ + SurfaceExternItem::Type { + name: "Window".into(), + }, + SurfaceExternItem::Type { + name: "IpcChannel".into(), + }, + SurfaceExternItem::Fn { + name: "open_window".into(), + params: vec![], + ret_ty: SurfaceTy::Named { + name: "Window".into(), + args: vec![], + }, + }, + SurfaceExternItem::Fn { + name: "open_ipc".into(), + params: vec![], + ret_ty: SurfaceTy::Named { + name: "IpcChannel".into(), + args: vec![], + }, + }, + ], + span: Span::dummy(), + }], + }; + + let core = desugar(&module).unwrap(); + assert_eq!(core.decls.len(), 1); + let Decl::Extern { items, .. } = &core.decls[0] else { + panic!("expected Decl::Extern"); + }; + // The two fn items end up at indexes 2 and 3 (after the two + // type items at 0 and 1). + let ExternItem::Fn { ret_ty: window_ret, .. } = &items[2] else { + panic!("expected ExternItem::Fn at index 2"); + }; + let ExternItem::Fn { ret_ty: ipc_ret, .. } = &items[3] else { + panic!("expected ExternItem::Fn at index 3"); + }; + assert_eq!(*window_ret, Ty::Var("Window".into())); + assert_eq!(*ipc_ret, Ty::Var("IpcChannel".into())); + assert_ne!(window_ret, ipc_ret, "distinct nominal types must not be equal"); + } + /// `SurfaceDecl::Extern` lowers to `Decl::Extern` with item types /// converted from `SurfaceTy` to core `Ty`. Phase 2A: the /// declaration survives desugar intact; downstream typecheck and diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index b088c39..7e98b1c 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -19,8 +19,8 @@ pub mod discipline; use ephapax_syntax::{ - BaseTy, BinOp, Decl, Expr, ExprKind, Literal, Module, RegionName, Span, Ty, UnaryOp, Var, - Visibility, + BaseTy, BinOp, Decl, Expr, ExprKind, ExternItem, Literal, Module, RegionName, Span, Ty, + UnaryOp, Var, Visibility, }; use std::collections::HashMap; use std::fmt; @@ -1369,12 +1369,33 @@ impl ModuleRegistry { let const_ty = ty.clone().unwrap_or(Ty::Base(BaseTy::Unit)); entries.push((name.clone(), const_ty, Visibility::Private)); } - // TODO(ephapax#43 phase 2B): register extern items as - // ambient bindings — extern types become opaque nominal - // types, extern fns get their declared signature in the - // module env. For phase 2A we accept the declaration - // syntactically but don't expose it through the registry. - Decl::Extern { .. } => {} + // Cross-module export of extern fn signatures. Extern + // type items are nominal — they exist in the type + // namespace, not the value namespace, so they have no + // entry here. Extern fns are exposed publicly so a + // dependent module that imports this one sees the same + // signatures the local code sees. Wired in phase 2B-i + // alongside the local type-environment registration in + // `type_check_module_inner`. + Decl::Extern { items, .. } => { + for item in items { + if let ExternItem::Fn { + name, + params, + ret_ty, + } = item + { + let fn_ty = params.iter().rev().fold( + ret_ty.clone(), + |acc, (_, param_ty)| Ty::Fun { + param: Box::new(param_ty.clone()), + ret: Box::new(acc), + }, + ); + entries.push((name.clone(), fn_ty, Visibility::Public)); + } + } + } } } self.modules.insert(module.name.clone(), entries); @@ -1457,28 +1478,62 @@ fn type_check_module_inner( tc: &mut TypeChecker, module: &Module, ) -> Result<(), SpannedTypeError> { - // First pass: collect all function signatures + // First pass: collect all function signatures (regular + extern) for decl in &module.decls { - if let Decl::Fn { - name, - params, - ret_ty, - type_params, - .. - } = decl - { - let fn_ty = params - .iter() - .rev() - .fold(ret_ty.clone(), |acc, (_, param_ty)| Ty::Fun { - param: Box::new(param_ty.clone()), - ret: Box::new(acc), + match decl { + Decl::Fn { + name, + params, + ret_ty, + type_params, + .. + } => { + let fn_ty = + params + .iter() + .rev() + .fold(ret_ty.clone(), |acc, (_, param_ty)| Ty::Fun { + param: Box::new(param_ty.clone()), + ret: Box::new(acc), + }); + let poly_ty = type_params.iter().rev().fold(fn_ty, |acc, tv| Ty::ForAll { + var: tv.clone(), + body: Box::new(acc), }); - let poly_ty = type_params.iter().rev().fold(fn_ty, |acc, tv| Ty::ForAll { - var: tv.clone(), - body: Box::new(acc), - }); - tc.ctx.extend(name.clone(), poly_ty, BindingForm::Let); + tc.ctx.extend(name.clone(), poly_ty, BindingForm::Let); + } + // Extern fn items are registered as ambient bindings with + // their declared signatures so regular fn bodies that call + // them type-check. Extern type items are nominal (resolved + // by the desugar pass to `Ty::Var`) and need no binding + // entry here — they exist in the type namespace, not the + // value namespace. + // + // Phase 2B-ii will wire wasm codegen to emit `(import …)` + // directives for each extern fn item and route call sites + // to the import index. Until then the binding type-checks + // but the generated wasm has no actual import — the + // declaration is observed but not yet honoured by codegen. + Decl::Extern { items, .. } => { + for item in items { + if let ExternItem::Fn { + name, + params, + ret_ty, + } = item + { + let fn_ty = params.iter().rev().fold( + ret_ty.clone(), + |acc, (_, param_ty)| Ty::Fun { + param: Box::new(param_ty.clone()), + ret: Box::new(acc), + }, + ); + tc.ctx.extend(name.clone(), fn_ty, BindingForm::Let); + } + } + } + _ => {} } } @@ -2605,4 +2660,157 @@ mod tests { type_check_module_with_registry(&lib_module, &mut registry).unwrap(); type_check_module_with_registry(&main_module, &mut registry).unwrap(); } + + // ========================================================================= + // Extern blocks (#43 phase 2B-i) + // ========================================================================= + // + // These tests cover the typechecker's handling of `extern "abi" { + // ... }` declarations: extern types are registered as nominal + // opaque types (resolved by the desugar pass to `Ty::Var(name)`), + // and extern fn signatures are added to the type environment so + // regular fn bodies can call them. + + /// A module with an extern block + a regular fn that returns an + /// extern fn's value type-checks successfully. The extern fn is + /// nullary (`open_handle(): Window`) so its type is just + /// `Ty::Var("Window")` after the signature fold — referencing it + /// directly in the body should produce that opaque type. + #[test] + fn typecheck_extern_nullary_fn_callable() { + // extern "gossamer" { fn open_handle(): Window } + // fn entry(): Window = open_handle + let module = Module { + name: "test".into(), + imports: vec![], + decls: vec![ + Decl::Extern { + abi: "gossamer".to_string(), + items: vec![ExternItem::Fn { + name: "open_handle".into(), + params: vec![], + ret_ty: Ty::Var("Window".into()), + }], + }, + Decl::Fn { + name: "entry".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Var("Window".into()), + body: Expr::dummy(ExprKind::Var("open_handle".into())), + }, + ], + }; + + type_check_module(&module).expect("extern reference should type-check"); + } + + /// A unary extern fn called with the correct argument type-checks; + /// the result flows back through the regular fn's return. + #[test] + fn typecheck_extern_unary_fn_applied() { + // extern "gossamer" { fn open(h: I32): Window } + // fn entry(): Window = open(7) + let module = Module { + name: "test".into(), + imports: vec![], + decls: vec![ + Decl::Extern { + abi: "gossamer".to_string(), + items: vec![ExternItem::Fn { + name: "open".into(), + params: vec![("h".into(), Ty::Base(BaseTy::I32))], + ret_ty: Ty::Var("Window".into()), + }], + }, + Decl::Fn { + name: "entry".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Var("Window".into()), + body: Expr::dummy(ExprKind::App { + func: Box::new(Expr::dummy(ExprKind::Var("open".into()))), + arg: Box::new(Expr::dummy(ExprKind::Lit(Literal::I32(7)))), + }), + }, + ], + }; + + type_check_module(&module).expect("unary extern call should type-check"); + } + + /// Calling an extern fn with the wrong argument type is rejected. + /// Here the extern signature is `(I32) -> Window`, but the call + /// passes a `Bool` — must surface as a typing error rather than + /// silently succeed. + #[test] + fn typecheck_extern_fn_arg_mismatch_rejected() { + let module = Module { + name: "test".into(), + imports: vec![], + decls: vec![ + Decl::Extern { + abi: "gossamer".to_string(), + items: vec![ExternItem::Fn { + name: "open".into(), + params: vec![("h".into(), Ty::Base(BaseTy::I32))], + ret_ty: Ty::Var("Window".into()), + }], + }, + Decl::Fn { + name: "entry".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Var("Window".into()), + body: Expr::dummy(ExprKind::App { + func: Box::new(Expr::dummy(ExprKind::Var("open".into()))), + arg: Box::new(Expr::dummy(ExprKind::Lit(Literal::Bool(true)))), + }), + }, + ], + }; + + assert!( + type_check_module(&module).is_err(), + "passing Bool to a fn expecting I32 must fail" + ); + } + + /// Two distinct extern types are nominal — they do not unify with + /// each other or with base types. `Window` and `IpcChannel` are + /// both `Ty::Var(name)` rigid types from the type checker's view; + /// declaring `entry: Window` but returning `IpcChannel` must fail. + #[test] + fn typecheck_distinct_extern_types_do_not_unify() { + let module = Module { + name: "test".into(), + imports: vec![], + decls: vec![ + Decl::Extern { + abi: "gossamer".to_string(), + items: vec![ExternItem::Fn { + name: "open_ipc".into(), + params: vec![], + ret_ty: Ty::Var("IpcChannel".into()), + }], + }, + Decl::Fn { + name: "entry".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Var("Window".into()), + body: Expr::dummy(ExprKind::Var("open_ipc".into())), + }, + ], + }; + + assert!( + type_check_module(&module).is_err(), + "Ty::Var(\"IpcChannel\") must not unify with Ty::Var(\"Window\")" + ); + } }