From 504d9e3ae9096d2a99535d103661791125bf691b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:19:13 +0000 Subject: [PATCH 1/2] feat(desugar): a qualified constructor takes its arguments (E7) `M.Ctor(x)` parses as a call of the member `M.Ctor`. The desugarer read the member alone as a constructor with no arguments, so a constructor with fields was an arity error ("expects 1 arguments, got 0"). The desugarer now reads the full call: when the head of a call is `M.Ctor` and `M` is an imported module, the arguments of the call are the arguments of the constructor. `M.Ctor()` is the constructor with no arguments, as `Ctor()` is. An unknown qualifier and a wrong number of arguments keep their errors. The qualifier still resolves unqualified, as `M.f` does. Gate: the probe `qa/app2` checks, compiles and runs (9), as wasm and in the interpreter. Tests: 606 all-target. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 ++-- src/ephapax-cli/tests/run_wasm.rs | 53 +++++++++++++++++++++++++++++++ src/ephapax-desugar/src/lib.rs | 53 ++++++++++++++++++++++++++++--- tests/probes/expectations.tsv | 8 ++--- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index c286b6e..0340ee8 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: *605 tests* (`+cargo test --workspace --all-targets+`); +* Total: *606 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 605 +* Documented all-target tests: 606 === 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 — 605 pass, 0 fail +* [x] cargo test –workspace –all-targets — 606 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/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index cf4f846..0c5704a 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -211,6 +211,59 @@ fn each_module_calls_its_own_function_of_a_shared_name() { assert_eq!(stdout_of(tmp.path(), &["run", "param.eph"]), "1000"); } +/// `M.Ctor(args)` builds a value of a data type of the imported module +/// `M`, with 0, 1 or 2 fields. The desugarer used to read `M.Ctor` as a +/// constructor with no arguments, then refuse the arguments. +#[test] +fn a_qualified_constructor_takes_its_arguments() { + let tmp = tempfile::tempdir().expect("tempdir"); + let write = + |name: &str, text: &str| std::fs::write(tmp.path().join(name), text).expect("write module"); + write( + "shapes.eph", + "module shapes\n\npub data Shape = Dot | Seg(I32) | Rect(I32, I32)\n\n\ + pub fn area(s: Shape): I32 =\n \ + match s of | Dot => 1 | Seg(n) => n | Rect(w, h) => w * h end\n", + ); + for (shape, want) in [ + ("shapes.Dot", "1"), + ("shapes.Dot()", "1"), + ("shapes.Seg(5)", "5"), + ("shapes.Seg(7)", "7"), + ("shapes.Rect(3, 4)", "12"), + ("shapes.Rect(4, 5)", "20"), + ] { + write( + "app.eph", + &format!("module app\n\nimport shapes\n\nfn main(): I32 = shapes.area({shape})\n"), + ); + for args in [&["run", "app.eph"][..], &["run", "--interp", "app.eph"]] { + assert_eq!(stdout_of(tmp.path(), args), want, "{shape} under {args:?}"); + } + } + + // (call, text of the load error) + for (shape, error) in [ + ( + "shapes.Seg(5, 6)", + "constructor `Seg` expects 1 arguments, got 2", + ), + ("other.Seg(5)", "`other` is not an imported module"), + ] { + write( + "bad.eph", + &format!("module bad\n\nimport shapes\n\nfn main(): I32 = shapes.area({shape})\n"), + ); + let out = ephapax(tmp.path(), &["compile", "bad.eph"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains(error), + "{shape}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } +} + /// A pair pattern binds each component at its own type: a destructuring /// `let`, and a constructor with several fields (a right-nested pair). /// Patterns used to read every component as an i32. diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index f470027..74a4a71 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -470,10 +470,15 @@ impl Desugarer { body: Box::new(self.desugar_expr(body)?), }, - SurfaceExprKind::App { func, arg } => ExprKind::App { - func: Box::new(self.desugar_expr(func)?), - arg: Box::new(self.desugar_expr(arg)?), - }, + SurfaceExprKind::App { func, arg } => { + if let Some((ctor, args)) = self.qualified_ctor_call(expr) { + return self.desugar_construct(ctor, &args, span); + } + ExprKind::App { + func: Box::new(self.desugar_expr(func)?), + arg: Box::new(self.desugar_expr(arg)?), + } + } SurfaceExprKind::Pair { left, right } => ExprKind::Pair { left: Box::new(self.desugar_expr(left)?), @@ -597,6 +602,46 @@ impl Desugarer { Ok(Expr::new(kind, span)) } + /// The constructor and the arguments of `M.Ctor(a, b)`, if `expr` is + /// such a call on an imported module. The parser gives the call as + /// `App(App(M.Ctor, a), b)`, and `M.Ctor()` as `App(M.Ctor, ())`. + fn qualified_ctor_call<'e>( + &self, + expr: &'e SurfaceExpr, + ) -> Option<(&'e SmolStr, Vec)> { + let mut args = Vec::new(); + let mut head = expr; + while let SurfaceExprKind::App { func, arg } = &head.kind { + args.push(arg.as_ref().clone()); + head = func; + } + let SurfaceExprKind::FieldAccess { base, field } = &head.kind else { + return None; + }; + let imported = + module_qualifier_of(base).is_some_and(|q| self.module_qualifiers.contains(&q)); + if !imported || !field.starts_with(|c: char| c.is_ascii_uppercase()) { + return None; + } + args.reverse(); + let nullary = self + .registry + .get_ctor(field.as_str()) + .is_some_and(|info| info.fields.is_empty()); + if nullary + && matches!( + args[..], + [SurfaceExpr { + kind: SurfaceExprKind::Lit(Literal::Unit), + .. + }] + ) + { + args.clear(); + } + Some((field, args)) + } + /// Desugar qualified module-member access `M.member`. /// /// `M` must be an imported module (matched by the last segment of its diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv index caa1dc4..6bddeb9 100644 --- a/tests/probes/expectations.tsv +++ b/tests/probes/expectations.tsv @@ -54,10 +54,10 @@ 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) -qa/app2 wasm-run no-module ok:9 E7 +qa/app2 check ok ok E7: qualified M.Ctor(x) across modules +qa/app2 compile ok ok E7 +qa/app2 run ok:9 ok:9 E7 + E3 (run goes through ephapax-driver::load_program) +qa/app2 wasm-run ok:9 ok:9 E7 (baseline recorded from `ephapax run`, same engine; no wasmtime CLI in that session) p1 check ok ok already accepted p1 compile ok ok already compiles (959 B in the survey) p1 run ok:162 ok:162 E3/E4: ephapax run executes the wasm and prints 162 From 0966fdc079f300fed7ab8d2560998a0ce0b33ccb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:22:04 +0000 Subject: [PATCH 2/2] fix(driver): only the main of the root module is an entry point The type checker refused a generic `main` in each module. A `main` of an imported module is not an entry point: `run` starts the `main` of the root, and an imported `main` without type parameters was already a function like any other. A program that imported a module with a generic `main` was a type error. The driver now makes this check, on the root module only. The error is the same `TypeError::GenericMain`. Review finding on #429. Tests: 607 all-target. 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 | 37 +++++++++++++++++++++++++++++++ src/ephapax-driver/src/lib.rs | 27 ++++++++++++++++++++-- src/ephapax-typing/src/lib.rs | 6 ----- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 0340ee8..12964ee 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: *606 tests* (`+cargo test --workspace --all-targets+`); +* Total: *607 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 606 +* Documented all-target tests: 607 === 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 — 606 pass, 0 fail +* [x] cargo test –workspace –all-targets — 607 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 e0cbfbc..e5bbca3 100644 --- a/src/ephapax-cli/tests/generics.rs +++ b/src/ephapax-cli/tests/generics.rs @@ -162,6 +162,43 @@ fn generic_programs_the_checker_refuses() { } } +/// Only the `main` of the root module is the entry point. A generic +/// `main` of an imported module is a function like any other; the same +/// module as the root of a program is refused. +#[test] +fn a_generic_main_of_an_imported_module_is_not_an_entry_point() { + let tmp = tempfile::tempdir().expect("tempdir"); + write( + tmp.path(), + "lib.eph", + "module lib\n\nfn main(x: a): a = x\n\npub fn keep(n: I32): I32 = main(n)\n", + ); + for input in ["7", "11"] { + write( + tmp.path(), + "app.eph", + &format!("module app\n\nimport lib\n\nfn main(): I32 = keep({input})\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(), + input, + "{args:?}:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + } + } + + let out = ephapax(tmp.path(), &["check", "lib.eph"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains("cannot have type parameters"), + "exit {:?}, 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] diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index fa9d00e..6db7ae4 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -25,7 +25,7 @@ use std::path::{Path, PathBuf}; use ephapax_desugar::{DataRegistry, DesugarError, Desugarer}; use ephapax_parser::ParseError; use ephapax_syntax::{Decl, ExternItem, Module}; -use ephapax_typing::{module_expr_types, ModuleRegistry, SpannedTypeError}; +use ephapax_typing::{module_expr_types, ModuleRegistry, SpannedTypeError, TypeError}; use ephapax_wasm::CodegenError; /// One module of a loaded program, desugared to core. @@ -265,6 +265,30 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { } })?); } + // Only the `main` of the root module is the entry point of the + // program, and no call gives an entry point its type arguments. A + // generic `main` of an imported module is a function like any other. + let root = program.root(); + let generic_main = root.core.decls.iter().find_map(|decl| match decl { + Decl::Fn { + name, + type_params, + body, + .. + } if name == "main" && !type_params.is_empty() => Some(body.span), + _ => None, + }); + if let Some(span) = generic_main { + return Err(DriverError::Type { + module: root.core.name.to_string(), + path: root.file_path.clone(), + source_text: root.source.clone(), + error: Box::new(SpannedTypeError { + error: TypeError::GenericMain, + span, + }), + }); + } // A program is one wasm module, and codegen finds a callee by its // bare name. Two functions of the same name get module-qualified names // in the merge (`qualify`). An extern function cannot: its name is the @@ -326,7 +350,6 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { .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 { diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 4b6dc6f..479de7c 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -2758,12 +2758,6 @@ 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