From 92645c75a019df0ba5235bf49d61302190c6f774 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 20:55:13 +0000 Subject: [PATCH 1/2] feat: top-level constants; fix a type that a function body fixes late 1. Top-level constants: `let K = 40` and `let K: I32 = 40`. The grammar had the declaration, and only the core parser read it. The surface parser refused it ("Unexpected declaration: const_decl"), the type checker did not check its value, no function body had it in scope, and no backend had code for it (a use of its name was a trap in wasm). A constant is now a value that each function of its module can name. - Its value sees the functions of the module and the constants before it. Its type is the type in the declaration, or else the type of its value. A type that is linear, or that the value does not fix, is an error by name. - A constant is private to its module. In the merge of a program it gets a module name as a function does. - Its value is computed at each use: in wasm a constant is a function with no parameter, and the interpreter evaluates the value from an empty environment. A local of the same name hides the constant. - The parser reads a name with an upper-case first letter as a constructor. Such a name that is no constructor in scope, and that is a constant of the module, is the constant. - A module declares a name 1 time, as a function or as a constant. 2 functions of one name gave a wasm module that was not valid. 2. Fix: a type that a function body fixes after the expression was checked stayed a variable in the type table, because the solutions of a body were dropped before the table was resolved. `let o = None in match o of | None => 5000000000 | Some(n) => n end` passed `check` and then gave a wasm module that was not valid. #439 made this program pass the checker. The types of a body are now resolved when its check ends. Tests: constants.rs (4 tests: 10 programs as wasm and in the interpreter, the module rule, the flag, 8 refused programs), and 2 more programs in a_constructor_gets_its_type_arguments_from_its_use. 642 tests pass; the all-target count is 639. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 +- src/ephapax-cli/tests/constants.rs | 237 +++++++++++++++++++++++++++++ src/ephapax-cli/tests/run_wasm.rs | 15 ++ src/ephapax-desugar/src/lib.rs | 39 +++++ src/ephapax-driver/src/lib.rs | 2 +- src/ephapax-driver/src/qualify.rs | 8 +- src/ephapax-interp/src/lib.rs | 14 ++ src/ephapax-parser/src/surface.rs | 23 +++ src/ephapax-surface/src/lib.rs | 8 + src/ephapax-typing/src/lib.rs | 103 ++++++++++++- src/ephapax-typing/src/mono.rs | 5 +- src/ephapax-wasm/src/lib.rs | 52 ++++++- 12 files changed, 492 insertions(+), 20 deletions(-) create mode 100644 src/ephapax-cli/tests/constants.rs diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index daa8db7..7fb9c35 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: *635 tests* (`+cargo test --workspace --all-targets+`); +* Total: *639 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 635 +* Documented all-target tests: 639 === 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 — 635 pass, 0 fail +* [x] cargo test –workspace –all-targets — 639 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/constants.rs b/src/ephapax-cli/tests/constants.rs new file mode 100644 index 0000000..bb622d3 --- /dev/null +++ b/src/ephapax-cli/tests/constants.rs @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// constants.rs: a top-level constant, `let K = 40` or `let K: I32 = 40`. +// +// The grammar had the declaration, and only the core parser read it: the +// surface parser refused it, the type checker did not check its value, +// and no backend had code for it. A constant is now a value that each +// function of its module can name. Its value is computed at each use (in +// wasm, a constant is a function with no parameter). Each program here +// runs as wasm and in the interpreter, and the 2 must print the expected +// value. + +use std::path::Path; +use std::process::{Command, Output}; + +/// Run the `ephapax` binary with `args`, in the directory `dir`. +fn ephapax(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(dir) + .args(args) + .output() + .expect("spawn ephapax") +} + +/// Write each program (name, text, output) into `dir` and run it as wasm +/// and in the interpreter, with the flags `flags`. +fn each_runs(dir: &Path, flags: &[&str], programs: &[(&str, &str, &str)]) { + for (name, text, want) in programs { + let file = format!("{name}.eph"); + std::fs::write(dir.join(&file), text).expect("write program"); + for mode in [&["run"][..], &["run", "--interp"]] { + let args: Vec<&str> = [mode, flags, &[file.as_str()]].concat(); + let out = ephapax(dir, &args); + assert!( + out.status.success(), + "{name} under {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout.trim(), *want, "{name} under {args:?}"); + } + } +} + +/// `check` refuses `text` with a message that has `error` in it. +fn check_refuses(dir: &Path, name: &str, text: &str, error: &str) { + let file = format!("{name}.eph"); + std::fs::write(dir.join(&file), text).expect("write program"); + let out = ephapax(dir, &["check", &file]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains(error), + "{name}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); +} + +#[test] +fn a_top_level_constant_runs() { + let tmp = tempfile::tempdir().expect("tempdir"); + each_runs( + tmp.path(), + &[], + &[ + ("plain", "let K = 40\n\nfn main(): I32 = K + 2\n", "42"), + // A lower-case name, and a type in the declaration. + ( + "lower_case", + "let k: I32 = 40\n\nlet two = 2\n\nfn main(): I32 = k + two\n", + "42", + ), + // A constant sees the constants before it and the functions + // of the module. A function sees each constant. + ( + "order", + "fn double(n: I32): I32 = n * 2\n\nlet K = double(10)\n\nlet M = K + 1\n\n\ + fn add(n: I32): I32 = n + K\n\nfn main(): I32 = add(1) + M\n", + "42", + ), + // In the body of a lambda: a constant is not captured. + ( + "in_a_lambda", + "fn twice(f: I32 -> I32, x: I32): I32 = f(f(x))\n\nlet STEP = 20\n\n\ + fn main(): I32 = twice(fn(n: I32) -> n + STEP, 2)\n", + "42", + ), + // A `let` and a parameter hide a constant of the same name. + ( + "hidden", + "let K = 1000\n\nfn shadow(K: I32): I32 = K + 1\n\n\ + fn main(): I32 = let K = 1 in K + shadow(40)\n", + "42", + ), + // Each value type keeps its width. + ( + "wide", + "let BIG = 5000000000\n\nlet HALF = 1.5\n\nlet YES = true\n\n\ + fn main(): I64 = if YES && HALF > 1.0 then BIG + BIG else BIG\n", + "10000000000i64", + ), + // A closure, a pair, a list, and a value of a `data` type. + ( + "values", + "data Option(a) = None | Some(a)\n\nlet INC = fn(n: I32) -> n + 1\n\n\ + let PAIR = (30, 2)\n\nlet XS = [4, 5]\n\nlet SOME: Option(I32) = Some(0)\n\n\ + fn main(): I32 =\n \ + INC(PAIR.0 + PAIR.1 + XS[0] + XS[1]) + (match SOME of | None => 1 | Some(n) => n end)\n", + "42", + ), + // The value uses a generic function. + ( + "generic", + "fn id(x: T): T = x\n\nlet K = id(42)\n\nfn main(): I32 = K\n", + "42", + ), + // A name with an upper-case first letter is a constructor + // when a constructor of that name is in scope. + ( + "constructor_first", + "data T = K | J\n\nlet K = 40\n\nfn main(): I32 = match K of | K => 1 | J => 2 end\n", + "1", + ), + // A function that uses a constant, as a value. + ( + "function_value", + "let K = 40\n\nfn get(): I32 = K\n\nfn main(): I32 = let g = get in g(()) + 2\n", + "42", + ), + ], + ); +} + +/// A constant belongs to its module: 2 modules can have a constant of +/// one name, and no module names a constant of another module. +#[test] +fn a_constant_is_private_to_its_module() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("lib.eph"), + "module lib\n\nlet K = 40\n\npub fn base(): I32 = K\n", + ) + .expect("write module"); + each_runs( + tmp.path(), + &[], + &[( + "app", + "module app\n\nimport lib\n\nlet K = 2\n\nfn main(): I32 = base() + K\n", + "42", + )], + ); + check_refuses( + tmp.path(), + "other", + "module other\n\nimport lib\n\nfn main(): I32 = K\n", + "unknown constructor `K`", + ); +} + +/// The forms behind the flag `--unstable-core` are behind it in the +/// value of a constant too. +#[test] +fn a_constant_follows_the_flag() { + let tmp = tempfile::tempdir().expect("tempdir"); + let wide = "let F = fn(x: F64) -> x + 1.5\n\nfn main(): F64 = F(40.5)\n"; + let list = "data IntList = Nil | Cons(I32, IntList)\n\nlet ONE: IntList = Cons(42, Nil)\n\n\ + fn head(l: IntList): I32 = match l of | Nil => 0 | Cons(h, t) => h end\n\n\ + fn main(): I32 = head(ONE)\n"; + each_runs( + tmp.path(), + &["--unstable-core"], + &[("wide", wide, "42"), ("list", list, "42")], + ); + check_refuses( + tmp.path(), + "wide", + wide, + "behind the flag `--unstable-core`: a lambda", + ); + check_refuses( + tmp.path(), + "list", + list, + "behind the flag `--unstable-core`", + ); +} + +#[test] +fn a_constant_that_is_not_valid_is_refused_by_name() { + let tmp = tempfile::tempdir().expect("tempdir"); + let main = "\n\nfn main(): I32 = 0\n"; + // (name, declarations, error) + for (name, decls, error) in [ + // A constant sees only the constants before it, and not itself. + ( + "later", + "let A = B + 1\n\nlet B = 1", + "Variable `B` not found in scope", + ), + ("itself", "let A = A + 1", "Variable `A` not found in scope"), + // The value does not have the type of the declaration. + ("wrong_type", "let K: Bool = 40", "Type mismatch"), + // The value does not fix the type. + ( + "type_unknown", + "data Option(a) = None | Some(a)\n\nlet K = None", + "The type of the constant `K` is not known from its value", + ), + // A string is in a region, and a constant is in no region. + ( + "string", + "let S = region r { String.new@r(\"ab\") }", + "escapes region `r`", + ), + // A name is declared 1 time: 2 constants, a constant and a + // function, and 2 functions (which gave a module that was not + // valid). + ( + "2_constants", + "let K = 40\n\nlet K = 1.5", + "`K` is declared 2 times in the module", + ), + ( + "constant_and_function", + "let k = 40\n\nfn k(): F64 = 1.5", + "`k` is declared 2 times in the module", + ), + ( + "2_functions", + "fn f(): I32 = 1\n\nfn f(): I32 = 2", + "`f` is declared 2 times in the module", + ), + ] { + check_refuses(tmp.path(), name, &format!("{decls}{main}"), error); + } +} diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index f32d799..58314f9 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -538,6 +538,21 @@ fn a_constructor_gets_its_type_arguments_from_its_use() { "let o = None in match o of | None => 42 | Some(n) => n end", "42", ), + // The arms of the `match` fix the type after the constructor and + // the scrutinee are checked. (The type table kept the type as + // not known, and the wasm module was not valid.) + ( + option.to_string(), + "I64", + "let o = None in match o of | None => 5000000000 | Some(n) => n end", + "5000000000i64", + ), + ( + option.to_string(), + "F64", + "let o = None in match o of | None => 1.5 | Some(n) => n end", + "1.5", + ), // The 2 branches of an `if` give the 2 sides of the sum. ( format!("{option}{or}"), diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 6eeb7bc..5fa8018 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -395,6 +395,11 @@ pub struct Desugarer { /// module reference at desugar time. Reset per-module by /// [`Desugarer::desugar_module`]. module_qualifiers: HashSet, + /// The names of the top-level constants of the module being + /// desugared. A constant with an upper-case first letter (`let K = + /// 40`) is read by the parser as a constructor: see `Construct` in + /// [`Desugarer::desugar_expr`]. + constants: HashSet, /// Named types currently being expanded by `desugar_named_type`, /// innermost last. A `data` type that is entered again is a recursive /// type (v2 plan rung E9). @@ -418,6 +423,7 @@ impl Desugarer { Self { registry: DataRegistry::new(), module_qualifiers: HashSet::new(), + constants: HashSet::new(), expanding: RefCell::new(Vec::new()), expansions: std::cell::Cell::new(0), scope: RefCell::default(), @@ -430,6 +436,7 @@ impl Desugarer { Self { registry, module_qualifiers: HashSet::new(), + constants: HashSet::new(), expanding: RefCell::new(Vec::new()), expansions: std::cell::Cell::new(0), scope: RefCell::default(), @@ -494,6 +501,14 @@ impl Desugarer { .iter() .map(|i| last_segment(i.module.as_str())) .collect(); + self.constants = module + .decls + .iter() + .filter_map(|decl| match decl { + SurfaceDecl::Const { name, .. } => Some(name.clone()), + _ => None, + }) + .collect(); // The public types of each imported module, in the order of the // imports: a later one shadows an earlier one, and a declaration @@ -584,6 +599,15 @@ impl Desugarer { ty: self.desugar_ty(ty)?, }); } + // A constant keeps its form: the type checker finds its + // type, and each backend finds its value by its name. + SurfaceDecl::Const { name, ty, value } => { + core_decls.push(Decl::Const { + name: name.clone(), + ty: ty.as_ref().map(|t| self.desugar_ty(t)).transpose()?, + value: self.desugar_expr(value)?, + }); + } // Data declarations are consumed by the registry, not emitted // as core declarations. The type information lives in the // registry and is used to desugar Construct/Match nodes. @@ -834,6 +858,21 @@ impl Desugarer { // === Surface-only nodes (real desugaring) === SurfaceExprKind::Construct { ctor, args } => { + // The parser reads each name with an upper-case first + // letter as a constructor. A name that is no constructor + // in scope, and that is a constant of the module, is that + // constant, applied to the arguments. + let scope = self.scope.borrow().clone(); + if self.constants.contains(ctor) && self.registry.get_ctor(&scope, ctor).is_none() { + let constant = Expr::new(ExprKind::Var(ctor.clone()), span); + return args.iter().try_fold(constant, |func, arg| { + let app = ExprKind::App { + func: Box::new(func), + arg: Box::new(self.desugar_expr(arg)?), + }; + Ok(Expr::new(app, span)) + }); + } return self.desugar_construct(ctor, args, span); } diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index 17a52ed..c851ae1 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -332,7 +332,7 @@ pub fn check_program_with(program: &Program, features: Features) -> Result<(), D let mut callables = Vec::new(); for decl in &m.core.decls { match decl { - Decl::Fn { name, .. } => callables.push((name, None)), + Decl::Fn { name, .. } | Decl::Const { name, .. } => callables.push((name, None)), Decl::Extern { abi, items } => { callables.extend(items.iter().filter_map(|item| match item { ExternItem::Fn { name, .. } => Some((name, Some((abi, item)))), diff --git a/src/ephapax-driver/src/qualify.rs b/src/ephapax-driver/src/qualify.rs index 99b26f1..a53b8d8 100644 --- a/src/ephapax-driver/src/qualify.rs +++ b/src/ephapax-driver/src/qualify.rs @@ -31,6 +31,9 @@ fn callables(module: &Module) -> Vec<(&Var, bool, bool)> { Decl::Fn { name, visibility, .. } => out.push((name, *visibility == Visibility::Public, true)), + // A constant is in the namespace of the functions, and no + // other module can import it. + Decl::Const { name, .. } => out.push((name, false, true)), Decl::Extern { items, .. } => out.extend(items.iter().filter_map(|item| match item { ExternItem::Fn { name, .. } => Some((name, true, false)), ExternItem::Type { .. } => None, @@ -130,7 +133,10 @@ fn rename_decl(decl: &mut Decl, scope: &Renames) { params.iter_mut().for_each(|(p, _)| rename(p, scope)); rename_expr(body, scope); } - Decl::Const { value, .. } => rename_expr(value, scope), + Decl::Const { name, value, .. } => { + rename(name, scope); + rename_expr(value, scope); + } Decl::Type { .. } | Decl::Extern { .. } | Decl::Data { .. } => {} } } diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 49322ff..ba6a343 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -325,6 +325,8 @@ pub struct Interpreter { regions: Vec, /// Function definitions functions: HashMap, + /// The value expression of each top-level constant. + constants: HashMap, /// Data declarations from loaded modules, used by `eval_match` to /// resolve `Pattern::Constructor` against the runtime binary-sum /// encoding. @@ -357,6 +359,7 @@ impl Interpreter { env: Environment::new(), regions: Vec::new(), functions: HashMap::new(), + constants: HashMap::new(), data_registry: InterpDataRegistry::default(), ffi_libraries: Vec::new(), } @@ -399,6 +402,9 @@ impl Interpreter { self.functions .insert(name.clone(), (params.clone(), ret_ty.clone(), body.clone())); } + if let Decl::Const { name, value, .. } = decl { + self.constants.insert(name.clone(), value.clone()); + } } self.data_registry.populate_from_module(module); } @@ -752,6 +758,14 @@ impl Interpreter { if let Some((params, _ret_ty, body)) = self.functions.get(name).cloned() { return Ok(self.function_value(params, body)); } + // A constant: its value, from an empty environment, at each + // use of the name (as wasm, which calls a function). + if let Some(value) = self.constants.get(name).cloned() { + let caller = std::mem::replace(&mut self.env, Environment::new()); + let result = self.eval(&value); + self.env = caller; + return result; + } } // Check if consumed (for linear values) diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index ea4ae3b..ee3d7c7 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -142,6 +142,7 @@ fn parse_surface_declaration(pair: pest::iterators::Pair) -> Result parse_fn_decl(inner), Rule::type_decl => parse_type_decl(inner), Rule::extern_block => parse_extern_block(inner), + Rule::const_decl => parse_const_decl(inner), _ => Err(ParseError::Syntax { message: format!("Unexpected declaration: {:?}", inner.as_rule()), span: span_from_pair(&inner), @@ -149,6 +150,28 @@ fn parse_surface_declaration(pair: pest::iterators::Pair) -> Result) -> Result { + let mut name = None; + let mut ty = None; + let mut value = None; + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier if name.is_none() => name = Some(parse_identifier(item)), + Rule::ty => ty = Some(parse_type(item)?), + Rule::expression => value = Some(parse_expression(item)?), + _ => {} + } + } + Ok(SurfaceDecl::Const { + name: name.ok_or_else(|| ParseError::missing("constant name"))?, + ty, + value: value.ok_or_else(|| ParseError::missing("constant value"))?, + }) +} + /// Parse an `extern "abi" { ... }` block into a `SurfaceDecl::Extern`. /// /// Mirror of the core parser's `parse_extern_block`, but types stay diff --git a/src/ephapax-surface/src/lib.rs b/src/ephapax-surface/src/lib.rs index 8a4dcd6..78f8cda 100644 --- a/src/ephapax-surface/src/lib.rs +++ b/src/ephapax-surface/src/lib.rs @@ -444,6 +444,14 @@ pub enum SurfaceDecl { /// Data type declaration (surface-only) Data(DataDecl), + /// Module-level constant: `let NAME = expr`, or `let NAME: T = expr`. + Const { + name: Var, + #[serde(default, skip_serializing_if = "Option::is_none")] + ty: Option, + value: SurfaceExpr, + }, + /// Foreign function and type declarations: `extern "abi" { ... }`. /// /// Surface mirror of the core `Decl::Extern`. The desugar pass diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 7228b9f..e55c7f9 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -151,6 +151,20 @@ pub enum TypeError { #[error("`{0}` is not a generic function and cannot take type arguments")] NotGeneric(Var), + /// A constant is used each time its name is used, so its value + /// cannot have a type that is used 1 time. + #[error("The constant `{0}` has the linear type {1:?}: a constant can be used many times")] + LinearConstant(Var, Ty), + + /// 2 declarations of a module give the same name to a function or to + /// a constant. Each backend finds such a declaration by its name. + #[error("`{0}` is declared 2 times in the module, as a function or as a constant")] + DuplicateDefinition(Var), + + /// The value of a constant does not fix its type (`let K = None`). + #[error("The type of the constant `{0}` is not known from its value: give it a type, as in `let {0}: T = ...`")] + ConstantTypeUnknown(Var), + /// `fold` to a type, or `unfold` of a value of a type, that is not a /// recursive type (v2 plan rung E9). #[error("`fold` and `unfold` need a recursive type, found {0:?}")] @@ -954,6 +968,9 @@ pub struct TypeChecker { ctx: Context, /// Type of each checked expression; see [`TypeChecker::expr_types`]. expr_types: ExprTypes, + /// The expressions of the function body being checked. Their types + /// are resolved when the body is checked ([`check_fn`]). + checked_in_body: Vec, /// Uses of generic functions in the body being checked. instantiations: Vec, /// The resolved type arguments of every use; see [`TypeArgs`]. @@ -978,6 +995,7 @@ impl TypeChecker { Self { ctx: Context::new(), expr_types: ExprTypes::new(), + checked_in_body: Vec::new(), instantiations: Vec::new(), type_args: TypeArgs::new(), generic_calls: Vec::new(), @@ -1185,6 +1203,7 @@ impl TypeChecker { pub fn check(&mut self, expr: &Expr) -> Result { let ty = self.check_kind(expr)?; self.expr_types.insert(expr.id, ty.clone()); + self.checked_in_body.push(expr.id); Ok(ty) } @@ -2805,6 +2824,24 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), span: Span::dummy(), })?; + // A function and a constant are found by their name: a module has + // each name 1 time. (2 functions of one name gave a wasm module that + // was not valid.) + let mut declared: HashSet<&Var> = HashSet::new(); + for decl in &module.decls { + if let Decl::Fn { name, body, .. } + | Decl::Const { + name, value: body, .. + } = decl + { + if !declared.insert(name) { + let error = TypeError::DuplicateDefinition(name.clone()); + let span = body.span; + return Err(SpannedTypeError { error, span }); + } + } + } + // First pass: collect all function signatures (regular + extern) for decl in &module.decls { match decl { @@ -2878,6 +2915,16 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), } } + // The constants, in the order of their declarations: the value of a + // constant sees the functions and the constants before it, and each + // function body sees each constant. (The value was not checked, and + // no function body had the constant in scope.) + for decl in &module.decls { + if let Decl::Const { name, ty, value } = decl { + check_const(tc, name, ty.as_ref(), value)?; + } + } + // Second pass: type check each function body for decl in &module.decls { let Decl::Fn { @@ -2889,10 +2936,10 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), .. } = 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. + // A constant is checked above. 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)?; @@ -2926,7 +2973,37 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), check_polymorphic_recursion(tc, module) } -/// Check the body of one function against its signature. +/// Check the value of the constant `name`, as the body of a function +/// with no parameter, and give the constant its type in the context: the +/// type `ty` that its declaration gives, or else the type of its value. +/// A constant is used each time its name is used, so its type cannot be +/// linear. +fn check_const( + tc: &mut TypeChecker, + name: &Var, + ty: Option<&Ty>, + value: &Expr, +) -> Result<(), SpannedTypeError> { + let declared = ty.cloned().unwrap_or_else(|| tc.fresh_unif()); + let ty = check_fn(tc, name, &[], &[], &declared, value)?; + let refused = if has_unif(&ty) { + Some(TypeError::ConstantTypeUnknown(name.clone())) + } else if ty.is_linear() { + Some(TypeError::LinearConstant(name.clone(), ty.clone())) + } else { + None + }; + if let Some(error) = refused { + let span = value.span; + return Err(SpannedTypeError { error, span }); + } + tc.ctx.extend(name.clone(), ty, BindingForm::Let); + Ok(()) +} + +/// Check the body of one function against its signature. Returns the +/// result type, which the body can fix (the value of a constant with no +/// type in its declaration). fn check_fn( tc: &mut TypeChecker, name: &Var, @@ -2934,7 +3011,7 @@ fn check_fn( params: &[(Var, Ty)], ret_ty: &Ty, body: &Expr, -) -> Result<(), SpannedTypeError> { +) -> Result { let saved_ctx = tc.ctx.clone(); let saved_unif = tc.next_unif; @@ -2989,11 +3066,23 @@ fn check_fn( tc.type_args.insert(use_.id, args); } + // The solutions of this body end here, and the next body uses the + // same numbers for its variables: resolve the types of this body now. + // (A type that the body fixed after the expression was checked stayed + // a variable in the table: `let o = None in match o of ... Some(n) => + // n` with an I64 result gave a module that was not valid.) + for id in std::mem::take(&mut tc.checked_in_body) { + if let Some(ty) = tc.expr_types.get_mut(&id) { + *ty = ty.resolve(&tc.unif_solutions); + } + } + + let ret_ty = tc.resolve(ret_ty); tc.ctx = saved_ctx; tc.next_unif = saved_unif; tc.unif_solutions.clear(); tc.wildcard_obligations.clear(); - Ok(()) + Ok(ret_ty) } /// True when `ty` has a unification variable with no solution. diff --git a/src/ephapax-typing/src/mono.rs b/src/ephapax-typing/src/mono.rs index 6b22836..0725e70 100644 --- a/src/ephapax-typing/src/mono.rs +++ b/src/ephapax-typing/src/mono.rs @@ -98,9 +98,10 @@ pub fn monomorphise(module: &Module) -> Result { for decl in &module.decls { match decl { Decl::Fn { type_params, .. } if !type_params.is_empty() => {} - Decl::Fn { .. } => { + // The value of a constant can use a generic function too. + Decl::Fn { .. } | Decl::Const { .. } => { let mut decl = decl.clone(); - if let Decl::Fn { body, .. } = &mut decl { + if let Decl::Fn { body, .. } | Decl::Const { value: body, .. } = &mut decl { mono.rewrite(body, &Subst::new()); } decls.push(decl); diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index cd1136e..3d46b27 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -648,6 +648,9 @@ pub struct Codegen { /// The table index of the closure function that stands for a named /// function used as a value, by the name of that function. fn_value_slots: HashMap, + /// The names of the top-level constants. A constant is a function + /// with no parameter, and a use of its name is a call of it. + constants: std::collections::HashSet, /// Optional debug information (enabled with --debug flag) debug_info: Option, @@ -758,6 +761,7 @@ impl Codegen { lambda_fns: Vec::new(), callable_params: HashMap::new(), fn_value_slots: HashMap::new(), + constants: std::collections::HashSet::new(), debug_info: None, optimize_closures: false, extern_imports: Vec::new(), @@ -1152,7 +1156,32 @@ impl Codegen { idx += 1; } Decl::Type { .. } => { /* type aliases are erased at runtime */ } - Decl::Const { .. } => { /* constants inlined at compile time */ } + // A constant is a function with no parameter: a use of its + // name is a call, so its value is computed at each use. + // (No code was made for a constant, and a use of its + // name was a trap.) + // snip: a call at each use; upgrade to a wasm global that + // a start function sets if the value of a constant is + // costly or has an effect that must happen 1 time. + Decl::Const { name, ty, value } => { + let result_type = ty + .as_ref() + .or_else(|| self.types.get(&value.id)) + .map_or(ValType::I32, ty_to_valtype); + let wasm_type_idx = self.register_type(vec![], vec![result_type]); + self.constants.insert(name.to_string()); + self.user_fns.insert( + name.to_string(), + UserFnInfo { + wasm_fn_idx: idx, + wasm_type_idx, + result_type, + param_names: Vec::new(), + param_kinds: Vec::new(), + }, + ); + idx += 1; + } // Extern fn imports are emitted via `collect_extern_imports` // earlier in `compile_ast_module`; extern types are opaque // (lowered as i32). Nothing to do here in user-fn collection. @@ -1477,9 +1506,14 @@ impl Codegen { ) -> Result<(), CodegenError> { for decl in &ast.decls { match decl { - Decl::Fn { - name, params, body, .. + Decl::Fn { name, body, .. } + | Decl::Const { + name, value: body, .. } => { + let params: &[(ephapax_syntax::Var, Ty)] = match decl { + Decl::Fn { params, .. } => params, + _ => &[], + }; let info = self.user_fns.get(name.as_str()).cloned().ok_or_else(|| { CodegenError(format!("BUG: function `{}` not collected", name)) })?; @@ -1503,7 +1537,6 @@ impl Codegen { code_sec.function(&self.with_locals(scratch)); } Decl::Type { .. } => {} - Decl::Const { .. } => {} // constants inlined // Extern fns have no code-section body — they're // imported via `(import ...)` directives emitted from // `collect_extern_imports`, so the code-section pass @@ -2339,6 +2372,12 @@ impl Codegen { func.instruction(&Instruction::LocalGet(idx)); // Mark linear local as consumed self.locals.mark_consumed(idx); + } else if let Some(info) = self + .user_fns + .get(name) + .filter(|_| self.constants.contains(name)) + { + func.instruction(&Instruction::Call(info.wasm_fn_idx)); } else if self.callable_params.contains_key(name) { self.compile_fn_value(func, name); } else { @@ -2824,8 +2863,9 @@ impl Codegen { // A local hides a function of the same name (`fn apply(f: ...) = // f(x)` with a top-level `f`): the call is then a closure call. // (The call went to the top-level function.) - let head = - flatten_app_chain(fn_expr, arg).filter(|(head, _)| self.locals.get(head).is_none()); + // A constant is a value, not a function to call with arguments. + let head = flatten_app_chain(fn_expr, arg) + .filter(|(head, _)| self.locals.get(head).is_none() && !self.constants.contains(*head)); if let Some((head, args)) = head { if let Some(info) = self.user_fns.get(head).cloned() { let arity = info.param_names.len(); From 9c7f51d5bc93d3839cc3e8bd96d5655c2a3c2566 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 21:05:17 +0000 Subject: [PATCH 2/2] docs(tests): doc comments for 2 tests of the constants The CodeRabbit check Docstring Coverage listed them on #441. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-cli/tests/constants.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ephapax-cli/tests/constants.rs b/src/ephapax-cli/tests/constants.rs index bb622d3..03f2ffb 100644 --- a/src/ephapax-cli/tests/constants.rs +++ b/src/ephapax-cli/tests/constants.rs @@ -56,6 +56,8 @@ fn check_refuses(dir: &Path, name: &str, text: &str, error: &str) { ); } +/// A constant gives its value to each function of its module, and to +/// each constant after it. #[test] fn a_top_level_constant_runs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -186,6 +188,8 @@ fn a_constant_follows_the_flag() { ); } +/// Each constant that the checker cannot accept has an error that names +/// the cause. A name that a module declares 2 times is one of them. #[test] fn a_constant_that_is_not_valid_is_refused_by_name() { let tmp = tempfile::tempdir().expect("tempdir");