From fa2317727079fc1acf21eaafbda58a648f89b3df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:39:24 +0000 Subject: [PATCH 1/3] feat(generics): generic functions, checked once and monomorphised (E7) v2 plan rung E7, the generic-function part (step S2b). - The surface parser and the desugarer keep the type parameters of `fn id(x: a): a`. They dropped them, so every generic function was a type error. - The checker checks a generic body one time, with each type parameter as an opaque type, and records the type arguments of every use. It then checks the body again with each type parameter as a linear type: a value of such a type cannot be used two times or left unused (`fn dup(x: a) = (x, x)` is `LinearVariableReused`). - Two new named errors: `CannotInferTypeArgument` (nothing in a call fixes a type parameter) and `PolymorphicRecursion` (a generic function reaches itself with type arguments that are not its own type parameters). - `ephapax_typing::monomorphise` makes one copy of a generic function for each set of type arguments (`id`, `id`) and removes the generic definition. `Program::into_merged` runs it, so codegen and the interpreter see monomorphic functions only. It now returns a `Result`; `Program::merge` is the merge alone. - `check_program` also validates the monomorphic copies, so `check` refuses what `compile` refuses at a wide type. Gate: tests/generics.rs. `id` at I32, I64 and F64 with two inputs each returns its input as wasm and in the interpreter; a generic function of another module; the four refused programs; one copy for each set of type arguments. Probe `gen_angle` is at its target on check, compile and run. A checker test that accepted `fn const_fn(x: A, y: B): A = x` now expects `LinearVariableNotConsumed`: the plan makes a type parameter linear. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 +- src/ephapax-cli/src/main.rs | 4 +- src/ephapax-cli/tests/generics.rs | 172 ++++++++ src/ephapax-cli/tests/typed_codegen_matrix.rs | 1 + src/ephapax-desugar/src/lib.rs | 6 +- src/ephapax-driver/src/lib.rs | 68 ++- src/ephapax-parser/src/surface.rs | 9 + src/ephapax-query/src/lib.rs | 8 +- src/ephapax-repl/src/lib.rs | 8 +- src/ephapax-surface/src/lib.rs | 3 + src/ephapax-syntax/src/lib.rs | 65 +++ src/ephapax-typing/src/lib.rs | 410 +++++++++++++----- src/ephapax-typing/src/mono.rs | 190 ++++++++ tests/probes/expectations.tsv | 8 +- 14 files changed, 829 insertions(+), 129 deletions(-) create mode 100644 src/ephapax-cli/tests/generics.rs create mode 100644 src/ephapax-typing/src/mono.rs diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 26777843..29bb8d21 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *600 tests* (`+cargo test --workspace --all-targets+`); +* Total: *604 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 600 +* Documented all-target tests: 604 === CRG Testing Taxonomy — Status @@ -83,7 +83,7 @@ eliminator-fork invariant + its gated capstone, per PROOF-NEEDS.adoc §4) ==== Build & Execution * [x] cargo build — compiles -* [x] cargo test –workspace –all-targets — 600 pass, 0 fail +* [x] cargo test –workspace –all-targets — 604 pass, 0 fail * [x] idris2/ephapax-parse-tests.ipkg — compiles AND runs green (2026-07-07, idris2 0.8.0 + zig-built libephapax_tokbuf.so); CI-gated compile-only by abi-verify.yml `+idris2-parse-front+` (runtime run stays diff --git a/src/ephapax-cli/src/main.rs b/src/ephapax-cli/src/main.rs index 14a1983f..2cb55fa4 100644 --- a/src/ephapax-cli/src/main.rs +++ b/src/ephapax-cli/src/main.rs @@ -332,7 +332,7 @@ fn run_file( } if name == "main" => Some((params.len(), ret_ty.clone())), _ => None, }); - let module = program.into_merged(); + let module = program.into_merged().map_err(|e| report_load_error(&e))?; let Some((n_params, ret_ty)) = main else { if verbose { println!("{} Module loaded (no main function)", "✓".green()); @@ -516,7 +516,7 @@ fn compile_file( } finish_compile( - program.into_merged(), + program.into_merged().map_err(|e| report_load_error(&e))?, &filename, path, output, diff --git a/src/ephapax-cli/tests/generics.rs b/src/ephapax-cli/tests/generics.rs new file mode 100644 index 00000000..38e9d50b --- /dev/null +++ b/src/ephapax-cli/tests/generics.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// generics.rs, v2 plan rung E7 gate (generic functions). +// +// A generic function is checked one time, with each type parameter as an +// opaque linear type. The program then gets one monomorphic copy of the +// function for each set of type arguments it is used with, and the +// generic definition is removed: codegen and the interpreter see +// monomorphic functions only. + +use std::path::Path; +use std::process::{Command, Output}; + +use ephapax_syntax::Decl; + +fn ephapax(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(dir) + .args(args) + .output() + .expect("spawn ephapax") +} + +fn write(dir: &Path, name: &str, text: &str) { + std::fs::write(dir.join(name), text).expect("write program"); +} + +/// `id` used at three types, with two inputs for each: every copy +/// returns its input, as wasm and in the interpreter. +#[test] +fn a_generic_function_runs_at_each_type_it_is_used_with() { + let tmp = tempfile::tempdir().expect("tempdir"); + for (ty, input, want) in [ + ("I32", "3", "3"), + ("I32", "41", "41"), + ("I64", "5000000000", "5000000000i64"), + ("I64", "6000000000", "6000000000i64"), + ("F64", "2.5", "2.5"), + ("F64", "0.25", "0.25"), + ] { + write( + tmp.path(), + "g.eph", + &format!("module g\n\nfn id(x: a): a = x\n\nfn main(): {ty} = id({input})\n"), + ); + for args in [&["run", "g.eph"][..], &["run", "--interp", "g.eph"]] { + let out = ephapax(tmp.path(), args); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + want, + "id({input}) at {ty} under {args:?}:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + } + } + + // A generic function of another module, and one that calls another + // generic function. + write( + tmp.path(), + "lib.eph", + "module lib\n\npub fn id(x: a): a = x\n\npub fn twice(x: a): a = id(id(x))\n", + ); + write( + tmp.path(), + "app.eph", + "module app\n\nimport lib\n\nfn main(): F64 = twice(1.5)\n", + ); + for args in [&["run", "app.eph"][..], &["run", "--interp", "app.eph"]] { + let out = ephapax(tmp.path(), args); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "1.5", + "{args:?}" + ); + } +} + +/// The merged program has one monomorphic function for each set of type +/// arguments, and no generic function. +#[test] +fn a_program_has_one_copy_for_each_set_of_type_arguments() { + let tmp = tempfile::tempdir().expect("tempdir"); + write( + tmp.path(), + "two.eph", + "module two\n\nfn id(x: a): a = x\n\nfn main(): I64 =\n let n = id(7)\n id(6000000000)\n", + ); + let merged = ephapax_driver::load_program(&tmp.path().join("two.eph"), None) + .expect("loads") + .into_merged() + .expect("merges"); + let mut functions = Vec::new(); + for decl in &merged.decls { + if let Decl::Fn { + name, type_params, .. + } = decl + { + assert!(type_params.is_empty(), "`{name}` is still generic"); + functions.push(name.as_str()); + } + } + assert_eq!(functions, ["main", "id", "id"]); +} + +/// What the checker refuses in a generic program, each with a named +/// `TypeError`, on `check` and on `compile`. +#[test] +fn generic_programs_the_checker_refuses() { + let tmp = tempfile::tempdir().expect("tempdir"); + // (program after the module header, text of the error) + for (program, error) in [ + // A value of a type-parameter type can be linear in a use of the + // function: no second use, and no discard. + ( + "fn dup(x: a): (a, a) = (x, x)\n\nfn main(): I32 = fst(dup(3))\n", + "Linear variable `x` used more than once", + ), + ( + "fn k(x: a): I32 = 0\n\nfn main(): I32 = k(3)\n", + "Linear variable `x` not consumed", + ), + // Each call needs its own copy of `f`: copies without end. + ( + "fn f(x: a): I32 = f((x, 1))\n\nfn main(): I32 = f(3)\n", + "Polymorphic recursion", + ), + // Nothing in the call fixes `a`. + ( + "fn zero(): I32 = 0\n\nfn main(): I32 = zero()\n", + "Cannot infer a type argument of the generic function `zero`", + ), + ] { + write(tmp.path(), "bad.eph", &format!("module bad\n\n{program}")); + for args in [&["check", "bad.eph"][..], &["compile", "bad.eph"]] { + let out = ephapax(tmp.path(), args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains("Type error") && stderr.contains(error), + "{args:?} on:\n{program}\nexit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } + } +} + +/// A limit of the typed lowering that only a copy at a wide type shows +/// (here a lambda with an F64 parameter) is a load error on `check` too. +#[test] +fn check_sees_the_limits_of_the_monomorphic_copies() { + let tmp = tempfile::tempdir().expect("tempdir"); + let program = |ty: &str, input: &str| { + format!( + "module lam\n\nfn apply(x: a): a =\n let f = fn(y: a) -> y\n f(x)\n\n\ + fn main(): {ty} = apply({input})\n" + ) + }; + write(tmp.path(), "lam.eph", &program("I32", "3")); + assert!(ephapax(tmp.path(), &["check", "lam.eph"]).status.success()); + + write(tmp.path(), "lam.eph", &program("F64", "2.5")); + for args in [&["check", "lam.eph"][..], &["compile", "lam.eph"]] { + let out = ephapax(tmp.path(), args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains("Codegen error") && stderr.contains("lambda"), + "{args:?}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } +} diff --git a/src/ephapax-cli/tests/typed_codegen_matrix.rs b/src/ephapax-cli/tests/typed_codegen_matrix.rs index fcc0231d..c82dfd4c 100644 --- a/src/ephapax-cli/tests/typed_codegen_matrix.rs +++ b/src/ephapax-cli/tests/typed_codegen_matrix.rs @@ -275,6 +275,7 @@ fn load(src: &str) -> ephapax_syntax::Module { ephapax_driver::load_source(src, "matrix", None, stdlib.as_deref()) .unwrap_or_else(|e| panic!("the matrix module must load: {e}")) .into_merged() + .expect("the matrix module merges") } fn linker(engine: &Engine) -> Linker<()> { diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 05deaf09..30bc4a43 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -320,6 +320,7 @@ impl Desugarer { SurfaceDecl::Fn { name, visibility, + type_params, params, ret_ty, body, @@ -333,7 +334,7 @@ impl Desugarer { core_decls.push(Decl::Fn { name: name.clone(), visibility: lower_visibility(*visibility), - type_params: vec![], + type_params: type_params.clone(), params: core_params, ret_ty: core_ret, body: core_body, @@ -1795,6 +1796,7 @@ mod tests { SurfaceDecl::Fn { name: "unwrap_or".into(), visibility: SurfaceVisibility::Private, + type_params: vec![], params: vec![ ( "opt".into(), @@ -1879,6 +1881,7 @@ mod tests { SurfaceDecl::Fn { name: "test".into(), visibility: SurfaceVisibility::Private, + type_params: vec![], params: vec![( "opt".into(), SurfaceTy::Named { @@ -1944,6 +1947,7 @@ mod tests { SurfaceDecl::Fn { name: "test".into(), visibility: SurfaceVisibility::Private, + type_params: vec![], params: vec![( "opt".into(), SurfaceTy::Named { diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index ce9c768e..fa9d00e1 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -55,14 +55,35 @@ impl Program { .expect("a program has at least its root module") } - /// Merge every imported module into the root, for codegen and the - /// interpreter. Wasm produces a single binary; each imported module's - /// `Fn` declarations become part of the same wasm module. When two - /// modules declare a function of the same name, one of them gets a - /// module-qualified name (`deep::helper`) and its callers follow it; - /// the root keeps its names. An extern item that an earlier module - /// already declared is dropped (first-seen wins, the root first). - pub fn into_merged(self) -> Module { + /// The whole program as one monomorphic module, for codegen and the + /// interpreter: [`Self::merge`], then one copy of each generic + /// function for each set of type arguments it is used with (v2 plan + /// E7). The error is a type error of the merged program; a program + /// that passed [`check_program`] has none. + pub fn into_merged(self) -> Result { + let root = self.root(); + let (module, path, source_text) = ( + root.core.name.to_string(), + root.file_path.clone(), + root.source.clone(), + ); + ephapax_typing::monomorphise(&self.merge()).map_err(|error| DriverError::Type { + module, + path, + source_text, + error: Box::new(error), + }) + } + + /// Merge every imported module into the root. Wasm produces a single + /// binary; each imported module's `Fn` declarations become part of + /// the same wasm module. When two modules declare a function of the + /// same name, one of them gets a module-qualified name + /// (`deep::helper`) and its callers follow it; the root keeps its + /// names. An extern item that an earlier module already declared is + /// dropped (first-seen wins, the root first). A generic function is + /// still generic: [`Self::into_merged`] is the form for a back end. + pub fn merge(self) -> Module { // Dedup keys: `fn:` for a function, `extern::` for // an extern item (each extern fn is one wasm import, so the same // item from two modules must be emitted once). Extern blocks are @@ -295,6 +316,33 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { } })?; } + // A generic function is compiled as its monomorphic copies, so the + // copies get the same check: a copy at a wide type can meet a limit + // that the generic body does not show. + let generic = + |decl: &Decl| matches!(decl, Decl::Fn { type_params, .. } if !type_params.is_empty()); + if program + .modules + .iter() + .any(|m| m.core.decls.iter().any(generic)) + { + let root = program.root(); + let merged = program.clone().into_merged()?; + let types = module_expr_types(&merged, &mut ModuleRegistry::new()).map_err(|error| { + DriverError::Type { + module: root.core.name.to_string(), + path: root.file_path.clone(), + source_text: root.source.clone(), + error: Box::new(error), + } + })?; + ephapax_wasm::validate_supported_typed(&merged, &types).map_err(|error| { + DriverError::Unsupported { + path: root.file_path.clone(), + error, + } + })?; + } Ok(()) } @@ -354,7 +402,7 @@ mod tests { fn main(): I32 = only_app() + lib.only_lib() + shared()\n", ); let program = load_program(&dir.path().join("app.eph"), None).expect("loads"); - let merged = program.into_merged(); + let merged = program.into_merged().expect("merges"); // `shared` once (the root's), and each module's own item. assert_eq!(extern_fn_names(&merged), ["only_app", "only_lib", "shared"]); ephapax_wasm::compile_module(&merged).expect("the merged module compiles"); @@ -400,7 +448,7 @@ mod tests { write("deep.eph", &deep(deep_fn)); write("app.eph", &app("fn helper(n: I32): I32 = n + 7")); - let merged = load().expect("loads").into_merged(); + let merged = load().expect("loads").into_merged().expect("merges"); let names: Vec<&str> = merged .decls .iter() diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index f50ff931..53b81fc1 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -315,6 +315,7 @@ fn parse_fn_decl(pair: pest::iterators::Pair) -> Result = None; + let mut type_params = Vec::new(); let mut params = Vec::new(); let mut ret_ty = None; let mut body = None; @@ -327,6 +328,13 @@ fn parse_fn_decl(pair: pest::iterators::Pair) -> Result { name = Some(parse_identifier(item)); } + Rule::fn_type_params => { + type_params.extend( + item.into_inner() + .filter(|p| p.as_rule() == Rule::identifier) + .map(parse_identifier), + ); + } Rule::param_list => { for param in item.into_inner() { if param.as_rule() == Rule::param { @@ -360,6 +368,7 @@ fn parse_fn_decl(pair: pest::iterators::Pair) -> Result { - compile_module(&program.clone().into_merged()).map_err(|e| e.0) - } + (Ok(program), Ok(())) => program + .clone() + .into_merged() + .map_err(|e| format!("cannot compile: {e}")) + .and_then(|module| compile_module(&module).map_err(|e| e.0)), (Err(e), _) | (_, Err(e)) => Err(format!("cannot compile: {e}")), }; self.recomputes += 1; diff --git a/src/ephapax-repl/src/lib.rs b/src/ephapax-repl/src/lib.rs index 06a7cbc9..db711002 100644 --- a/src/ephapax-repl/src/lib.rs +++ b/src/ephapax-repl/src/lib.rs @@ -359,7 +359,9 @@ impl Repl { fn load_declarations(&mut self, source: &str) -> Result<(), DriverError> { let stdlib = ephapax_driver::stdlib_dir(None); let program = ephapax_driver::parse_source(source, "repl", None, stdlib.as_deref())?; - self.interpreter.load_module(&program.into_merged()); + // Not monomorphised (that needs a type check): the interpreter + // runs a generic function as it is. + self.interpreter.load_module(&program.merge()); Ok(()) } @@ -367,7 +369,9 @@ impl Repl { &mut self, program: Result, ) -> Result { - let module = program.map_err(ReplError::Load)?.into_merged(); + let module = program + .and_then(ephapax_driver::Program::into_merged) + .map_err(ReplError::Load)?; self.interpreter.load_module(&module); Ok(module.decls.len()) } diff --git a/src/ephapax-surface/src/lib.rs b/src/ephapax-surface/src/lib.rs index f71e0d18..2f9aa8d6 100644 --- a/src/ephapax-surface/src/lib.rs +++ b/src/ephapax-surface/src/lib.rs @@ -414,6 +414,9 @@ pub enum SurfaceDecl { name: Var, #[serde(default, skip_serializing_if = "SurfaceVisibility::is_private")] visibility: SurfaceVisibility, + /// `fn id(...)`: the type parameters, in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + type_params: Vec, params: Vec<(Var, SurfaceTy)>, ret_ty: SurfaceTy, body: SurfaceExpr, diff --git a/src/ephapax-syntax/src/lib.rs b/src/ephapax-syntax/src/lib.rs index 02664260..2e5a4e39 100644 --- a/src/ephapax-syntax/src/lib.rs +++ b/src/ephapax-syntax/src/lib.rs @@ -659,6 +659,71 @@ impl Expr { pub fn dummy(kind: ExprKind) -> Self { Self::new(kind, Span::dummy()) } + + /// Give this node a new id. A copy of a tree that will get its own + /// entries in a table keyed by [`ExprId`] needs this on every node. + pub fn renew_id(&mut self) { + self.id = ExprId::fresh(); + } + + /// The direct sub-expressions, for a pass that rewrites a tree. + pub fn children_mut(&mut self) -> Vec<&mut Expr> { + // No `_` arm: a new expression form must list its children. + let children: Vec<&mut Expr> = match &mut self.kind { + ExprKind::Lit(_) | ExprKind::Var(_) | ExprKind::StringNew { .. } => vec![], + ExprKind::StringConcat { left, right } + | ExprKind::Pair { left, right } + | ExprKind::BinOp { left, right, .. } => vec![left, right], + ExprKind::App { func, arg } => vec![func, arg], + ExprKind::ListIndex { list, index } => vec![list, index], + ExprKind::StringLen(e) + | ExprKind::Fst(e) + | ExprKind::Snd(e) + | ExprKind::Deref(e) + | ExprKind::Drop(e) + | ExprKind::Copy(e) => vec![e], + ExprKind::Inl { value: e, .. } + | ExprKind::Inr { value: e, .. } + | ExprKind::Lambda { body: e, .. } + | ExprKind::Region { body: e, .. } + | ExprKind::Borrow { inner: e, .. } + | ExprKind::UnaryOp { operand: e, .. } + | ExprKind::TupleIndex { tuple: e, .. } => vec![e], + ExprKind::Let { value, body, .. } | ExprKind::LetLin { value, body, .. } => { + vec![value, body] + } + ExprKind::Case { + scrutinee, + left_body, + right_body, + .. + } => vec![scrutinee, left_body, right_body], + ExprKind::Match { scrutinee, arms } => { + let mut children: Vec<&mut Expr> = vec![scrutinee]; + for arm in arms { + children.extend(arm.guard.as_deref_mut()); + children.push(&mut arm.body); + } + children + } + ExprKind::If { + cond, + then_branch, + else_branch, + } => vec![cond, then_branch, else_branch], + ExprKind::Block(exprs) + | ExprKind::ListLit(exprs) + | ExprKind::TupleLit(exprs) + | ExprKind::FFI { args: exprs, .. } + | ExprKind::Perform { args: exprs, .. } => exprs.iter_mut().collect(), + ExprKind::Handle { body, clauses } => { + let mut children: Vec<&mut Expr> = vec![body]; + children.extend(clauses.iter_mut().map(|c| &mut c.body)); + children + } + }; + children + } } /// A single arm in an `ExprKind::Match` expression (added ephapax#61). diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 80b58672..3f95b248 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -18,6 +18,9 @@ //! See [`discipline`] module for the complete rules reference. pub mod discipline; +mod mono; + +pub use mono::monomorphise; use ephapax_syntax::{ BaseTy, BinOp, Decl, Expr, ExprId, ExprKind, ExternItem, Literal, MatchArm, Module, Pattern, @@ -124,6 +127,20 @@ pub enum TypeError { #[error("Parameter `{0}` is declared more than once in the same function")] DuplicateParameter(Var), + /// A call of a generic function leaves one of its type parameters + /// with no type (nothing in the call fixes it). + #[error("Cannot infer a type argument of the generic function `{0}` at this call")] + CannotInferTypeArgument(Var), + + /// A generic function reaches itself with other type arguments than + /// its own type parameters. Each generic function becomes one copy + /// for each set of type arguments, and this needs copies without end. + #[error( + "Polymorphic recursion: the generic function `{0}` reaches itself with \ + type arguments that are not its own type parameters" + )] + PolymorphicRecursion(Var), + /// A projection (`t.i` or `xs[i]`) would consume the container and /// return one component, discarding unselected linear components. #[error( @@ -864,10 +881,33 @@ fn is_useful( /// instructions and layouts (v2 plan rung E5). pub type ExprTypes = HashMap; +/// For each use of a generic function (the [`ExprId`] of the variable +/// node), its type arguments in the order of the type parameters. Inside +/// a generic function they can name its own type parameters. +pub type TypeArgs = HashMap>; + +/// A use of a generic function in the function body being checked. +#[derive(Clone)] +struct Instantiation { + id: ExprId, + span: Span, + callee: Var, + /// Fresh unification variables until the body is checked. + args: Vec, +} + +#[derive(Clone)] pub struct TypeChecker { ctx: Context, /// Type of each checked expression; see [`TypeChecker::expr_types`]. expr_types: ExprTypes, + /// Uses of generic functions in the body being checked. + instantiations: Vec, + /// The resolved type arguments of every use; see [`TypeArgs`]. + type_args: TypeArgs, + /// (generic caller, generic callee, type arguments, span of the call): + /// the input of the polymorphic-recursion check. + generic_calls: Vec<(Var, Var, Vec, Span)>, /// Next fresh unification variable ID. next_unif: u32, wildcard_obligations: Vec<(Span, Ty)>, @@ -885,6 +925,9 @@ impl TypeChecker { Self { ctx: Context::new(), expr_types: ExprTypes::new(), + instantiations: Vec::new(), + type_args: TypeArgs::new(), + generic_calls: Vec::new(), next_unif: 0, wildcard_obligations: Vec::new(), unif_solutions: HashMap::new(), @@ -912,16 +955,16 @@ impl TypeChecker { } /// Instantiate a ForAll type by replacing bound variables with fresh - /// unification variables. Non-ForAll types pass through unchanged. - fn instantiate(&mut self, ty: Ty) -> Ty { - match ty { - Ty::ForAll { var, body } => { - let fresh = self.fresh_unif(); - let instantiated = body.subst_var(&var, &fresh); - self.instantiate(instantiated) - } - other => other, + /// unification variables, which are also returned, outermost first. + /// Non-ForAll types pass through unchanged. + fn instantiate(&mut self, mut ty: Ty) -> (Ty, Vec) { + let mut args = Vec::new(); + while let Ty::ForAll { var, body } = ty { + let fresh = self.fresh_unif(); + ty = body.subst_var(&var, &fresh); + args.push(fresh); } + (ty, args) } /// Resolve a type by chasing unification variable solutions. @@ -1092,7 +1135,7 @@ impl TypeChecker { let s = expr.span; match &expr.kind { ExprKind::Lit(lit) => self.check_lit(s, lit), - ExprKind::Var(name) => self.check_var(s, name), + ExprKind::Var(name) => self.check_var(s, expr.id, name), ExprKind::StringNew { region, .. } => self.check_string_new(s, region), ExprKind::StringConcat { left, right } => self.check_string_concat(s, left, right), ExprKind::Let { @@ -1164,7 +1207,7 @@ impl TypeChecker { }) } - fn check_var(&mut self, s: Span, name: &Var) -> Result { + fn check_var(&mut self, s: Span, id: ExprId, name: &Var) -> Result { let ty = self .ctx .lookup(name) @@ -1175,7 +1218,16 @@ impl TypeChecker { // Instantiate ForAll types with fresh unification variables. // E.g. `identity : forall T. T -> T` becomes `?0 -> ?0`. - Ok(self.instantiate(ty)) + let (ty, args) = self.instantiate(ty); + if !args.is_empty() { + self.instantiations.push(Instantiation { + id, + span: s, + callee: name.clone(), + args, + }); + } + Ok(ty) } fn check_string_new(&self, s: Span, region: &RegionName) -> Result { @@ -2550,6 +2602,15 @@ pub fn module_expr_types( module: &Module, registry: &mut ModuleRegistry, ) -> Result { + module_types_and_args(module, registry).map(|(types, _)| types) +} + +/// [`module_expr_types`], and the type arguments of every use of a +/// generic function ([`TypeArgs`]). +pub fn module_types_and_args( + module: &Module, + registry: &mut ModuleRegistry, +) -> Result<(ExprTypes, TypeArgs), SpannedTypeError> { let mut tc = TypeChecker::new(); // Resolve imports: bring imported names into scope @@ -2584,7 +2645,7 @@ pub fn module_expr_types( // Register this module's declarations for dependents registry.register(module); - Ok(tc.expr_types()) + Ok((tc.expr_types(), tc.type_args)) } /// Internal module checking logic shared by single-module and registry paths. @@ -2673,69 +2734,183 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), // Second pass: type check each function body for decl in &module.decls { - match decl { - Decl::Fn { - name: _, - type_params: _, - visibility: _, - params, - ret_ty, - body, - } => { - let saved_ctx = tc.ctx.clone(); - let saved_unif = tc.next_unif; - - // A repeated parameter name would shadow the earlier - // parameter for the whole body, so a linear earlier - // parameter could never be consumed or checked. - let mut seen_params: HashSet<&Var> = HashSet::new(); - for (param_name, _) in params { - if !seen_params.insert(param_name) { - return Err(SpannedTypeError { - error: TypeError::DuplicateParameter(param_name.clone()), - span: body.span, - }); - } - } + let Decl::Fn { + name, + type_params, + params, + ret_ty, + body, + .. + } = decl + else { + // A constant is handled in module registration. An extern + // signature is registered in the first pass, and an extern + // type is opaque. Data constructors go through + // `DataCtorRegistry`. None of them has a body to check here. + continue; + }; + check_fn(tc, name, type_params, params, ret_ty, body)?; + if type_params.is_empty() { + continue; + } + // A type parameter can stand for a linear type. The check above + // took each type parameter as an opaque type, so the body uses + // such a value only in ways that every type allows. Check the + // body one more time with each type parameter as a linear type: + // now only the linear discipline can fail (a value used two times, + // or not consumed). Until grades exist (E13), an unconstrained + // type parameter is linear (v2 plan E7). + let linear = type_params + .iter() + .map(|p| (p.clone(), Ty::String("_".into()))) + .collect(); + let mut copy = decl.clone(); + mono::substitute_fn(&mut copy, &linear); + if let Decl::Fn { + params, + ret_ty, + body, + .. + } = © + { + // On a scratch checker: this check records nothing. + check_fn(&mut tc.clone(), name, &[], params, ret_ty, body)?; + } + } + check_polymorphic_recursion(tc, module) +} - for (param_name, param_ty) in params { - tc.ctx - .extend(param_name.clone(), param_ty.clone(), BindingForm::Param); - } +/// Check the body of one function against its signature. +fn check_fn( + tc: &mut TypeChecker, + name: &Var, + type_params: &[SmolStr], + params: &[(Var, Ty)], + ret_ty: &Ty, + body: &Expr, +) -> Result<(), SpannedTypeError> { + let saved_ctx = tc.ctx.clone(); + let saved_unif = tc.next_unif; + + // A repeated parameter name would shadow the earlier + // parameter for the whole body, so a linear earlier + // parameter could never be consumed or checked. + let mut seen_params: HashSet<&Var> = HashSet::new(); + for (param_name, _) in params { + if !seen_params.insert(param_name) { + return Err(SpannedTypeError { + error: TypeError::DuplicateParameter(param_name.clone()), + span: body.span, + }); + } + } - let body_ty = tc.check(body)?; + for (param_name, param_ty) in params { + tc.ctx + .extend(param_name.clone(), param_ty.clone(), BindingForm::Param); + } - tc.unify(body.span, ret_ty, &body_ty)?; + let body_ty = tc.check(body)?; - for (param_name, _param_ty) in params { - if let Some(entry) = tc.ctx.vars.get(param_name) { - if entry.demands_consumption() && !entry.used { - return Err(SpannedTypeError { - error: TypeError::LinearVariableNotConsumed(param_name.clone()), - span: body.span, - }); - } - } - } + tc.unify(body.span, ret_ty, &body_ty)?; - tc.ctx = saved_ctx; - tc.next_unif = saved_unif; - tc.unif_solutions.clear(); - tc.wildcard_obligations.clear(); + for (param_name, _param_ty) in params { + if let Some(entry) = tc.ctx.vars.get(param_name) { + if entry.demands_consumption() && !entry.used { + return Err(SpannedTypeError { + error: TypeError::LinearVariableNotConsumed(param_name.clone()), + span: body.span, + }); } - Decl::Type { .. } => {} - Decl::Const { .. } => {} // Constants are handled in module registration - // Extern fn signatures are registered earlier in the - // signature-collection pass; the body-checking pass has - // nothing more to do. Extern types are opaque. - Decl::Extern { .. } => {} - // Data ctor signatures flow through `DataCtorRegistry` - // (populated pre-pass); pattern-checking dispatches via the - // registry, so there is no per-decl body to walk here. - Decl::Data { .. } => {} } } + // The body is checked, so the type arguments of each use of a generic + // function in it are known. Inside a generic function they can name + // its type parameters. + for use_ in std::mem::take(&mut tc.instantiations) { + let args: Vec = use_.args.iter().map(|t| tc.resolve(t)).collect(); + if args.iter().any(has_unif) { + return Err(SpannedTypeError { + error: TypeError::CannotInferTypeArgument(use_.callee), + span: use_.span, + }); + } + if !type_params.is_empty() { + tc.generic_calls + .push((name.clone(), use_.callee, args.clone(), use_.span)); + } + tc.type_args.insert(use_.id, args); + } + + tc.ctx = saved_ctx; + tc.next_unif = saved_unif; + tc.unif_solutions.clear(); + tc.wildcard_obligations.clear(); + Ok(()) +} + +/// True when `ty` has a unification variable with no solution. +fn has_unif(ty: &Ty) -> bool { + match ty { + Ty::Unif(_) => true, + Ty::Base(_) | Ty::String(_) | Ty::Var(_) => false, + Ty::Ref { inner, .. } + | Ty::Region { inner, .. } + | Ty::Borrow { inner, .. } + | Ty::ForAll { body: inner, .. } + | Ty::List(inner) => has_unif(inner), + Ty::Fun { param, ret } | Ty::Effectful { param, ret, .. } => { + has_unif(param) || has_unif(ret) + } + Ty::Prod { left, right } | Ty::Sum { left, right } => has_unif(left) || has_unif(right), + Ty::Tuple(elems) => elems.iter().any(has_unif), + } +} + +/// Reject a generic function that reaches itself, through calls between +/// generic functions of this module, with a type argument that is not one +/// of the type parameters of the caller. Monomorphisation makes one copy +/// of a generic function for each set of type arguments; with this rule +/// every such cycle uses a finite set of types, so the copies end. +/// (A cycle cannot leave the module: imports have no cycle.) +fn check_polymorphic_recursion(tc: &TypeChecker, module: &Module) -> Result<(), SpannedTypeError> { + let generic: HashMap<&Var, &Vec> = module + .decls + .iter() + .filter_map(|decl| match decl { + Decl::Fn { + name, type_params, .. + } if !type_params.is_empty() => Some((name, type_params)), + _ => None, + }) + .collect(); + let calls: Vec<_> = tc + .generic_calls + .iter() + .filter(|(_, callee, ..)| generic.contains_key(callee)) + .collect(); + let reaches = |from: &Var, to: &Var| { + let (mut stack, mut seen) = (vec![from], HashSet::new()); + while let Some(f) = stack.pop() { + if f == to { + return true; + } + if seen.insert(f) { + stack.extend(calls.iter().filter(|c| &c.0 == f).map(|c| &c.1)); + } + } + false + }; + for (caller, callee, args, span) in &calls { + let own = |arg: &Ty| matches!(arg, Ty::Var(v) if generic[caller].contains(v)); + if !args.iter().all(own) && reaches(callee, caller) { + return Err(SpannedTypeError { + error: TypeError::PolymorphicRecursion(caller.clone()), + span: *span, + }); + } + } Ok(()) } @@ -3541,45 +3716,72 @@ mod tests { #[test] fn test_generic_two_params() { - // fn const_fn(x: A, y: B) : A = x - // fn main() : I32 = const_fn(42, true) - let module = Module { + // fn pair(x: A, y: B) : (A, B) = (x, y) + // fn main() : I32 = fst(pair(42, true)) + let generic = |name: &str, ret_ty: Ty, body: Expr| Decl::Fn { + name: name.into(), + visibility: Visibility::Private, + type_params: vec!["A".into(), "B".into()], + params: vec![ + ("x".into(), Ty::Var("A".into())), + ("y".into(), Ty::Var("B".into())), + ], + ret_ty, + body, + }; + let var = |name: &str| dummy_expr(ExprKind::Var(name.into())); + // callee(42)(true) — curried application + let call = |callee: &str| { + dummy_expr(ExprKind::App { + func: Box::new(dummy_expr(ExprKind::App { + func: Box::new(var(callee)), + arg: Box::new(dummy_expr(ExprKind::Lit(Literal::I32(42)))), + })), + arg: Box::new(dummy_expr(ExprKind::Lit(Literal::Bool(true)))), + }) + }; + let main = |body: Expr| Decl::Fn { + name: "main".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Base(BaseTy::I32), + body, + }; + let module = |decls| Module { name: "test".into(), imports: vec![], - decls: vec![ - Decl::Fn { - name: "const_fn".into(), - visibility: Visibility::Private, - type_params: vec!["A".into(), "B".into()], - params: vec![ - ("x".into(), Ty::Var("A".into())), - ("y".into(), Ty::Var("B".into())), - ], - ret_ty: Ty::Var("A".into()), - // const_fn is curried: fn(x: A) -> fn(y: B) -> x - // But our module checker expects multi-param. - // Actually module checker expects the body to be checked - // with all params in scope, so just return x. - body: dummy_expr(ExprKind::Var("x".into())), - }, - Decl::Fn { - name: "main".into(), - visibility: Visibility::Private, - type_params: vec![], - params: vec![], - ret_ty: Ty::Base(BaseTy::I32), - // const_fn(42)(true) — curried application - body: dummy_expr(ExprKind::App { - func: Box::new(dummy_expr(ExprKind::App { - func: Box::new(dummy_expr(ExprKind::Var("const_fn".into()))), - arg: Box::new(dummy_expr(ExprKind::Lit(Literal::I32(42)))), - })), - arg: Box::new(dummy_expr(ExprKind::Lit(Literal::Bool(true)))), - }), - }, - ], + decls, }; - assert!(type_check_module(&module).is_ok()); + + let pair_ty = Ty::Prod { + left: Box::new(Ty::Var("A".into())), + right: Box::new(Ty::Var("B".into())), + }; + let pair_body = dummy_expr(ExprKind::Pair { + left: Box::new(var("x")), + right: Box::new(var("y")), + }); + let ok = module(vec![ + generic("pair", pair_ty, pair_body), + main(dummy_expr(ExprKind::Fst(Box::new(call("pair"))))), + ]); + assert!(type_check_module(&ok).is_ok()); + + // fn const_fn(x: A, y: B) : A = x + // `y` can have a linear type in a use of `const_fn`, so the body + // must consume it (v2 plan E7: a type parameter is linear). + let discards = module(vec![ + generic("const_fn", Ty::Var("A".into()), var("x")), + main(call("const_fn")), + ]); + assert!(matches!( + type_check_module(&discards), + Err(SpannedTypeError { + error: TypeError::LinearVariableNotConsumed(name), + .. + }) if name == "y" + )); } #[test] @@ -3671,7 +3873,7 @@ mod tests { ret: Box::new(Ty::Var("T".into())), }), }; - let instantiated = tc.instantiate(forall_ty); + let (instantiated, _) = tc.instantiate(forall_ty); // Should be Fun { param: Unif(0), ret: Unif(0) } match &instantiated { diff --git a/src/ephapax-typing/src/mono.rs b/src/ephapax-typing/src/mono.rs new file mode 100644 index 00000000..f8f1010f --- /dev/null +++ b/src/ephapax-typing/src/mono.rs @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +//! Monomorphisation of generic functions (v2 plan rung E7, step S2b). +//! +//! The checker checks a generic function one time, with its type +//! parameters as opaque types, and records the type arguments of every +//! use ([`TypeArgs`]). [`monomorphise`] then makes one copy of the +//! function for each set of type arguments and removes the generic +//! definition. Code generation and the interpreter see monomorphic +//! functions only. + +use std::collections::{HashMap, HashSet}; + +use ephapax_syntax::{Decl, Expr, ExprKind, Module, Ty, Var}; +use smol_str::SmolStr; + +use crate::{module_types_and_args, subst_tys, ModuleRegistry, SpannedTypeError, TypeArgs}; + +/// Type variable → type. +type Subst = HashMap; + +/// Replace type variables in the signature of the function `decl` and in +/// the type annotations of its body. +pub(crate) fn substitute_fn(decl: &mut Decl, subst: &Subst) { + let Decl::Fn { + params, + ret_ty, + body, + .. + } = decl + else { + return; + }; + for (_, ty) in params.iter_mut() { + *ty = subst_tys(ty, subst); + } + *ret_ty = subst_tys(ret_ty, subst); + substitute_expr(body, subst); +} + +fn substitute_expr(expr: &mut Expr, subst: &Subst) { + substitute_node(expr, subst); + for child in expr.children_mut() { + substitute_expr(child, subst); + } +} + +/// The type annotations of one node. +fn substitute_node(expr: &mut Expr, subst: &Subst) { + match &mut expr.kind { + ExprKind::Let { ty: Some(ty), .. } + | ExprKind::LetLin { ty: Some(ty), .. } + | ExprKind::Lambda { param_ty: ty, .. } + | ExprKind::Inl { ty, .. } + | ExprKind::Inr { ty, .. } => *ty = subst_tys(ty, subst), + _ => {} + } +} + +/// `module` with one monomorphic copy of each generic function for each +/// set of type arguments it is used with, and no generic function. +/// +/// `module` must be a whole program (the imports already merged in): it +/// is checked on its own. A program with no generic function is returned +/// as it is, without a check. +pub fn monomorphise(module: &Module) -> Result { + let generic: HashMap<&Var, &Decl> = module + .decls + .iter() + .filter_map(|decl| match decl { + Decl::Fn { + name, type_params, .. + } if !type_params.is_empty() => Some((name, decl)), + _ => None, + }) + .collect(); + if generic.is_empty() { + return Ok(module.clone()); + } + let (_, type_args) = module_types_and_args(module, &mut ModuleRegistry::new())?; + let mut mono = Mono { + generic, + type_args, + made: HashSet::new(), + instances: Vec::new(), + }; + + let mut decls = Vec::new(); + for decl in &module.decls { + match decl { + Decl::Fn { type_params, .. } if !type_params.is_empty() => {} + Decl::Fn { .. } => { + let mut decl = decl.clone(); + if let Decl::Fn { body, .. } = &mut decl { + mono.rewrite(body, &Subst::new()); + } + decls.push(decl); + } + other => decls.push(other.clone()), + } + } + decls.extend(mono.instances); + Ok(Module { + name: module.name.clone(), + imports: module.imports.clone(), + decls, + }) +} + +struct Mono<'a> { + generic: HashMap<&'a Var, &'a Decl>, + type_args: TypeArgs, + /// Names of the copies made (or in work) so far. + made: HashSet, + instances: Vec, +} + +impl Mono<'_> { + /// Point each use of a generic function in `expr` at the copy for its + /// type arguments. `subst` is not empty when `expr` is the body of + /// such a copy: it gives the type parameters of the copied function. + fn rewrite(&mut self, expr: &mut Expr, subst: &Subst) { + // The table has the id that the node had when it was checked. + let args = self.type_args.get(&expr.id).cloned(); + if let (ExprKind::Var(name), Some(args)) = (&mut expr.kind, args) { + let args = args.iter().map(|t| subst_tys(t, subst)).collect(); + *name = self.instance(name, args); + } + if !subst.is_empty() { + substitute_node(expr, subst); + // The copy gets its own entries in the next type table. + expr.renew_id(); + } + for child in expr.children_mut() { + self.rewrite(child, subst); + } + } + + /// The name of the copy of the generic function `name` for `args`; + /// the copy is made at the first request. + fn instance(&mut self, name: &Var, args: Vec) -> Var { + let Some(&generic) = self.generic.get(name) else { + return name.clone(); + }; + let rendered: Vec = args.iter().map(ty_name).collect(); + let copy_name: Var = format!("{name}<{}>", rendered.join(", ")).into(); + // Recorded before the body is rewritten, so a recursive call + // finds the copy in work. + if !self.made.insert(copy_name.clone()) { + return copy_name; + } + let mut copy = generic.clone(); + if let Decl::Fn { + name, + type_params, + params, + ret_ty, + body, + .. + } = &mut copy + { + let subst: Subst = std::mem::take(type_params).into_iter().zip(args).collect(); + *name = copy_name.clone(); + for (_, ty) in params.iter_mut() { + *ty = subst_tys(ty, &subst); + } + *ret_ty = subst_tys(ret_ty, &subst); + self.rewrite(body, &subst); + } + self.instances.push(copy); + copy_name + } +} + +/// A short name of a type, for the name of a copy. Two types have the +/// same name only when they are equal. +fn ty_name(ty: &Ty) -> String { + let list = |tys: &[Ty]| tys.iter().map(ty_name).collect::>().join(", "); + match ty { + Ty::Base(base) => format!("{base:?}"), + Ty::String(region) => format!("String@{region}"), + Ty::Var(name) => name.to_string(), + Ty::Prod { left, right } => format!("({} * {})", ty_name(left), ty_name(right)), + Ty::Sum { left, right } => format!("({} + {})", ty_name(left), ty_name(right)), + Ty::Tuple(elems) => format!("({})", list(elems)), + Ty::List(elem) => format!("[{}]", ty_name(elem)), + other => format!("{other:?}"), + } +} diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv index d0c83b5c..caa1dc4f 100644 --- a/tests/probes/expectations.tsv +++ b/tests/probes/expectations.tsv @@ -50,10 +50,10 @@ perform wasm-run no-module no-module E1 g5 check error:TypeError error:* E1: raw variadic __ffi rejected with a named error (kind name set by E1) g5 compile error:TypeError error:* E1: named raw-FFI error, not an invalid module g5 wasm-run no-module no-module E1 -gen_angle check error:TypeError ok E7 generics: id instantiated at I32 -gen_angle compile error:TypeError ok E7 -gen_angle run error:TypeError ok:3 E7 (since E3, run loads through ephapax-driver and rejects what check rejects; before E3 the interpreter path printed 3) -gen_angle wasm-run no-module ok:3 E7 +gen_angle check ok ok E7 generics: id instantiated at I32 +gen_angle compile ok ok E7 +gen_angle run ok:3 ok:3 E7 +gen_angle wasm-run ok:3 ok:3 E7 (baseline recorded from `ephapax run`, same engine; no wasmtime CLI in that session) qa/app2 check error:DesugarError ok E7: qualified M.Ctor(x) across modules qa/app2 compile error:DesugarError ok E7 qa/app2 run error:DesugarError ok:9 E7 + E3 (run goes through ephapax-driver::load_program) From 83390d0f781870fc3c6f6dcd89120830a427f6eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:53:37 +0000 Subject: [PATCH 2/3] refactor(driver): the rename pass uses the shared child traversal `Expr::children_mut` (new in the generics commit) repeated the child traversal of `rename_expr` in `qualify.rs`, and SonarCloud counted it as duplicated new code (4.0%, limit 3%). `rename_expr` now handles the names of a node and takes its children from `Expr::children_mut`. Its match still has no `_` arm. No change in behaviour. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-driver/src/qualify.rs | 99 ++++++++++++------------------- 1 file changed, 37 insertions(+), 62 deletions(-) diff --git a/src/ephapax-driver/src/qualify.rs b/src/ephapax-driver/src/qualify.rs index 3bf2b419..677458f8 100644 --- a/src/ephapax-driver/src/qualify.rs +++ b/src/ephapax-driver/src/qualify.rs @@ -143,83 +143,58 @@ fn rename(name: &mut Var, scope: &Renames) { /// local `let helper = ...` is renamed together with its uses, so it /// shadows the renamed function as it shadowed the function before. fn rename_expr(expr: &mut Expr, scope: &Renames) { - // No `_` arm: a new expression form must say where its names are. - let children: Vec<&mut Expr> = match &mut expr.kind { - ExprKind::Lit(_) | ExprKind::StringNew { .. } => vec![], - ExprKind::Var(name) => { - rename(name, scope); - vec![] - } - ExprKind::StringConcat { left, right } - | ExprKind::Pair { left, right } - | ExprKind::BinOp { left, right, .. } => vec![left, right], - ExprKind::App { func, arg } => vec![func, arg], - ExprKind::ListIndex { list, index } => vec![list, index], - ExprKind::StringLen(e) - | ExprKind::Fst(e) - | ExprKind::Snd(e) - | ExprKind::Deref(e) - | ExprKind::Drop(e) - | ExprKind::Copy(e) => vec![e], - ExprKind::Inl { value: e, .. } - | ExprKind::Inr { value: e, .. } - | ExprKind::Region { body: e, .. } - | ExprKind::Borrow { inner: e, .. } - | ExprKind::UnaryOp { operand: e, .. } - | ExprKind::TupleIndex { tuple: e, .. } => vec![e], - ExprKind::Let { - name, value, body, .. - } - | ExprKind::LetLin { - name, value, body, .. - } => { - rename(name, scope); - vec![value, body] - } - ExprKind::Lambda { param, body, .. } => { - rename(param, scope); - vec![body] - } + // The names of this node: a use or a binder. No `_` arm: a new + // expression form must say if it has a name. + match &mut expr.kind { + ExprKind::Var(name) + | ExprKind::Let { name, .. } + | ExprKind::LetLin { name, .. } + | ExprKind::Lambda { param: name, .. } => rename(name, scope), ExprKind::Case { - scrutinee, left_var, - left_body, right_var, - right_body, + .. } => { rename(left_var, scope); rename(right_var, scope); - vec![scrutinee, left_body, right_body] } - ExprKind::Match { scrutinee, arms } => { - let mut children: Vec<&mut Expr> = vec![scrutinee]; + ExprKind::Match { arms, .. } => { for arm in arms { rename_pattern(&mut arm.pattern, scope); - children.extend(arm.guard.as_deref_mut()); - children.push(&mut arm.body); } - children } - ExprKind::If { - cond, - then_branch, - else_branch, - } => vec![cond, then_branch, else_branch], - ExprKind::Block(exprs) - | ExprKind::ListLit(exprs) - | ExprKind::TupleLit(exprs) - | ExprKind::FFI { args: exprs, .. } - | ExprKind::Perform { args: exprs, .. } => exprs.iter_mut().collect(), - ExprKind::Handle { body, clauses } => { - let mut children: Vec<&mut Expr> = vec![body]; + ExprKind::Handle { clauses, .. } => { for clause in clauses { clause.params.iter_mut().for_each(|p| rename(p, scope)); - children.push(&mut clause.body); } - children } - }; - for child in children { + ExprKind::Lit(_) + | ExprKind::StringNew { .. } + | ExprKind::StringConcat { .. } + | ExprKind::StringLen(_) + | ExprKind::App { .. } + | ExprKind::Pair { .. } + | ExprKind::Fst(_) + | ExprKind::Snd(_) + | ExprKind::Inl { .. } + | ExprKind::Inr { .. } + | ExprKind::If { .. } + | ExprKind::Region { .. } + | ExprKind::Borrow { .. } + | ExprKind::Deref(_) + | ExprKind::Drop(_) + | ExprKind::Copy(_) + | ExprKind::Block(_) + | ExprKind::FFI { .. } + | ExprKind::Perform { .. } + | ExprKind::BinOp { .. } + | ExprKind::UnaryOp { .. } + | ExprKind::ListLit(_) + | ExprKind::ListIndex { .. } + | ExprKind::TupleLit(_) + | ExprKind::TupleIndex { .. } => {} + } + for child in expr.children_mut() { rename_expr(child, scope); } } From 4aa4d3000082bde721aa98adc759e17fa5c4610b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:07:55 +0000 Subject: [PATCH 3/3] fix(generics): upper-case type parameters, generic main, REPL load Three findings from the Codex review of the generics change, each checked against the code. - `fn identity(x: T): T` was `unknown type T` in the desugarer: an upper-case name is a named type, and the type parameters of the function were not in scope. They are now, for the signature and the body, and they shadow any other meaning of the name. - `fn main(): I32 = 0` passed `check`; monomorphisation then removed `main`, because no call gives it type arguments, and `run` found no entry point. It is now `TypeError::GenericMain`. - `:load` of a file with a generic function the file does not use removed that function, so a call from the prompt failed. The REPL loads a checked file without monomorphisation, as it loads a prompt declaration; the interpreter runs a generic function as it is. Tests: the gate file has the upper-case and the generic-main cases; one REPL test. TEST-NEEDS count 605. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 +++--- src/ephapax-cli/tests/generics.rs | 17 +++++++++++++++++ src/ephapax-desugar/src/lib.rs | 20 ++++++++++++++++++++ src/ephapax-repl/src/lib.rs | 21 ++++++++++++++++++--- src/ephapax-typing/src/lib.rs | 11 +++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 29bb8d21..c286b6ec 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *604 tests* (`+cargo test --workspace --all-targets+`); +* Total: *605 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 604 +* Documented all-target tests: 605 === CRG Testing Taxonomy — Status @@ -83,7 +83,7 @@ eliminator-fork invariant + its gated capstone, per PROOF-NEEDS.adoc §4) ==== Build & Execution * [x] cargo build — compiles -* [x] cargo test –workspace –all-targets — 604 pass, 0 fail +* [x] cargo test –workspace –all-targets — 605 pass, 0 fail * [x] idris2/ephapax-parse-tests.ipkg — compiles AND runs green (2026-07-07, idris2 0.8.0 + zig-built libephapax_tokbuf.so); CI-gated compile-only by abi-verify.yml `+idris2-parse-front+` (runtime run stays diff --git a/src/ephapax-cli/tests/generics.rs b/src/ephapax-cli/tests/generics.rs index 38e9d50b..e0cbfbcb 100644 --- a/src/ephapax-cli/tests/generics.rs +++ b/src/ephapax-cli/tests/generics.rs @@ -55,6 +55,18 @@ fn a_generic_function_runs_at_each_type_it_is_used_with() { } } + // An upper-case type parameter, also in an annotation of the body. + write( + tmp.path(), + "upper.eph", + "module upper\n\nfn apply(x: T): T =\n let f = fn(y: T) -> y\n f(x)\n\n\ + fn main(): I32 = apply(3)\n", + ); + for args in [&["run", "upper.eph"][..], &["run", "--interp", "upper.eph"]] { + let out = ephapax(tmp.path(), args); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "3", "{args:?}"); + } + // A generic function of another module, and one that calls another // generic function. write( @@ -131,6 +143,11 @@ fn generic_programs_the_checker_refuses() { "fn zero(): I32 = 0\n\nfn main(): I32 = zero()\n", "Cannot infer a type argument of the generic function `zero`", ), + // No call gives the entry point its type arguments. + ( + "fn main(): I32 = 0\n", + "`main` is the entry point of a program and cannot have type parameters", + ), ] { write(tmp.path(), "bad.eph", &format!("module bad\n\n{program}")); for args in [&["check", "bad.eph"][..], &["compile", "bad.eph"]] { diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 30bc4a43..f4700277 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -258,6 +258,10 @@ pub struct Desugarer { /// Named types currently being expanded by `desugar_named_type`, /// innermost last. Re-entering one is a recursive type. expanding: RefCell>, + /// The type parameters of the function being desugared. In its + /// signature and body such a name is a type variable, also when it + /// starts with an upper-case letter (`fn id(x: T): T`). + fn_type_params: Vec, } impl Desugarer { @@ -267,6 +271,7 @@ impl Desugarer { registry: DataRegistry::new(), module_qualifiers: HashSet::new(), expanding: RefCell::new(Vec::new()), + fn_type_params: Vec::new(), } } @@ -276,6 +281,7 @@ impl Desugarer { registry, module_qualifiers: HashSet::new(), expanding: RefCell::new(Vec::new()), + fn_type_params: Vec::new(), } } @@ -325,12 +331,14 @@ impl Desugarer { ret_ty, body, } => { + self.fn_type_params.clone_from(type_params); let core_params: Vec<_> = params .iter() .map(|(n, t)| Ok((n.clone(), self.desugar_ty(t)?))) .collect::>()?; let core_ret = self.desugar_ty(ret_ty)?; let core_body = self.desugar_expr(body)?; + self.fn_type_params.clear(); core_decls.push(Decl::Fn { name: name.clone(), visibility: lower_visibility(*visibility), @@ -685,6 +693,18 @@ impl Desugarer { /// 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 { + // A type parameter of the enclosing function shadows every other + // meaning of the name. + if self.fn_type_params.contains(name) { + if !args.is_empty() { + return Err(DesugarError::TypeArityMismatch { + name: name.to_string(), + expected: 0, + got: args.len(), + }); + } + return Ok(Ty::Var(name.clone())); + } // Built-in type aliases that aren't (yet) keywords in the lexer. // `Unit` is the type-position spelling of the literal `()`; // bridge.eph and other ML-adjacent corpora write it freely. diff --git a/src/ephapax-repl/src/lib.rs b/src/ephapax-repl/src/lib.rs index db711002..d1580f23 100644 --- a/src/ephapax-repl/src/lib.rs +++ b/src/ephapax-repl/src/lib.rs @@ -369,9 +369,10 @@ impl Repl { &mut self, program: Result, ) -> Result { - let module = program - .and_then(ephapax_driver::Program::into_merged) - .map_err(ReplError::Load)?; + // The driver checked the program. It is loaded without + // monomorphisation, as a prompt declaration is: a generic function + // of the file stays callable from the prompt by its own name. + let module = program.map_err(ReplError::Load)?.merge(); self.interpreter.load_module(&module); Ok(module.decls.len()) } @@ -499,6 +500,20 @@ mod tests { assert!(matches!(result, Value::I32(42))); } + /// A generic function of a loaded file is callable from the prompt, + /// also when the file itself does not use it. + #[test] + fn loaded_generic_function_is_callable_from_the_prompt() { + let mut repl = Repl::new().expect("REPL should initialize"); + repl.load_module("fn id(x: a): a = x\n", "lib") + .expect("the module loads"); + let result = repl + .interpreter + .call("id", vec![Value::I32(7)]) + .expect("id runs"); + assert!(matches!(result, Value::I32(7))); + } + #[test] fn test_eval_if() { let mut repl = Repl::new().expect("REPL should initialize"); diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 3f95b248..4b6dc6f8 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -141,6 +141,11 @@ pub enum TypeError { )] PolymorphicRecursion(Var), + /// `main` is the entry point: no call gives it type arguments, so no + /// monomorphic copy of it would exist. + #[error("`main` is the entry point of a program and cannot have type parameters")] + GenericMain, + /// A projection (`t.i` or `xs[i]`) would consume the container and /// return one component, discarding unselected linear components. #[error( @@ -2753,6 +2758,12 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), if type_params.is_empty() { continue; } + if name == "main" { + return Err(SpannedTypeError { + error: TypeError::GenericMain, + span: body.span, + }); + } // A type parameter can stand for a linear type. The check above // took each type parameter as an opaque type, so the body uses // such a value only in ways that every type allows. Check the