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..d74315c 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; @@ -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); + } + } + } + _ => {} } } @@ -236,6 +266,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, + }); + } } } @@ -469,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(), @@ -1023,7 +1097,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 +1654,109 @@ 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 + /// 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 f53c5d6..eac9c98 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 @@ -30,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 } @@ -366,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" @@ -376,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 7d3437c..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(); @@ -1857,6 +1957,55 @@ 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); + } + + /// `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 bcc9a4d..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; @@ -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, }) } @@ -107,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), @@ -114,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(); @@ -1572,6 +1676,106 @@ 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); + } + + /// 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..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,6 +1369,33 @@ impl ModuleRegistry { let const_ty = ty.clone().unwrap_or(Ty::Base(BaseTy::Unit)); entries.push((name.clone(), const_ty, Visibility::Private)); } + // 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); @@ -1451,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); + } + } + } + _ => {} } } @@ -1516,6 +1577,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 { .. } => {} } } @@ -2593,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\")" + ); + } } 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(())