Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TEST-NEEDS.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
241 changes: 241 additions & 0 deletions src/ephapax-cli/tests/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// SPDX-License-Identifier: MPL-2.0
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
// 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()
);
}

/// 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");
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<T>(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`",
);
}

/// 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");
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);
}
}
15 changes: 15 additions & 0 deletions src/ephapax-cli/tests/run_wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
Expand Down
39 changes: 39 additions & 0 deletions src/ephapax-desugar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,11 @@ pub struct Desugarer {
/// module reference at desugar time. Reset per-module by
/// [`Desugarer::desugar_module`].
module_qualifiers: HashSet<SmolStr>,
/// 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<SmolStr>,
/// 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).
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/ephapax-driver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))),
Expand Down
8 changes: 7 additions & 1 deletion src/ephapax-driver/src/qualify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 { .. } => {}
}
}
Expand Down
Loading
Loading