From be485c24a6ae6c19e82196c09594299fb36aed06 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 11:06:05 +0000 Subject: [PATCH 01/13] feat(driver,run): one front end for every tool (E3) and a wasm runner (E4) v2 plan rungs E3 and E4 (docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc). E3: new crate `ephapax-driver`. It resolves imports, parses (surface grammar, core-parser fallback), desugars, type-checks and rejects constructs that codegen cannot lower. `check`, `compile`, `run`, the REPL, the LSP and the query engine now all load programs through it. Before this, `check` had no import resolver, and `run`, the REPL, the LSP and the query engine used the v1 core parser only. - The import resolver moves from the CLI into the driver. The module index is now built on the first import that misses its literal path. - `check` resolves imports: 17 of 121 corpus files check (was 13), the same 17 that compile. - An integer literal above i64::MAX is `ParseError::IntegerOutOfRange`. It used to become 0 without a diagnostic. - `src/ephapax-tools` stays out of the workspace, with the reason recorded in Cargo.toml. E4: `ephapax run` compiles the program and executes the wasm in-process (wasmtime). It provides `env.print_i32`, `env.print_string` and the `stdlib/Argv.eph` host imports, with the program path as argv[0]. The interpreter is `run --interp`; `-L` is accepted only with it. tests/interp-known-failures.txt lists the compiling corpus files on which the interpreter disagrees with the wasm path. Gates: tests/driver_agreement.rs (every entry point agrees with the driver on all 121 corpus files; LSP twin in ephapax-lsp), and tests/run_wasm.rs (`run p1.eph` prints 162; three argument vectors; interpreter/wasm agreement ratchet). Probe battery baseline: `p1 run` reaches its target (ok:162). `gen_angle run` and `qa/app2 run` now fail the same way `check` and `compile` do, until E7. Not done in E3: folding `-9223372036854775808` to i64::MIN. Unary minus on a literal does not parse today in either parser. TEST-NEEDS.adoc: documented all-target test count 554 -> 570 (7 new tests; the count was already 563 before this change). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- Cargo.lock | 23 +- Cargo.toml | 5 + Justfile | 2 +- TEST-NEEDS.adoc | 4 +- ...2-grammar-and-codegen-gaps-2026-06-16.adoc | 2 +- scripts/probe-battery.sh | 3 +- spec/TRG-PROFILE.adoc | 2 +- src/ephapax-cli/Cargo.toml | 9 +- src/ephapax-cli/src/import_resolver.rs | 285 -------------- src/ephapax-cli/src/main.rs | 353 +++++++----------- src/ephapax-cli/src/runner.rs | 145 +++++++ src/ephapax-cli/tests/driver_agreement.rs | 202 ++++++++++ src/ephapax-cli/tests/run_wasm.rs | 118 ++++++ src/ephapax-cli/tests/v2_stdlib_import.rs | 2 +- src/ephapax-driver/Cargo.toml | 21 ++ src/ephapax-driver/src/lib.rs | 251 +++++++++++++ src/ephapax-driver/src/resolver.rs | 267 +++++++++++++ src/ephapax-lsp/Cargo.toml | 3 +- src/ephapax-lsp/src/main.rs | 159 +++++--- src/ephapax-parser/src/error.rs | 17 + src/ephapax-parser/src/lib.rs | 47 ++- src/ephapax-query/Cargo.toml | 4 +- src/ephapax-query/src/lib.rs | 44 ++- src/ephapax-repl/Cargo.toml | 1 + src/ephapax-repl/src/lib.rs | 79 ++-- tests/interp-known-failures.txt | 14 + tests/probes/expectations.tsv | 6 +- tools/coproc/README.adoc | 8 +- 28 files changed, 1402 insertions(+), 674 deletions(-) delete mode 100644 src/ephapax-cli/src/import_resolver.rs create mode 100644 src/ephapax-cli/src/runner.rs create mode 100644 src/ephapax-cli/tests/driver_agreement.rs create mode 100644 src/ephapax-cli/tests/run_wasm.rs create mode 100644 src/ephapax-driver/Cargo.toml create mode 100644 src/ephapax-driver/src/lib.rs create mode 100644 src/ephapax-driver/src/resolver.rs create mode 100644 tests/interp-known-failures.txt diff --git a/Cargo.lock b/Cargo.lock index 4c243a3e..608df693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -756,6 +756,7 @@ dependencies = [ "clap", "colored", "ephapax-desugar", + "ephapax-driver", "ephapax-interp", "ephapax-ir", "ephapax-lexer", @@ -763,7 +764,6 @@ dependencies = [ "ephapax-parser", "ephapax-query", "ephapax-repl", - "ephapax-surface", "ephapax-syntax", "ephapax-typing", "ephapax-wasm", @@ -789,6 +789,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ephapax-driver" +version = "0.1.0" +dependencies = [ + "ephapax-desugar", + "ephapax-parser", + "ephapax-surface", + "ephapax-syntax", + "ephapax-typing", + "ephapax-wasm", + "thiserror 2.0.18", +] + [[package]] name = "ephapax-fuzz" version = "0.1.0" @@ -844,9 +857,8 @@ dependencies = [ name = "ephapax-lsp" version = "0.1.0" dependencies = [ - "ephapax-parser", + "ephapax-driver", "ephapax-syntax", - "ephapax-typing", "serde_json", "smol_str", "tokio", @@ -904,9 +916,7 @@ dependencies = [ name = "ephapax-query" version = "0.1.0" dependencies = [ - "ephapax-parser", - "ephapax-syntax", - "ephapax-typing", + "ephapax-driver", "ephapax-wasm", ] @@ -915,6 +925,7 @@ name = "ephapax-repl" version = "0.1.0" dependencies = [ "colored", + "ephapax-driver", "ephapax-interp", "ephapax-lexer", "ephapax-parser", diff --git a/Cargo.toml b/Cargo.toml index 5f499534..51b6622f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,14 @@ members = [ "ephapax-linear", "src/ephapax-surface", "src/ephapax-desugar", + "src/ephapax-driver", "src/ephapax-query", "src/ephapax-meta", "tests/fuzz", # "src/ephapax-vram-cache", # Disabled: requires ephapax-proven repo + # "src/ephapax-tools", # Excluded (v2 plan E3): no consumer. Its formatter and + # linter are text heuristics that do not load programs, so they do not go + # through ephapax-driver. It builds as a member: 31 tests pass, 8 clippy warnings. ] [workspace.package] @@ -54,6 +58,7 @@ ephapax-lsp = { path = "src/ephapax-lsp" } ephapax-package = { path = "src/ephapax-package" } ephapax-query = { path = "src/ephapax-query" } ephapax-meta = { path = "src/ephapax-meta" } +ephapax-driver = { path = "src/ephapax-driver" } ephapax-vram-cache = { path = "src/ephapax-vram-cache" } # Sibling-repo crates. diff --git a/Justfile b/Justfile index 8ebaf43c..6901cc8b 100644 --- a/Justfile +++ b/Justfile @@ -26,7 +26,7 @@ build-coproc: set -euo pipefail command -v zig >/dev/null 2>&1 || { echo "zig not found"; exit 1; } zig build-lib -dynamic -O ReleaseSafe tools/coproc/ephapax_coproc.zig -femit-bin=libephapax_coproc.so - echo "built libephapax_coproc.so (run: ephapax run prog.eph -L libephapax_coproc.so)" + echo "built libephapax_coproc.so (run: ephapax run --interp prog.eph -L libephapax_coproc.so)" # Run the coprocessor seam's own unit tests (Zig). test-coproc: diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index b01faafb..2c83e9d4 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: *554 tests* (`+cargo test --workspace --all-targets+`); +* Total: *570 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 554 +* Documented all-target tests: 570 === CRG Testing Taxonomy — Status diff --git a/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc b/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc index 5c76f0da..9f428f02 100644 --- a/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc +++ b/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc @@ -153,7 +153,7 @@ coprocessor seam is reachable today only through raw `__ffi("eph_coproc_*", *This is the single highest-leverage v2 toolchain item:* module-import + qualified-member access (the import resolver in -`src/ephapax-cli/src/import_resolver.rs` already loads the import graph for +`src/ephapax-driver/src/resolver.rs`, at the time `src/ephapax-cli/src/import_resolver.rs`, already loads the import graph for multi-file `compile`, but the surface parser/desugarer does not expose imported members). Landing it unblocks consumption of the entire stdlib at once. diff --git a/scripts/probe-battery.sh b/scripts/probe-battery.sh index 392496df..674fc3fc 100755 --- a/scripts/probe-battery.sh +++ b/scripts/probe-battery.sh @@ -23,7 +23,8 @@ # check ephapax check .eph # compile ephapax compile .eph -o .wasm # (compile always runs wasmparser validation before writing) -# run ephapax run .eph (today: the interpreter) +# run ephapax run .eph (compiles, then runs the wasm +# in-process; since v2 plan rung E4) # wasm-run compile, then `wasmtime run --preload env=env.wat --invoke main` # with the wasmtime CLI; verdict `no-module` if compile fails # with an ordinary error, `timeout` or `crash:` if the diff --git a/spec/TRG-PROFILE.adoc b/spec/TRG-PROFILE.adoc index ee42afd7..572d81a1 100644 --- a/spec/TRG-PROFILE.adoc +++ b/spec/TRG-PROFILE.adoc @@ -74,7 +74,7 @@ The overall TRG grade is the *worst* of any MUST component. | F3 | Parser | *C* | `src/ephapax-parser/src/lib.rs` (87KB, `chumsky`). Unit tests + property tests + grammar phase tests. | F4 | AST | *C* | `src/ephapax-syntax/src/lib.rs` (23KB, core AST) + `src/ephapax-surface/src/lib.rs` (18KB, surface AST). `src/ephapax-desugar/src/lib.rs` (71KB) lowers surface → core. All three with unit tests. | F5 | Macro expander | N/A | Ephapax has no macros. -| F6 | Import resolver / Modules | *D* | `src/ephapax-cli/src/import_resolver.rs`. Embedded in CLI; not a separate auditable component. +| F6 | Import resolver / Modules | *D* | `src/ephapax-driver/src/resolver.rs`. Since v2 plan rung E3 it is part of the `ephapax-driver` crate, which every tool loads programs through; it was embedded in the CLI before. Grade not reassessed. | F7 | Diagnostics / Errors | *D* | `src/ephapax-parser/src/error.rs` (LexerError, ParseError). CLI error reporting in `main.rs`. No published error-code index. |=== diff --git a/src/ephapax-cli/Cargo.toml b/src/ephapax-cli/Cargo.toml index 9be2069d..6d5573c7 100644 --- a/src/ephapax-cli/Cargo.toml +++ b/src/ephapax-cli/Cargo.toml @@ -20,14 +20,12 @@ ephapax-syntax.workspace = true ephapax-ir.workspace = true ephapax-lexer.workspace = true ephapax-parser.workspace = true -ephapax-surface = { path = "../ephapax-surface" } -ephapax-desugar = { path = "../ephapax-desugar" } +ephapax-driver.workspace = true ephapax-typing.workspace = true ephapax-wasm.workspace = true ephapax-interp.workspace = true ephapax-repl.workspace = true ephapax-package.workspace = true -ephapax-query.workspace = true # `typed-wasm-verify` is optional: it powers `ephapax compile # --verify-ownership`, which is a post-codegen L7+L10 verifier. With # the feature OFF the CLI builds + runs without any reference to the @@ -44,6 +42,8 @@ serde_json.workspace = true # (the always-on floor; distinct from the optional --verify-ownership # semantic check). Same crate the wasm_e2e / validate-wasm gates use. wasmparser = "0.221" +# `ephapax run` executes the compiled module in-process (v2 plan E4). +wasmtime.workspace = true [features] default = ["typed-wasm-verify"] @@ -55,7 +55,8 @@ default = ["typed-wasm-verify"] "typed-wasm-verify" = ["dep:typed-wasm-verify"] [dev-dependencies] -wasmtime.workspace = true +ephapax-desugar = { path = "../ephapax-desugar" } +ephapax-query.workspace = true proptest = { workspace = true } tempfile = "3" # SHA-256 pin check of the vendored aLib vectors (tests/alib_conformance.rs). diff --git a/src/ephapax-cli/src/import_resolver.rs b/src/ephapax-cli/src/import_resolver.rs deleted file mode 100644 index 4a90bc4e..00000000 --- a/src/ephapax-cli/src/import_resolver.rs +++ /dev/null @@ -1,285 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell -// -//! Multi-file module loader for `compile-eph`. -//! -//! Resolves `import a/b/c` declarations by reading the matching file -//! `/a/b/c.eph` relative to the root module. Walks the import -//! graph transitively, detects cycles, and returns the modules in -//! topological order (dependencies before dependents) so the compiler -//! pipeline (desugar → typecheck → codegen) sees each module after its -//! dependencies have already populated the registries. -//! -//! Scope today is deliberately small: file-system resolution against a -//! single base directory, no package-manifest support yet, no version -//! ranges. Both the dot-form (`Foo.Bar.Baz`) and slash-form -//! (`hyperpolymath/ephapax/test`) are recognised — they map to the same -//! file path with the separator normalised to `/`. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use ephapax_parser::parse_surface_module; -use ephapax_surface::SurfaceModule; - -#[derive(Debug)] -pub struct LoadedModule { - /// Module path as written in the source (e.g. `hypatia/ui/gui`). - #[allow(dead_code)] - pub logical_path: String, - /// Resolved file path on disk. - pub file_path: PathBuf, - /// Source contents (kept for error reporting). - #[allow(dead_code)] - pub source: String, - /// Parsed surface module. - pub surface: SurfaceModule, -} - -#[derive(Debug)] -pub enum ResolveError { - Io { path: PathBuf, message: String }, - Parse { path: PathBuf, message: String }, - Cycle { path: Vec }, -} - -impl std::fmt::Display for ResolveError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ResolveError::Io { path, message } => { - write!(f, "cannot read {}: {}", path.display(), message) - } - ResolveError::Parse { path, message } => { - write!(f, "{}: parse error: {}", path.display(), message) - } - ResolveError::Cycle { path } => { - write!(f, "import cycle: {}", path.join(" -> ")) - } - } - } -} - -impl std::error::Error for ResolveError {} - -/// Walk the import graph rooted at `root_path` and return all loaded -/// modules in topological order — dependencies first, root last. Search -/// for imports under `base_dir` (typically the directory containing the -/// root file), falling back to `stdlib_dir` when given — this is what -/// lets a program anywhere on disk say `import Argv` and reach the -/// shipped stdlib. -pub fn load_program( - root_path: &Path, - base_dir: &Path, - stdlib_dir: Option<&Path>, -) -> Result, ResolveError> { - let mut loaded: HashMap = HashMap::new(); - let mut order: Vec = Vec::new(); - let mut visiting: HashSet = HashSet::new(); - let mut stack: Vec = Vec::new(); - - // Build a `declared module name → file path` index by scanning the - // base directory for .eph files. Imports try the literal path first - // (`a/b/c.eph` under base_dir); if that misses, they fall back to - // this map. This lets files live anywhere in the tree as long as - // they declare their module name in a `module a/b/c` header — which - // matches existing corpora like hypatia/src/ui/gossamer/. - // The stdlib directory (when configured) contributes a THIRD tier: - // local files always shadow stdlib modules of the same name. - let mut mod_index = scan_module_index(base_dir); - if let Some(sd) = stdlib_dir { - for (name, path) in scan_module_index(sd) { - mod_index.entry(name).or_insert(path); - } - } - - let root_module_path = root_module_path_from_source(root_path)?; - visit( - &root_module_path, - Some(root_path), - base_dir, - &mod_index, - &mut loaded, - &mut order, - &mut visiting, - &mut stack, - )?; - // Re-arrange into the visit (post-order) order: dependencies first. - let mut result = Vec::with_capacity(order.len()); - for name in order { - if let Some(m) = loaded.remove(&name) { - result.push(m); - } - } - Ok(result) -} - -/// Walk `base_dir` recursively, reading the first `module a/b/c` line of -/// every `.eph` file we find, and return a map from declared module name -/// to file path. Files without a `module` header are skipped. -fn scan_module_index(base_dir: &Path) -> HashMap { - let mut idx = HashMap::new(); - let mut stack = vec![base_dir.to_path_buf()]; - while let Some(dir) = stack.pop() { - let entries = match std::fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => continue, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - if path.extension().and_then(|s| s.to_str()) != Some("eph") { - continue; - } - let Ok(source) = std::fs::read_to_string(&path) else { - continue; - }; - if let Some(name) = first_module_declaration(&source) { - idx.entry(name).or_insert(path); - } - } - } - idx -} - -fn first_module_declaration(source: &str) -> Option { - for line in source.lines() { - let line = line.trim_start(); - if let Some(rest) = line.strip_prefix("module") { - let rest = rest.trim(); - // Take everything up to a whitespace, comma, or comment marker. - let end = rest - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '/')) - .unwrap_or(rest.len()); - let name = &rest[..end]; - if !name.is_empty() { - return Some(normalise_path(name)); - } - } - } - None -} - -#[allow(clippy::too_many_arguments)] // recursive resolver: 8 params vs the 7 default threshold -fn visit( - logical: &str, - explicit_file: Option<&Path>, - base_dir: &Path, - mod_index: &HashMap, - loaded: &mut HashMap, - order: &mut Vec, - visiting: &mut HashSet, - stack: &mut Vec, -) -> Result<(), ResolveError> { - if loaded.contains_key(logical) { - return Ok(()); - } - if visiting.contains(logical) { - let mut cycle = stack.clone(); - cycle.push(logical.to_string()); - return Err(ResolveError::Cycle { path: cycle }); - } - visiting.insert(logical.to_string()); - stack.push(logical.to_string()); - - let file_path = match explicit_file { - Some(p) => p.to_path_buf(), - None => { - // 1) Literal path under base_dir (`a/b/c` → `/a/b/c.eph`). - let direct = logical_to_file_path(logical, base_dir); - if direct.exists() { - direct - } else if let Some(p) = mod_index.get(logical) { - // 2) Module-declaration index built by walking base_dir. - p.clone() - } else { - // Fall back to the literal path so the IO error names a - // useful location. - direct - } - } - }; - - let source = std::fs::read_to_string(&file_path).map_err(|e| ResolveError::Io { - path: file_path.clone(), - message: e.to_string(), - })?; - - let surface = parse_surface_module(&source, logical).map_err(|errs| ResolveError::Parse { - path: file_path.clone(), - message: errs - .iter() - .map(|e| format!("{}", e)) - .collect::>() - .join("; "), - })?; - - // Recurse into imports BEFORE inserting this module so that the - // post-order visit places dependencies before this module. - let deps: Vec = surface - .imports - .iter() - .map(|i| normalise_path(i.module.as_str())) - .collect(); - for dep in &deps { - visit( - dep, None, base_dir, mod_index, loaded, order, visiting, stack, - )?; - } - - loaded.insert( - logical.to_string(), - LoadedModule { - logical_path: logical.to_string(), - file_path, - source, - surface, - }, - ); - order.push(logical.to_string()); - stack.pop(); - visiting.remove(logical); - Ok(()) -} - -fn root_module_path_from_source(root_path: &Path) -> Result { - let source = std::fs::read_to_string(root_path).map_err(|e| ResolveError::Io { - path: root_path.to_path_buf(), - message: e.to_string(), - })?; - for line in source.lines() { - let line = line.trim_start(); - if let Some(rest) = line.strip_prefix("module") { - let rest = rest.trim(); - if let Some(end) = rest - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '/')) - { - return Ok(normalise_path(&rest[..end])); - } else if !rest.is_empty() { - return Ok(normalise_path(rest)); - } - } - } - // No explicit `module` header — synthesise a logical path from the - // filename stem. - Ok(root_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("root") - .to_string()) -} - -fn normalise_path(raw: &str) -> String { - raw.replace('.', "/") -} - -fn logical_to_file_path(logical: &str, base_dir: &Path) -> PathBuf { - let mut p = base_dir.to_path_buf(); - for seg in logical.split('/') { - p.push(seg); - } - p.set_extension("eph"); - p -} diff --git a/src/ephapax-cli/src/main.rs b/src/ephapax-cli/src/main.rs index 335680e6..535d5094 100644 --- a/src/ephapax-cli/src/main.rs +++ b/src/ephapax-cli/src/main.rs @@ -7,17 +7,17 @@ //! //! The main entry point for the Ephapax compiler and tools. -mod import_resolver; +mod runner; // Note: ariadne removed for now - using simple error output use clap::{Parser, Subcommand}; use colored::Colorize; -use ephapax_desugar::{desugar, DataRegistry, Desugarer}; +use ephapax_driver::DriverError; use ephapax_interp::Interpreter; use ephapax_lexer::Lexer; -use ephapax_parser::{parse, parse_module, parse_surface_module}; +use ephapax_parser::{parse, parse_module}; use ephapax_repl::Repl; -use ephapax_typing::{type_check_module, type_check_module_with_registry, ModuleRegistry}; +use ephapax_syntax::{BaseTy, Decl, Ty}; // AST dump support (sexpr + json output) #[allow(unused_imports)] use std::fs; @@ -56,11 +56,19 @@ enum Commands { /// File to run file: PathBuf, - /// Native shared libraries to load for FFI (e.g. libgossamer.so) + /// Run with the legacy tree-walking interpreter instead of + /// compiling to WebAssembly. The interpreter is incomplete (no + /// `extern`, no program arguments): tests/interp-known-failures.txt + /// lists the programs on which it disagrees with the wasm path. + #[arg(long)] + interp: bool, + + /// Native shared libraries to load for FFI (e.g. libgossamer.so). + /// Interpreter only: rejected without --interp. #[arg(long = "ffi-lib", short = 'L')] ffi_libs: Vec, - /// Arguments to pass to the program + /// Arguments to pass to the program (read through `import Argv`) #[arg(trailing_var_arg = true)] args: Vec, }, @@ -193,9 +201,10 @@ fn main() -> ExitCode { Some(Commands::Repl { preload }) => run_repl(preload, cli.verbose), Some(Commands::Run { file, + interp, ffi_libs, - args: _, - }) => run_file(&file, &ffi_libs, cli.verbose), + args, + }) => run_file(&file, interp, &ffi_libs, &args, cli.verbose), Some(Commands::Check { files, mode }) => check_files(&files, mode.as_deref(), cli.verbose), Some(Commands::Compile { file, @@ -228,7 +237,7 @@ fn main() -> ExitCode { None => { // If a file is given without subcommand, run it if let Some(file) = cli.file { - run_file(&file, &[], cli.verbose) + run_file(&file, false, &[], &[], cli.verbose) } else { // No file, start REPL run_repl(None, cli.verbose) @@ -256,53 +265,118 @@ fn run_repl(preload: Option, verbose: bool) -> Result<(), String> { // Preload file if specified if let Some(path) = preload { - let content = fs::read_to_string(&path) - .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?; - repl.load_module(&content, path.to_str().unwrap_or("preload")) - .map_err(|e| e.to_string())?; + repl.load_path(&path).map_err(|e| e.to_string())?; println!("{} {}", "Preloaded:".green(), path.display()); } repl.run() } -fn run_file(path: &PathBuf, ffi_libs: &[String], verbose: bool) -> Result<(), String> { - let content = - fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {}", path.display(), e))?; - - let filename = path.to_str().unwrap_or("input"); - - // Drive parse + type-check through the demand-driven query engine. - // For a single-shot `ephapax run` this is just a thin pass-through (the - // db is discarded after one query), but it routes the real CLI path - // through `ephapax-query` and is exactly the surface a long-lived - // `QueryDb` in the LSP/REPL holds to skip re-work across edits. - let mut db = ephapax_query::QueryDb::new(); - db.set_source_text(filename, content.clone()); +/// Load a program through the one front end. On failure, print the +/// details to stderr and return the one-line summary. +fn load_program(path: &Path, stdlib: Option) -> Result { + let stdlib_dir = ephapax_driver::stdlib_dir(stdlib); + ephapax_driver::load_program(path, stdlib_dir.as_deref()).map_err(|e| report_load_error(&e)) +} - // Parse as module - let module = db.parsed(filename).map_err(|errors| { - for error in &errors { - report_parse_error(filename, &content, error); +/// Describe a load failure. A type error is also printed to stderr with +/// its line and column; the returned one-line summary carries the error's +/// debug form, which names the exact `TypeError` variant. +fn report_load_error(e: &DriverError) -> String { + match e { + DriverError::Type { + module, + path, + source_text, + error, + } => { + report_type_error(&path.to_string_lossy(), source_text, error); + format!("Type error in module `{module}`: {error:?}") } - format!("{} parse error(s)", errors.len()) - })?; - - if verbose { - println!("{} Parsed {} declarations", "✓".green(), module.decls.len()); + _ => e.to_string(), } +} - // Type check - db.typed(filename).map_err(|e| { - report_type_error(filename, &content, &e); - format!("Type error: {}", e) - })?; +/// `ephapax run`: load, then execute `main`. By default the program is +/// compiled and the wasm runs under wasmtime, with `args` reachable +/// through `import Argv` and the program path as `argv[0]`. `--interp` +/// uses the legacy interpreter on the same loaded program. +fn run_file( + path: &Path, + interp: bool, + ffi_libs: &[String], + args: &[String], + verbose: bool, +) -> Result<(), String> { + if !interp && !ffi_libs.is_empty() { + return Err( + "-L/--ffi-lib only applies to the interpreter; add --interp to use it".to_string(), + ); + } + let program = load_program(path, None)?; if verbose { println!("{} Type check passed", "✓".green()); } + let module = program.into_merged(); + + if interp { + return run_interp(&module, ffi_libs, verbose); + } - // Run with interpreter + let main = module.decls.iter().find_map(|d| match d { + Decl::Fn { + name, + params, + ret_ty, + .. + } if name == "main" => Some((params.len(), ret_ty.clone())), + _ => None, + }); + let Some((n_params, ret_ty)) = main else { + if verbose { + println!("{} Module loaded (no main function)", "✓".green()); + } + return Ok(()); + }; + if n_params != 0 { + return Err(format!( + "`main` takes {n_params} parameter(s); `ephapax run` calls `main` with none" + )); + } + + let wasm = ephapax_wasm::compile_module(&module).map_err(|e| format!("Codegen error: {e}"))?; + wasmparser::validate(&wasm) + .map_err(|e| format!("emitted wasm failed structural validation: {e}"))?; + + let mut argv = vec![path.to_string_lossy().into_owned()]; + argv.extend(args.iter().cloned()); + let result = runner::run_main(&wasm, argv)?; + + // Print scalars the way the interpreter prints them. + use wasmtime::Val; + match (&ret_ty, result) { + (Ty::Base(BaseTy::Unit), _) => println!("()"), + (Ty::Base(BaseTy::Bool), Val::I32(n)) => println!("{}", n != 0), + (Ty::Base(BaseTy::I32), Val::I32(n)) => println!("{n}"), + (Ty::Base(BaseTy::I64), Val::I64(n)) => println!("{n}i64"), + (Ty::Base(BaseTy::F32), Val::F32(bits)) => println!("{}f32", f32::from_bits(bits)), + (Ty::Base(BaseTy::F64), Val::F64(bits)) => println!("{}", f64::from_bits(bits)), + // A string, pair or sum comes back as an address in linear memory. + _ => eprintln!( + "{} `main` returned a non-scalar value ({:?}); it is not printed", + "note:".yellow().bold(), + ret_ty + ), + } + Ok(()) +} + +fn run_interp( + module: &ephapax_syntax::Module, + ffi_libs: &[String], + verbose: bool, +) -> Result<(), String> { let mut interp = Interpreter::new(); // Load FFI libraries (native shared objects for __ffi() calls) @@ -318,7 +392,7 @@ fn run_file(path: &PathBuf, ffi_libs: &[String], verbose: bool) -> Result<(), St } } - interp.load_module(&module); + interp.load_module(module); // Look for main function and run it if interp.has_function("main") { @@ -368,75 +442,29 @@ fn reject_opt_level(opt_level: u8) -> Result<(), String> { fn check_files(files: &[PathBuf], mode: Option<&str>, verbose: bool) -> Result<(), String> { reject_mode_flag(mode)?; + let stdlib_dir = ephapax_driver::stdlib_dir(None); let mut errors = 0; let mut checked = 0; for path in files { - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - eprintln!("{} Cannot read {}: {}", "✗".red(), path.display(), e); - errors += 1; - continue; - } - }; - - let filename = path.to_str().unwrap_or("input"); - - // Parse → Desugar → Type check - // Try surface parser first (supports data/match/constructors). - // Falls back to core parser if surface parsing fails. - let module = match parse_surface_module(&content, filename) { - Ok(surface_module) => { - // Desugar surface → core - match desugar(&surface_module) { - Ok(core) => core, - Err(e) => { - eprintln!("{} {} desugar error: {}", "✗".red(), path.display(), e); - errors += 1; - continue; - } - } - } - Err(_) => { - // Fallback to core parser (for files without surface syntax) - match parse_module(&content, filename) { - Ok(m) => m, - Err(parse_errors) => { - for error in &parse_errors { - report_parse_error(filename, &content, error); - } - errors += 1; - continue; - } - } - } - }; - - // Type check, then the same unsupported-construct validation - // codegen runs (effects, raw FFI), so `check` never accepts a - // file that `compile` must reject (v2 plan E1). - let outcome = type_check_module(&module).map_err(|e| { - report_type_error(filename, &content, &e); - }); - let outcome = outcome.and_then(|()| { - ephapax_wasm::validate_supported(&module).map_err(|e| { - eprintln!("{} {}: Codegen error: {}", "✗".red(), path.display(), e); - }) - }); - match outcome { - Ok(()) => { + match ephapax_driver::load_program(path, stdlib_dir.as_deref()) { + Ok(program) => { if verbose { println!( "{} {} ({} declarations)", "✓".green(), path.display(), - module.decls.len() + program.root().core.decls.len() ); } checked += 1; } - Err(()) => { + Err(e) => { + let summary = report_load_error(&e); + // A type error was already printed with its position. + if !matches!(e, DriverError::Type { .. }) { + eprintln!("{} {}", "✗".red(), summary); + } errors += 1; } } @@ -474,131 +502,24 @@ fn compile_file( reject_mode_flag(mode)?; reject_opt_level(opt_level)?; - let base_dir = path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - // Stdlib search dir: --stdlib flag > $EPHAPAX_STDLIB > the source - // tree's stdlib/ (dev builds). None if none of those exist. - let stdlib_dir = stdlib - .or_else(|| std::env::var_os("EPHAPAX_STDLIB").map(PathBuf::from)) - .or_else(|| { - let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../stdlib"); - dev.canonicalize().ok() - }) - .filter(|p| p.is_dir()); - - // Try the multi-module pipeline first: load + parse the import graph - // (modules returned in topological order, dependencies first, root - // last). If the surface parser rejects the root file, fall back to - // the legacy single-file core-parser path. - let loaded = match import_resolver::load_program(path, &base_dir, stdlib_dir.as_deref()) { - Ok(l) => l, - Err(import_resolver::ResolveError::Parse { .. }) => { - // Surface parse failed on the root — legacy core-parser fallback. - let content = fs::read_to_string(path) - .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?; - let module = parse_module(&content, &filename).map_err(|errors| { - for error in &errors { - report_parse_error(&filename, &content, error); - } - format!("{} parse error(s)", errors.len()) - })?; - if verbose { - println!("{} Parsed {} declarations", "✓".green(), module.decls.len()); - } - type_check_module(&module).map_err(|e| { - report_type_error(&filename, &content, &e); - format!("Type error: {}", e) - })?; - if verbose { - println!("{} Type check passed", "✓".green()); - } - return finish_compile( - module, - &filename, - path, - output, - opt_level, - debug, - verify_ownership, - verbose, - ); - } - Err(e) => return Err(e.to_string()), - }; + let program = load_program(path, stdlib)?; if verbose { println!( "{} Resolved {} module(s) in import graph", "✓".green(), - loaded.len() + program.modules.len() ); - } - - // Multi-module desugar: chain a single DataRegistry across all modules - // so imported `data` and `extern type` items are visible. - let mut desugarer = Desugarer::with_registry(DataRegistry::new()); - let mut core_modules = Vec::with_capacity(loaded.len()); - for lm in &loaded { - let core = desugarer - .desugar_module(&lm.surface) - .map_err(|e| format!("Desugar error in {}: {}", lm.file_path.display(), e))?; - core_modules.push(core); - } - - // Multi-module typecheck: chain a single ModuleRegistry so imports - // resolve. - let mut module_registry = ModuleRegistry::new(); - for core in &core_modules { - type_check_module_with_registry(core, &mut module_registry) - .map_err(|e| format!("Type error in module `{}`: {:?}", core.name, e))?; - } - - // Merge all imported modules into the root for codegen. Wasm produces - // a single binary; each imported module's `Fn` declarations become - // part of the same wasm module. Duplicate fn/extern names across - // modules are dropped (first-seen wins). - let mut merged = core_modules - .pop() - .ok_or_else(|| "import graph produced no modules".to_string())?; - // Dedup keys: `fn:` for functions, `extern:` for whole - // extern blocks (an extern block emits one wasm import per fn item; - // re-emitting the same ABI block from another module would produce - // duplicate imports). First-seen wins. - fn dedup_key(d: &ephapax_syntax::Decl) -> Option { - match d { - ephapax_syntax::Decl::Fn { name, .. } => Some(format!("fn:{name}")), - ephapax_syntax::Decl::Extern { abi, .. } => Some(format!("extern:{abi}")), - _ => None, - } - } - let mut seen: std::collections::HashSet = - merged.decls.iter().filter_map(dedup_key).collect(); - for dep in core_modules.into_iter() { - for decl in dep.decls { - if let Some(key) = dedup_key(&decl) { - if !seen.insert(key) { - continue; // Skip duplicate fn / extern block. - } - } - merged.decls.push(decl); - } - } - let module = merged; - - if verbose { println!( "{} Parsed {} declarations in root module", "✓".green(), - module.decls.len() + program.root().core.decls.len() ); println!("{} Type check passed", "✓".green()); } finish_compile( - module, + program.into_merged(), &filename, path, output, @@ -609,8 +530,7 @@ fn compile_file( ) } -/// Codegen + optional ownership verification + output write. Shared by -/// the multi-module and legacy single-file compile paths. +/// Codegen + optional ownership verification + output write. #[allow(clippy::too_many_arguments)] // CLI entry point: 8 options vs the 7 default threshold fn finish_compile( module: ephapax_syntax::Module, @@ -994,17 +914,6 @@ fn handle_package_command(cmd: PackageCommands, verbose: bool) -> Result<(), Str } } -fn report_parse_error(_filename: &str, _source: &str, error: &ephapax_parser::ParseError) { - let span = error.span(); - eprintln!( - "{}: {} at {}..{}", - "Parse error".red().bold(), - error, - span.start, - span.end - ); -} - fn report_type_error(filename: &str, source: &str, error: &ephapax_typing::SpannedTypeError) { let span = error.span; if span.start != 0 || span.end != 0 { diff --git a/src/ephapax-cli/src/runner.rs b/src/ephapax-cli/src/runner.rs new file mode 100644 index 00000000..407a7de3 --- /dev/null +++ b/src/ephapax-cli/src/runner.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell + +//! `ephapax run`: execute a compiled module under wasmtime (v2 plan rung E4). +//! +//! Host imports provided, all in module `env`: +//! +//! - `print_i32(v)` and `print_string(ptr, len)`: the two imports every +//! compiled module declares. Each writes one line to stdout. +//! - `argv_count`, `argv_arg_len`, `argv_arg_get`: the contract in +//! `stdlib/Argv.eph`. +//! +//! A module that imports anything else fails to instantiate, with +//! wasmtime's "unknown import" error. + +use std::io::Write; + +use wasmtime::{Caller, Engine, Error, Linker, Memory, Module, Store, Val}; + +/// Host state: the argument vector, `argv[0]` first. +type Argv = Vec; + +/// Call the exported, parameterless `main` of `wasm` and return its result. +pub fn run_main(wasm: &[u8], argv: Argv) -> Result { + let engine = Engine::default(); + let module = Module::new(&engine, wasm).map_err(|e| format!("cannot load module: {e}"))?; + let mut linker = Linker::new(&engine); + define_host(&mut linker).map_err(|e| format!("cannot define host imports: {e}"))?; + let mut store = Store::new(&engine, argv); + let instance = linker + .instantiate(&mut store, &module) + .map_err(|e| format!("cannot instantiate module: {e}"))?; + let main = instance + .get_func(&mut store, "main") + .ok_or("module exports no `main`")?; + // Every Ephapax function returns exactly one value. + let mut results = [Val::I32(0)]; + main.call(&mut store, &[], &mut results) + .map_err(|e| format!("Runtime error: {}", e.root_cause()))?; + Ok(results[0]) +} + +fn memory(caller: &mut Caller<'_, Argv>) -> wasmtime::Result { + caller + .get_export("memory") + .and_then(|e| e.into_memory()) + .ok_or_else(|| Error::msg("module exports no `memory`")) +} + +fn define_host(linker: &mut Linker) -> wasmtime::Result<()> { + linker.func_wrap("env", "print_i32", |v: i32| println!("{v}"))?; + linker.func_wrap( + "env", + "print_string", + |mut caller: Caller<'_, Argv>, ptr: i32, len: i32| -> wasmtime::Result<()> { + let mem = memory(&mut caller)?; + // Wasm addresses are unsigned 32-bit. + let (start, len) = (ptr as u32 as usize, len as u32 as usize); + let bytes = start + .checked_add(len) + .and_then(|end| mem.data(&caller).get(start..end)) + .ok_or_else(|| Error::msg("print_string: range is outside linear memory"))?; + let mut out = std::io::stdout().lock(); + out.write_all(bytes)?; + out.write_all(b"\n")?; + Ok(()) + }, + )?; + linker.func_wrap("env", "argv_count", |caller: Caller<'_, Argv>| { + caller.data().len() as i32 + })?; + linker.func_wrap( + "env", + "argv_arg_len", + |caller: Caller<'_, Argv>, idx: i32| arg(caller.data(), idx).map_or(-1, |a| a.len() as i32), + )?; + linker.func_wrap( + "env", + "argv_arg_get", + |mut caller: Caller<'_, Argv>, + idx: i32, + buf_ptr: i32, + buf_len: i32| + -> wasmtime::Result { + let Some(bytes) = arg(caller.data(), idx).map(|a| a.as_bytes().to_vec()) else { + return Ok(-1); + }; + // A negative or too-small `buf_len` is "insufficient". + if usize::try_from(buf_len).map_or(true, |n| n < bytes.len()) { + return Ok(-1); + } + // A buffer outside linear memory is a program bug: trap. + let mem = memory(&mut caller)?; + mem.write(&mut caller, buf_ptr as u32 as usize, &bytes)?; + Ok(bytes.len() as i32) + }, + )?; + Ok(()) +} + +/// Argument `idx`, or `None` when `idx` is out of range (negative included). +fn arg(argv: &Argv, idx: i32) -> Option<&String> { + argv.get(usize::try_from(idx).ok()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The one thing an .eph program cannot observe yet: the bytes + /// `argv_arg_get` copies into linear memory. + #[test] + fn argv_arg_get_copies_the_argument_bytes() { + let engine = Engine::default(); + let module = Module::new( + &engine, + r#"(module + (import "env" "argv_arg_get" (func $get (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "get") (param i32 i32 i32) (result i32) + (call $get (local.get 0) (local.get 1) (local.get 2))))"#, + ) + .unwrap(); + let mut linker = Linker::new(&engine); + define_host(&mut linker).unwrap(); + let argv = vec!["prog.eph".to_string(), "héllo".to_string()]; + let mut store = Store::new(&engine, argv); + let instance = linker.instantiate(&mut store, &module).unwrap(); + let get = instance + .get_typed_func::<(i32, i32, i32), i32>(&mut store, "get") + .unwrap(); + let mem = instance.get_memory(&mut store, "memory").unwrap(); + + // "héllo" is 6 bytes of UTF-8. + assert_eq!(get.call(&mut store, (1, 16, 64)).unwrap(), 6); + assert_eq!(&mem.data(&store)[16..22], "héllo".as_bytes()); + // Buffer too small, negative length, index out of range: -1. + assert_eq!(get.call(&mut store, (1, 16, 5)).unwrap(), -1); + assert_eq!(get.call(&mut store, (1, 16, -1)).unwrap(), -1); + assert_eq!(get.call(&mut store, (2, 16, 64)).unwrap(), -1); + assert_eq!(get.call(&mut store, (-1, 16, 64)).unwrap(), -1); + // Buffer outside linear memory: a trap, not a silent -1. + assert!(get.call(&mut store, (1, 65_534, 64)).is_err()); + } +} diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs new file mode 100644 index 00000000..d721b0a4 --- /dev/null +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// driver_agreement.rs, v2 plan rung E3 gate. +// +// Every tool loads a program through `ephapax-driver`, so every tool must +// give the same verdict on every .eph file in the repo. The reference +// verdict is `ephapax_driver::load_program`; `check`, `compile`, `run`, +// the REPL and the query engine are each compared with it. The LSP is a +// binary crate, so its twin of this test lives in +// src/ephapax-lsp/src/main.rs. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use ephapax_driver::DriverError; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repo root") +} + +/// All .eph files under the repo, repo-root-relative (same walk as +/// eph_corpus_gate.rs). +fn corpus(root: &Path) -> Vec { + fn walk(dir: &Path, root: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if path.is_dir() { + if !matches!( + name.as_str(), + "target" | ".git" | ".claude" | "node_modules" | ".zig-cache-global" + ) { + walk(&path, root, out); + } + } else if name.ends_with(".eph") { + let rel = path.strip_prefix(root).expect("under root"); + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out +} + +/// The text every CLI command prints for each way a program fails to load. +const LOAD_ERRORS: [&str; 6] = [ + "cannot read", + "import cycle", + "Parse error", + "Desugar error", + "Type error", + "Codegen error", +]; + +fn load_error_text(e: &DriverError) -> &'static str { + match e { + DriverError::Io { .. } => LOAD_ERRORS[0], + DriverError::Cycle { .. } => LOAD_ERRORS[1], + DriverError::Parse { .. } => LOAD_ERRORS[2], + DriverError::Desugar { .. } => LOAD_ERRORS[3], + DriverError::Type { .. } => LOAD_ERRORS[4], + DriverError::Unsupported { .. } => LOAD_ERRORS[5], + } +} + +#[test] +fn every_entry_point_agrees_on_every_corpus_file() { + let root = repo_root(); + let tmp = tempfile::tempdir().expect("tempdir"); + let wasm_out = tmp.path().join("agree.wasm"); + let stdlib = ephapax_driver::stdlib_dir(None); + let mut repl = ephapax_repl::Repl::new().expect("REPL initialises"); + let files = corpus(&root); + assert!(files.len() > 100, "corpus walk found too few files"); + + let mut failures = Vec::new(); + let mut loads = 0; + for rel in &files { + let path = root.join(rel); + let want = ephapax_driver::load_program(&path, stdlib.as_deref()) + .err() + .map(|e| load_error_text(&e)); + loads += usize::from(want.is_none()); + + // check and compile: same exit status, same kind of error. + let commands: [(&str, Vec<&str>); 3] = [ + ("check", vec!["check", rel]), + ( + "compile", + vec![ + "compile", + rel, + "-o", + wasm_out.to_str().expect("utf8 tmp path"), + ], + ), + ("run", vec!["run", rel]), + ]; + for (name, args) in commands { + let out = Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(&root) + .args(&args) + .output() + .expect("spawn ephapax"); + let stderr = String::from_utf8_lossy(&out.stderr); + let agrees = match want { + Some(text) => !out.status.success() && stderr.contains(text), + // `run` goes on to execute `main`, which may fail for its + // own reasons (a host import `run` does not provide); it + // must not fail to *load*. + None if name == "run" => !LOAD_ERRORS.iter().any(|t| stderr.contains(t)), + None => out.status.success(), + }; + if !agrees { + failures.push(format!( + "{rel}: `{name}` exit {:?}, driver says {want:?}; stderr:\n{stderr}", + out.status.code() + )); + } + } + + // REPL `:load`. + if repl.load_path(&path).is_ok() != want.is_none() { + failures.push(format!("{rel}: REPL disagrees, driver says {want:?}")); + } + + // Query engine (the id is the file path, so imports resolve). + let id = path.to_string_lossy(); + let mut db = ephapax_query::QueryDb::new(); + db.set_source_text( + &id, + std::fs::read_to_string(&path).expect("read corpus file"), + ); + if (db.parsed(&id).is_ok() && db.typed(&id).is_ok()) != want.is_none() { + failures.push(format!( + "{rel}: query engine disagrees, driver says {want:?}" + )); + } + } + + println!("driver agreement: {} files, {} load", files.len(), loads); + assert!(loads > 0, "no corpus file loads: gate misconfigured"); + assert!( + failures.is_empty(), + "entry points disagree with ephapax-driver:\n{}", + failures.join("\n") + ); +} + +/// An integer literal above `i64::MAX` used to become 0 without a +/// diagnostic. It is now `ParseError::IntegerOutOfRange` on every command. +#[test] +fn out_of_range_integer_literal_is_a_parse_error() { + let tmp = tempfile::tempdir().expect("tempdir"); + let run = |body: &str, args: &[&str]| { + std::fs::write( + tmp.path().join("lit.eph"), + format!("module lit\n\nfn main(): I64 = {body}\n"), + ) + .expect("write program"); + Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(tmp.path()) + .args(args) + .output() + .expect("spawn ephapax") + }; + let commands: [&[&str]; 4] = [ + &["check", "lit.eph"], + &["compile", "lit.eph"], + &["run", "lit.eph"], + &["run", "--interp", "lit.eph"], + ]; + + for body in ["9223372036854775808", "-(9223372036854775808)"] { + for args in commands { + let out = run(body, args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() + && stderr.contains("Integer literal `9223372036854775808` is out of range"), + "`{body}` under {args:?}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } + } + + // The largest literal still works. + let out = run("9223372036854775807", &["run", "lit.eph"]); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "9223372036854775807i64" + ); +} diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs new file mode 100644 index 00000000..e569e7da --- /dev/null +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// run_wasm.rs, v2 plan rung E4 gate. +// +// `ephapax run` compiles the program and executes the wasm (wasmtime, +// in-process), forwarding its trailing arguments through the host imports +// `stdlib/Argv.eph` declares. The interpreter is `run --interp`; it is +// incomplete, and tests/interp-known-failures.txt lists the compiling +// corpus files on which it disagrees with the wasm path. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repo root") +} + +fn ephapax(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(dir) + .args(args) + .output() + .expect("spawn ephapax") +} + +/// stdout of a successful `ephapax `, trimmed. +fn stdout_of(dir: &Path, args: &[&str]) -> String { + let out = ephapax(dir, args); + assert!( + out.status.success(), + "ephapax {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +/// Paths (one per line, `#` comments) in a repo-root-relative list file. +fn list(root: &Path, file: &str) -> BTreeSet { + std::fs::read_to_string(root.join(file)) + .unwrap_or_else(|e| panic!("read {file}: {e}")) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(str::to_string) + .collect() +} + +/// Probe p1 (data/match, recursion, `let!`): 40 + 2 + 120. +#[test] +fn run_executes_the_compiled_wasm() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::copy( + repo_root().join("tests/probes/p1.eph.txt"), + tmp.path().join("p1.eph"), + ) + .expect("stage p1"); + assert_eq!(stdout_of(tmp.path(), &["run", "p1.eph"]), "162"); + // A bare file argument runs it the same way. + assert_eq!(stdout_of(tmp.path(), &["p1.eph"]), "162"); +} + +/// An .eph program cannot yet read bytes out of a raw buffer, so it +/// reports the argument count and byte lengths; the bytes `argv_arg_get` +/// copies are checked host-side (src/runner.rs). +#[test] +fn run_forwards_arguments_through_argv() { + let tmp = tempfile::tempdir().expect("tempdir"); + let write = |name: &str, body: &str| { + let src = format!("module {name}\n\nimport Argv\n\nfn main(): I32 = {body}\n"); + std::fs::write(tmp.path().join(format!("{name}.eph")), src).expect("write program"); + }; + // 1000 * argc + byte length of argv[1] (-1 when there is none). + write( + "count_len", + "Argv.argv_count() * 1000 + Argv.argv_arg_len(1)", + ); + write("arg0_len", "Argv.argv_arg_len(0)"); + write("last_len", "Argv.argv_arg_len(Argv.argv_count() - 1)"); + + let run = |args: &[&str]| stdout_of(tmp.path(), args); + // No arguments: argv is just the program path, so argc is 1. + assert_eq!(run(&["run", "count_len.eph"]), "999"); + assert_eq!(run(&["run", "count_len.eph", "hello"]), "2005"); + assert_eq!(run(&["run", "count_len.eph", "ab", "c", "héllo"]), "4002"); + // argv[0] is the program path as given. + assert_eq!( + run(&["run", "arg0_len.eph", "x"]), + "arg0_len.eph".len().to_string() + ); + // Byte length, not character count: "héllo" is 6 bytes of UTF-8. + assert_eq!(run(&["run", "last_len.eph", "ab", "c", "héllo"]), "6"); +} + +/// `run` and `run --interp` must give the same result on every compiling +/// corpus file, except the listed known failures. The list is a ratchet: +/// an entry that starts to agree must be removed. +#[test] +fn interpreter_and_wasm_agree_except_known_failures() { + let root = repo_root(); + let known = list(&root, "tests/interp-known-failures.txt"); + let mut disagree = BTreeSet::new(); + for rel in list(&root, "tests/eph-corpus-compiles.txt") { + let wasm = ephapax(&root, &["run", &rel]); + let interp = ephapax(&root, &["run", "--interp", &rel]); + if (wasm.status.success(), &wasm.stdout) != (interp.status.success(), &interp.stdout) { + disagree.insert(rel); + } + } + assert_eq!( + disagree, known, + "interpreter/wasm disagreements differ from tests/interp-known-failures.txt" + ); +} diff --git a/src/ephapax-cli/tests/v2_stdlib_import.rs b/src/ephapax-cli/tests/v2_stdlib_import.rs index e9786bac..4613a284 100644 --- a/src/ephapax-cli/tests/v2_stdlib_import.rs +++ b/src/ephapax-cli/tests/v2_stdlib_import.rs @@ -4,7 +4,7 @@ // Stdlib import resolution — `import Argv` from a program that is NOT a // sibling of stdlib/Argv.eph. // -// Resolution order (import_resolver::load_program): +// Resolution order (ephapax-driver, src/ephapax-driver/src/resolver.rs): // 1. literal path / module-declaration index under the program's dir, // 2. module-declaration index under the stdlib dir // (--stdlib flag > $EPHAPAX_STDLIB > repo stdlib/ for dev builds). diff --git a/src/ephapax-driver/Cargo.toml b/src/ephapax-driver/Cargo.toml new file mode 100644 index 00000000..ad11ff74 --- /dev/null +++ b/src/ephapax-driver/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell + +[package] +name = "ephapax-driver" +description = "The one front end for every Ephapax tool: import resolution, parse, desugar, check" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +ephapax-syntax.workspace = true +ephapax-parser.workspace = true +ephapax-surface = { path = "../ephapax-surface" } +ephapax-desugar = { path = "../ephapax-desugar" } +ephapax-typing.workspace = true +ephapax-wasm.workspace = true +thiserror.workspace = true diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs new file mode 100644 index 00000000..e1e33fca --- /dev/null +++ b/src/ephapax-driver/src/lib.rs @@ -0,0 +1,251 @@ +#![forbid(unsafe_code)] +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell + +//! The one front end for every Ephapax tool (v2 plan rung E3). +//! +//! `check`, `compile`, `run`, the REPL, the LSP and the query engine all +//! load a program through this crate, so they accept and reject the same +//! programs: +//! +//! ```text +//! resolve imports → parse (surface, core fallback) → desugar +//! → type-check → reject constructs codegen cannot lower +//! ``` +//! +//! [`parse_program`] / [`parse_source`] run the first three steps and +//! [`check_program`] the last two; [`load_program`] / [`load_source`] run +//! all five. + +mod resolver; + +use std::path::{Path, PathBuf}; + +use ephapax_desugar::{DataRegistry, DesugarError, Desugarer}; +use ephapax_parser::ParseError; +use ephapax_syntax::{Decl, Module}; +use ephapax_typing::{type_check_module_with_registry, ModuleRegistry, SpannedTypeError}; +use ephapax_wasm::CodegenError; + +/// One module of a loaded program, desugared to core. +#[derive(Debug, Clone)] +pub struct LoadedModule { + /// Module path as written in the source (e.g. `hypatia/ui/gui`). + pub logical_path: String, + /// File the module was read from (the given name for an in-memory root). + pub file_path: PathBuf, + /// Source text; spans in `core` index into it. + pub source: String, + pub core: Module, +} + +/// A program: the root module and everything it imports, dependencies +/// first, root last. +#[derive(Debug, Clone)] +pub struct Program { + pub modules: Vec, +} + +impl Program { + /// The module the program was loaded from. + pub fn root(&self) -> &LoadedModule { + self.modules + .last() + .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. Duplicate + /// fn / extern-block names across modules are dropped (first-seen + /// wins, the root first). + pub fn into_merged(self) -> Module { + // Dedup keys: `fn:` for functions, `extern:` for whole + // extern blocks (an extern block emits one wasm import per fn item; + // re-emitting the same ABI block from another module would produce + // duplicate imports). + fn dedup_key(d: &Decl) -> Option { + match d { + Decl::Fn { name, .. } => Some(format!("fn:{name}")), + Decl::Extern { abi, .. } => Some(format!("extern:{abi}")), + _ => None, + } + } + let mut modules = self.modules; + let mut merged = modules + .pop() + .expect("a program has at least its root module") + .core; + let mut seen: std::collections::HashSet = + merged.decls.iter().filter_map(dedup_key).collect(); + for dep in modules { + for decl in dep.core.decls { + if let Some(key) = dedup_key(&decl) { + if !seen.insert(key) { + continue; + } + } + merged.decls.push(decl); + } + } + merged + } +} + +/// Why a program did not load. +#[derive(Debug, Clone, thiserror::Error)] +pub enum DriverError { + #[error("cannot read {}: {message}", path.display())] + Io { path: PathBuf, message: String }, + + #[error("import cycle: {}", path.join(" -> "))] + Cycle { path: Vec }, + + #[error("{}: Parse error: {}", path.display(), join_parse_errors(errors))] + Parse { + path: PathBuf, + errors: Vec, + }, + + #[error("Desugar error in {}: {error}", path.display())] + Desugar { path: PathBuf, error: DesugarError }, + + /// `source_text` is the text of the module the error is in + /// (`error.span` indexes into it). + #[error("Type error in module `{module}`: {error}")] + Type { + module: String, + path: PathBuf, + source_text: String, + error: Box, + }, + + /// The program type-checks but uses a construct with no wasm lowering. + #[error("{}: Codegen error: {error}", path.display())] + Unsupported { path: PathBuf, error: CodegenError }, +} + +/// Every parse error with its byte span, on one line. +fn join_parse_errors(errors: &[ParseError]) -> String { + errors + .iter() + .map(|e| format!("{e} at {}..{}", e.span().start, e.span().end)) + .collect::>() + .join("; ") +} + +/// The stdlib search directory: `explicit` (a `--stdlib` flag) > +/// `$EPHAPAX_STDLIB` > the source tree's `stdlib/` (dev builds). `None` if +/// the chosen one is not a directory. +pub fn stdlib_dir(explicit: Option) -> Option { + explicit + .or_else(|| std::env::var_os("EPHAPAX_STDLIB").map(PathBuf::from)) + .or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../stdlib") + .canonicalize() + .ok() + }) + .filter(|p| p.is_dir()) +} + +/// Resolve imports, parse and desugar the program rooted at the file +/// `root`. Imports are searched next to `root`, then in `stdlib_dir`. +pub fn parse_program(root: &Path, stdlib_dir: Option<&Path>) -> Result { + let source = std::fs::read_to_string(root).map_err(|e| DriverError::Io { + path: root.to_path_buf(), + message: e.to_string(), + })?; + // A bare file name has the empty path as its parent: use the current + // directory, so the module index of that directory is still scanned. + let base_dir = root + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + parse_source(&source, &root.to_string_lossy(), Some(base_dir), stdlib_dir) +} + +/// [`parse_program`] for a root module held in memory (an editor buffer, +/// a REPL line). `name` stands in for its file name; `base_dir` is where +/// its imports are searched (`None`: only `stdlib_dir`). +pub fn parse_source( + source: &str, + name: &str, + base_dir: Option<&Path>, + stdlib_dir: Option<&Path>, +) -> Result { + let resolved = resolver::resolve( + source.to_string(), + PathBuf::from(name), + base_dir, + stdlib_dir, + )?; + + // Chain a single DataRegistry across all modules so imported `data` + // and `extern type` items are visible. + let mut desugarer = Desugarer::with_registry(DataRegistry::new()); + let mut modules = Vec::with_capacity(resolved.len()); + for m in resolved { + let core = match m.parsed { + resolver::Parsed::Surface(surface) => { + desugarer + .desugar_module(&surface) + .map_err(|error| DriverError::Desugar { + path: m.file_path.clone(), + error, + })? + } + resolver::Parsed::Core(core) => core, + }; + modules.push(LoadedModule { + logical_path: m.logical_path, + file_path: m.file_path, + source: m.source, + core, + }); + } + Ok(Program { modules }) +} + +/// Type-check every module (a single registry is chained so imports +/// resolve), then run the same unsupported-construct validation codegen +/// runs, so no tool accepts a program that `compile` must reject. +pub fn check_program(program: &Program) -> Result<(), DriverError> { + let mut registry = ModuleRegistry::new(); + for m in &program.modules { + type_check_module_with_registry(&m.core, &mut registry).map_err(|error| { + DriverError::Type { + module: m.core.name.to_string(), + path: m.file_path.clone(), + source_text: m.source.clone(), + error: Box::new(error), + } + })?; + } + for m in &program.modules { + ephapax_wasm::validate_supported(&m.core).map_err(|error| DriverError::Unsupported { + path: m.file_path.clone(), + error, + })?; + } + Ok(()) +} + +/// [`parse_program`] then [`check_program`]. +pub fn load_program(root: &Path, stdlib_dir: Option<&Path>) -> Result { + let program = parse_program(root, stdlib_dir)?; + check_program(&program)?; + Ok(program) +} + +/// [`parse_source`] then [`check_program`]. +pub fn load_source( + source: &str, + name: &str, + base_dir: Option<&Path>, + stdlib_dir: Option<&Path>, +) -> Result { + let program = parse_source(source, name, base_dir, stdlib_dir)?; + check_program(&program)?; + Ok(program) +} diff --git a/src/ephapax-driver/src/resolver.rs b/src/ephapax-driver/src/resolver.rs new file mode 100644 index 00000000..7b21e351 --- /dev/null +++ b/src/ephapax-driver/src/resolver.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +//! Multi-file module loader. +//! +//! Resolves `import a/b/c` declarations by reading the matching file +//! `/a/b/c.eph` relative to the root module. Walks the import +//! graph transitively, detects cycles, and returns the modules in +//! topological order (dependencies before dependents) so the rest of the +//! pipeline (desugar → typecheck → codegen) sees each module after its +//! dependencies have already populated the registries. +//! +//! Scope today is deliberately small: file-system resolution against a +//! single base directory, no package-manifest support yet, no version +//! ranges. Both the dot-form (`Foo.Bar.Baz`) and slash-form +//! (`hyperpolymath/ephapax/test`) are recognised, they map to the same +//! file path with the separator normalised to `/`. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use ephapax_parser::{parse_module, parse_surface_module}; +use ephapax_surface::SurfaceModule; +use ephapax_syntax::{Import, Module}; + +use crate::DriverError; + +/// A module as the parser produced it. +pub(crate) enum Parsed { + /// The v2 surface grammar (needs desugaring). + Surface(SurfaceModule), + /// Legacy fallback: the surface parser rejected the file and the core + /// parser accepted it. Already a core module. + Core(Module), +} + +impl Parsed { + fn imports(&self) -> &[Import] { + match self { + Parsed::Surface(m) => &m.imports, + Parsed::Core(m) => &m.imports, + } + } +} + +pub(crate) struct ResolvedModule { + /// Module path as written in the source (e.g. `hypatia/ui/gui`). + pub logical_path: String, + /// File the module was read from (the given name for an in-memory root). + pub file_path: PathBuf, + pub source: String, + pub parsed: Parsed, +} + +/// Walk the import graph rooted at `root_source` and return all modules in +/// topological order, dependencies first, root last. Imports are searched +/// under `base_dir` (the directory of the root file; `None` for a source +/// that has no file), then under `stdlib_dir`, this is what lets a +/// program anywhere on disk say `import Argv` and reach the shipped stdlib. +pub(crate) fn resolve( + root_source: String, + root_file: PathBuf, + base_dir: Option<&Path>, + stdlib_dir: Option<&Path>, +) -> Result, DriverError> { + let mut r = Resolver { + base_dir, + stdlib_dir, + mod_index: None, + loaded: HashMap::new(), + order: Vec::new(), + visiting: HashSet::new(), + stack: Vec::new(), + }; + let root_logical = first_module_declaration(&root_source).unwrap_or_else(|| { + // No explicit `module` header, synthesise a logical path from the + // filename stem. + root_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("root") + .to_string() + }); + r.visit(&root_logical, Some((root_file, root_source)))?; + // Re-arrange into the visit (post-order) order: dependencies first. + let Resolver { + mut loaded, order, .. + } = r; + Ok(order + .into_iter() + .filter_map(|name| loaded.remove(&name)) + .collect()) +} + +struct Resolver<'a> { + base_dir: Option<&'a Path>, + stdlib_dir: Option<&'a Path>, + /// `declared module name → file path`, built on the first import that + /// misses its literal path (so a program with no such import never + /// pays for the directory walk). + mod_index: Option>, + loaded: HashMap, + order: Vec, + visiting: HashSet, + stack: Vec, +} + +impl Resolver<'_> { + /// Where the import `logical` lives on disk. + /// + /// 1. Literal path under base_dir (`a/b/c` → `/a/b/c.eph`). + /// 2. The module-declaration index: files anywhere under base_dir that + /// declare `module a/b/c`, which matches existing corpora like + /// hypatia/src/ui/gossamer/, then the same under the stdlib + /// directory. Local files always shadow stdlib modules of the same + /// name. + /// + /// On a miss, the literal path is returned so the IO error names a + /// useful location. + fn locate(&mut self, logical: &str) -> PathBuf { + let direct = logical_to_file_path(logical, self.base_dir.unwrap_or(Path::new(""))); + if self.base_dir.is_some() && direct.exists() { + return direct; + } + let (base_dir, stdlib_dir) = (self.base_dir, self.stdlib_dir); + let index = self.mod_index.get_or_insert_with(|| { + let mut idx = base_dir.map(scan_module_index).unwrap_or_default(); + if let Some(sd) = stdlib_dir { + for (name, path) in scan_module_index(sd) { + idx.entry(name).or_insert(path); + } + } + idx + }); + index.get(logical).cloned().unwrap_or(direct) + } + + /// `root` is the root module's `(file, source)`; imports pass `None` + /// and are located and read from disk. + fn visit(&mut self, logical: &str, root: Option<(PathBuf, String)>) -> Result<(), DriverError> { + if self.loaded.contains_key(logical) { + return Ok(()); + } + if self.visiting.contains(logical) { + let mut cycle = self.stack.clone(); + cycle.push(logical.to_string()); + return Err(DriverError::Cycle { path: cycle }); + } + self.visiting.insert(logical.to_string()); + self.stack.push(logical.to_string()); + + let (file_path, source) = match root { + Some(r) => r, + None => { + let file_path = self.locate(logical); + let source = std::fs::read_to_string(&file_path).map_err(|e| DriverError::Io { + path: file_path.clone(), + message: e.to_string(), + })?; + (file_path, source) + } + }; + + // Surface grammar first. A file it rejects may still be a + // core-syntax file, so fall back to the core parser and report + // that parser's errors if both fail. + let parsed = match parse_surface_module(&source, logical) { + Ok(m) => Parsed::Surface(m), + Err(_) => match parse_module(&source, logical) { + Ok(m) => Parsed::Core(m), + Err(errors) => { + return Err(DriverError::Parse { + path: file_path, + errors, + }) + } + }, + }; + + // Recurse into imports BEFORE inserting this module so that the + // post-order visit places dependencies before this module. + let deps: Vec = parsed + .imports() + .iter() + .map(|i| normalise_path(i.module.as_str())) + .collect(); + for dep in &deps { + self.visit(dep, None)?; + } + + self.loaded.insert( + logical.to_string(), + ResolvedModule { + logical_path: logical.to_string(), + file_path, + source, + parsed, + }, + ); + self.order.push(logical.to_string()); + self.stack.pop(); + self.visiting.remove(logical); + Ok(()) + } +} + +/// Walk `base_dir` recursively, reading the first `module a/b/c` line of +/// every `.eph` file we find, and return a map from declared module name +/// to file path. Files without a `module` header are skipped. +fn scan_module_index(base_dir: &Path) -> HashMap { + let mut idx = HashMap::new(); + let mut stack = vec![base_dir.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|s| s.to_str()) != Some("eph") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + if let Some(name) = first_module_declaration(&source) { + idx.entry(name).or_insert(path); + } + } + } + idx +} + +fn first_module_declaration(source: &str) -> Option { + for line in source.lines() { + let line = line.trim_start(); + if let Some(rest) = line.strip_prefix("module") { + let rest = rest.trim(); + // Take everything up to a whitespace, comma, or comment marker. + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '/')) + .unwrap_or(rest.len()); + let name = &rest[..end]; + if !name.is_empty() { + return Some(normalise_path(name)); + } + } + } + None +} + +fn normalise_path(raw: &str) -> String { + raw.replace('.', "/") +} + +fn logical_to_file_path(logical: &str, base_dir: &Path) -> PathBuf { + let mut p = base_dir.to_path_buf(); + for seg in logical.split('/') { + p.push(seg); + } + p.set_extension("eph"); + p +} diff --git a/src/ephapax-lsp/Cargo.toml b/src/ephapax-lsp/Cargo.toml index 26fa92ef..335ccf29 100644 --- a/src/ephapax-lsp/Cargo.toml +++ b/src/ephapax-lsp/Cargo.toml @@ -8,8 +8,7 @@ description = "Language Server Protocol implementation for Ephapax" [dependencies] ephapax-syntax = { path = "../ephapax-syntax" } -ephapax-parser = { path = "../ephapax-parser" } -ephapax-typing = { path = "../ephapax-typing" } +ephapax-driver = { path = "../ephapax-driver" } tower-lsp = "0.20" tokio = { version = "1", features = ["full"] } serde_json = "1.0" diff --git a/src/ephapax-lsp/src/main.rs b/src/ephapax-lsp/src/main.rs index a620346f..67ff7a3c 100644 --- a/src/ephapax-lsp/src/main.rs +++ b/src/ephapax-lsp/src/main.rs @@ -12,9 +12,8 @@ //! - Document symbols //! - Keyword and declaration completions -use ephapax_parser::parse_module; +use ephapax_driver::DriverError; use ephapax_syntax::{Decl, Expr, ExprKind, ExternItem, Module, Span, Ty}; -use ephapax_typing::type_check_module; use std::collections::HashMap; use std::sync::Mutex; use tower_lsp::jsonrpc::Result; @@ -83,57 +82,11 @@ impl Backend { /// Parse, type-check, and cache analysis for a document. async fn analyze_document(&self, uri: &Url, text: &str) -> Vec { - let mut diagnostics = Vec::new(); - let mut declarations = Vec::new(); - let mut module_opt = None; - - // Parse the document - match parse_module(text, "document") { - Ok(module) => { - // Extract declarations - declarations = extract_declarations(&module, text); - module_opt = Some(module.clone()); - - // Type-check - if let Err(err) = type_check_module(&module) { - diagnostics.push(Diagnostic { - range: span_to_range(text, err.span), - severity: Some(DiagnosticSeverity::ERROR), - code: None, - source: Some("ephapax-typing".to_string()), - message: format!("Type error: {}", err), - related_information: None, - tags: None, - code_description: None, - data: None, - }); - } - } - Err(errors) => { - for err in errors { - diagnostics.push(Diagnostic { - range: Range { - start: Position { - line: 0, - character: 0, - }, - end: Position { - line: 0, - character: 0, - }, - }, - severity: Some(DiagnosticSeverity::ERROR), - code: None, - source: Some("ephapax".to_string()), - message: format!("Parse error: {:?}", err), - related_information: None, - tags: None, - code_description: None, - data: None, - }); - } - } - } + let (diagnostics, module_opt) = analyze(uri, text); + let declarations = module_opt + .as_ref() + .map(|m| extract_declarations(m, text)) + .unwrap_or_default(); // Cache the state if let Ok(mut docs) = self.documents.lock() { @@ -731,6 +684,57 @@ fn format_ty(ty: &Ty) -> String { } /// Convert byte offset span to LSP range. +/// Load a document through the one front end (`ephapax-driver`, the same +/// pipeline `ephapax check` runs) and turn the outcome into diagnostics. +/// Imports resolve next to the document's file, then in the stdlib. +/// Returns the document's own core module when it parses. +fn analyze(uri: &Url, text: &str) -> (Vec, Option) { + fn diagnostic(range: Range, source: &str, message: String) -> Diagnostic { + Diagnostic { + range, + severity: Some(DiagnosticSeverity::ERROR), + source: Some(source.to_string()), + message, + ..Diagnostic::default() + } + } + + let base_dir = uri + .to_file_path() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + let stdlib = ephapax_driver::stdlib_dir(None); + let program = match ephapax_driver::parse_source( + text, + "document", + base_dir.as_deref(), + stdlib.as_deref(), + ) { + Ok(program) => program, + Err(e) => { + return ( + vec![diagnostic(Range::default(), "ephapax", e.to_string())], + None, + ) + } + }; + let diagnostics = match ephapax_driver::check_program(&program) { + Ok(()) => Vec::new(), + Err(e) => { + // A type error in this document has a position. Any other + // failure, or one in an imported module, goes at the top. + let range = match &e { + DriverError::Type { path, error, .. } if *path == program.root().file_path => { + span_to_range(text, error.span) + } + _ => Range::default(), + }; + vec![diagnostic(range, "ephapax-typing", e.to_string())] + } + }; + (diagnostics, Some(program.root().core.clone())) +} + fn span_to_range(text: &str, span: Span) -> Range { let start = offset_to_position(text, span.start); let end = offset_to_position(text, span.end); @@ -905,3 +909,52 @@ async fn main() { let (service, socket) = LspService::new(Backend::new); Server::new(stdin, stdout, socket).serve(service).await; } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + fn eph_files(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if path.is_dir() { + if !matches!( + name.as_str(), + "target" | ".git" | ".claude" | "node_modules" + ) { + eph_files(&path, out); + } + } else if name.ends_with(".eph") { + out.push(path); + } + } + } + + /// v2 plan rung E3: the LSP reports no diagnostic exactly when + /// `ephapax-driver` loads the file, on every .eph file in the repo + /// (LSP twin of src/ephapax-cli/tests/driver_agreement.rs). + #[test] + fn diagnostics_agree_with_the_driver_on_every_corpus_file() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repo root"); + let stdlib = ephapax_driver::stdlib_dir(None); + let mut files = Vec::new(); + eph_files(&root, &mut files); + assert!(files.len() > 100, "corpus walk found too few files"); + + let mut clean = 0; + for path in &files { + let text = std::fs::read_to_string(path).expect("read corpus file"); + let uri = Url::from_file_path(path).expect("file URL"); + let lsp_ok = analyze(&uri, &text).0.is_empty(); + let driver_ok = ephapax_driver::load_program(path, stdlib.as_deref()).is_ok(); + assert_eq!(lsp_ok, driver_ok, "{}", path.display()); + clean += usize::from(lsp_ok); + } + assert!(clean > 0, "no corpus file is clean: gate misconfigured"); + } +} diff --git a/src/ephapax-parser/src/error.rs b/src/ephapax-parser/src/error.rs index 648bedba..909d1fd3 100644 --- a/src/ephapax-parser/src/error.rs +++ b/src/ephapax-parser/src/error.rs @@ -35,6 +35,11 @@ pub enum ParseError { /// claim a guarantee that does not hold (v2 plan rung E1). #[error("Annotation `@{name}` is not enforced: the compiler does not check it, so it is rejected rather than silently ignored")] AnnotationNotEnforced { name: String, span: Span }, + + /// An integer literal above `i64::MAX`. It used to become 0 without + /// a diagnostic (v2 plan rung E3). + #[error("Integer literal `{literal}` is out of range: the largest integer is 9223372036854775807 (I64)")] + IntegerOutOfRange { literal: String, span: Span }, } impl ParseError { @@ -45,6 +50,7 @@ impl ParseError { ParseError::UnexpectedEof { span } => *span, ParseError::Expected { span, .. } => *span, ParseError::AnnotationNotEnforced { span, .. } => *span, + ParseError::IntegerOutOfRange { span, .. } => *span, } } @@ -190,6 +196,17 @@ impl Report { ) .finish() } + + ParseError::IntegerOutOfRange { literal, .. } => { + AriadneReport::build(ReportKind::Error, self.source_name.clone(), range.start) + .with_message(format!("Integer literal `{}` is out of range", literal)) + .with_label( + Label::new(location) + .with_message("larger than 9223372036854775807 (I64)") + .with_color(Color::Red), + ) + .finish() + } } } diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index f63e2679..34026e28 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 // Owner: Jonathan D.A. Jewell // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// hypatia: allow code_safety/unwrap_dangerous_default -- the two integer-literal `parse().unwrap_or(0)` sites (see inline notes) only fall back on i64 overflow in a fn returning `Literal` (no Result); 0 is a deliberate lossy fallback pending the parse_literal Result refactor. +// hypatia: allow code_safety/unwrap_dangerous_default -- the two float-literal `parse().unwrap_or(0.0)` sites cannot take the default: grammar `float = ASCII_DIGIT+ "." ASCII_DIGIT+` (ephapax.pest) always parses as f64. //! Ephapax Parser //! @@ -102,6 +102,21 @@ pub fn parse_module(source: &str, name: &str) -> Result> // Helper Functions // ============================================================================ +/// An integer literal: `I32` when it fits, else `I64`. A literal above +/// `i64::MAX` is a [`ParseError::IntegerOutOfRange`]; it used to become 0. +fn integer_literal(pair: &pest::iterators::Pair) -> Result { + // Grammar `integer = ASCII_DIGIT+` (ephapax.pest): parse() can only + // fail on overflow. + let n: i64 = pair + .as_str() + .parse() + .map_err(|_| ParseError::IntegerOutOfRange { + literal: pair.as_str().to_string(), + span: span_from_pair(pair), + })?; + Ok(i32::try_from(n).map_or(Literal::I64(n), Literal::I32)) +} + fn span_from_pair(pair: &pest::iterators::Pair) -> Span { let span = pair.as_span(); Span::new(span.start(), span.end()) @@ -915,7 +930,7 @@ fn parse_pattern_core(pair: pest::iterators::Pair) -> Result { let name = parse_identifier( @@ -969,20 +984,12 @@ fn parse_constructor_pattern_core( /// `literal` rule wraps one of `unit_literal | boolean | float | /// integer | string`; this function handles either the wrapped or /// unwrapped form by recursing one level when needed. -fn parse_literal_value_core(pair: pest::iterators::Pair) -> Literal { - match pair.as_rule() { +fn parse_literal_value_core(pair: pest::iterators::Pair) -> Result { + Ok(match pair.as_rule() { Rule::unit_literal => Literal::Unit, Rule::boolean => Literal::Bool(pair.as_str() == "true"), Rule::float => Literal::F64(pair.as_str().parse().unwrap_or(0.0)), - Rule::integer => { - // hypatia: allow code_safety/unwrap_dangerous_default -- grammar `integer = ASCII_DIGIT+` (ephapax.pest) means parse() can only fail on i64 overflow; this fn returns `Literal` (no Result), so 0 is a deliberate lossy fallback. TODO: propagate overflow as ParseError (ripples to callers at lib.rs:896/978). - let n: i64 = pair.as_str().parse().unwrap_or(0); - if n > i32::MAX as i64 || n < i32::MIN as i64 { - Literal::I64(n) - } else { - Literal::I32(n as i32) - } - } + Rule::integer => integer_literal(&pair)?, Rule::string => { let s = pair.as_str(); let content = &s[1..s.len() - 1]; @@ -998,12 +1005,12 @@ fn parse_literal_value_core(pair: pest::iterators::Pair) -> Literal { _ => { // `literal` wraps the actual variant — recurse once. if let Some(inner) = pair.into_inner().next() { - parse_literal_value_core(inner) + parse_literal_value_core(inner)? } else { Literal::Unit } } - } + }) } fn parse_let_expr(pair: pest::iterators::Pair) -> Result { @@ -2009,15 +2016,7 @@ fn parse_literal(pair: pest::iterators::Pair) -> Result let n: f64 = inner.as_str().parse().unwrap_or(0.0); Literal::F64(n) } - Rule::integer => { - // hypatia: allow code_safety/unwrap_dangerous_default -- as above: parse() only fails on i64 overflow and there is no Result to propagate here, so 0 is a deliberate lossy fallback pending the parse_literal Result refactor. - let n: i64 = inner.as_str().parse().unwrap_or(0); - if n > i32::MAX as i64 || n < i32::MIN as i64 { - Literal::I64(n) - } else { - Literal::I32(n as i32) - } - } + Rule::integer => integer_literal(&inner)?, Rule::string => { let s = inner.as_str(); // Remove quotes and process escapes diff --git a/src/ephapax-query/Cargo.toml b/src/ephapax-query/Cargo.toml index b8916be8..4906bfb6 100644 --- a/src/ephapax-query/Cargo.toml +++ b/src/ephapax-query/Cargo.toml @@ -9,7 +9,5 @@ license = "MPL-2.0" description = "Demand-driven incremental query engine for the ephapax compile pipeline" [dependencies] -ephapax-syntax = { path = "../ephapax-syntax" } -ephapax-parser = { path = "../ephapax-parser" } -ephapax-typing = { path = "../ephapax-typing" } +ephapax-driver = { path = "../ephapax-driver" } ephapax-wasm = { path = "../ephapax-wasm" } diff --git a/src/ephapax-query/src/lib.rs b/src/ephapax-query/src/lib.rs index 88b80b78..44f467ab 100644 --- a/src/ephapax-query/src/lib.rs +++ b/src/ephapax-query/src/lib.rs @@ -35,17 +35,18 @@ //! pipeline `source -> parsed -> typed -> wasm`, so dependencies are //! threaded by hand rather than tracked through a general query trait. A //! production engine would: (a) track an arbitrary dependency DAG (needed -//! for the multi-module `import` case — see `import_resolver`), (b) return +//! for the multi-module `import` case: `parsed` resolves imports through +//! `ephapax-driver`, but an imported file is not an input here, so an edit +//! to it does not invalidate the importer's memos), (b) return //! `Arc`/references instead of cloning memoised values, and (c) //! optionally persist memos through a `QueryStore` backing (the disabled //! `ephapax-vram-cache` is a natural candidate once `ephapax-proven` //! returns). Those are documented extension points, not built here. -use ephapax_parser::{parse_module, ParseError}; -use ephapax_syntax::Module; -use ephapax_typing::{type_check_module, SpannedTypeError}; +use ephapax_driver::{check_program, parse_source, DriverError, Program}; use ephapax_wasm::compile_module; use std::collections::HashMap; +use std::path::Path; /// A logical source unit, identified by name/path. pub type SourceId = String; @@ -54,10 +55,10 @@ pub type SourceId = String; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct Revision(pub u64); -/// `parse_module`'s result type. -pub type ParsedResult = Result>; -/// `type_check_module`'s result type. -pub type TypedResult = Result<(), SpannedTypeError>; +/// Result of the front end up to desugaring (`ephapax_driver::parse_source`). +pub type ParsedResult = Result; +/// Result of `ephapax_driver::check_program`. +pub type TypedResult = Result<(), DriverError>; /// `compile_module`'s result, with the error flattened to a `String` so the /// memo type is self-contained. pub type WasmResult = Result, String>; @@ -89,10 +90,11 @@ pub struct QueryDb { } /// Two parse results are equivalent for backdating iff both are `Ok` with -/// equal modules. `ParseError` is not `PartialEq`, and a differing parse +/// equal core modules. `DriverError` is not `PartialEq`, and a differing /// error is a real change anyway, so any `Err` is treated as changed. fn parsed_equiv(a: &ParsedResult, b: &ParsedResult) -> bool { - matches!((a, b), (Ok(x), Ok(y)) if x == y) + matches!((a, b), (Ok(x), Ok(y)) + if x.modules.iter().map(|m| &m.core).eq(y.modules.iter().map(|m| &m.core))) } impl QueryDb { @@ -135,8 +137,9 @@ impl QueryDb { .unwrap_or_default() } - /// Parse query. Memoises `parse_module`; depends only on the source - /// text. + /// Parse query. Memoises the driver's resolve + parse + desugar; + /// depends only on the source text. Imports resolve next to `id` when + /// `id` is a file on disk, then in the stdlib. pub fn parsed(&mut self, id: &str) -> ParsedResult { let dep = self.input_changed_at(id); let rev = self.revision; @@ -152,7 +155,9 @@ impl QueryDb { .get(id) .map(|i| i.text.clone()) .unwrap_or_default(); - let new = parse_module(&text, id); + let base_dir = Path::new(id).parent().filter(|_| Path::new(id).is_file()); + let stdlib = ephapax_driver::stdlib_dir(None); + let new = parse_source(&text, id, base_dir, stdlib.as_deref()); self.recomputes += 1; let rev = self.revision; let changed_at = match self.parsed.get(id) { @@ -170,7 +175,7 @@ impl QueryDb { new } - /// Type-check query. Memoises `type_check_module`; depends on `parsed`. + /// Type-check query. Memoises `check_program`; depends on `parsed`. pub fn typed(&mut self, id: &str) -> TypedResult { let parsed = self.parsed(id); let dep = self @@ -187,7 +192,7 @@ impl QueryDb { } } let new: TypedResult = match &parsed { - Ok(module) => type_check_module(module), + Ok(program) => check_program(program), // Unparseable input has no typecheck result of its own; the // parse error is surfaced by `parsed`. Treat as no type error. Err(_) => Ok(()), @@ -195,7 +200,7 @@ impl QueryDb { self.recomputes += 1; let rev = self.revision; let changed_at = match self.typed.get(id) { - Some(old) if old.value == new => old.changed_at, + Some(old) if old.value.is_ok() && new.is_ok() => old.changed_at, _ => rev, }; self.typed.insert( @@ -230,9 +235,10 @@ impl QueryDb { } } let new: WasmResult = match (&parsed, &typed) { - (Ok(module), Ok(())) => compile_module(module).map_err(|e| e.0), - (Err(_), _) => Err("cannot compile: source has parse errors".to_string()), - (_, Err(e)) => Err(format!("cannot compile: type error: {e:?}")), + (Ok(program), Ok(())) => { + compile_module(&program.clone().into_merged()).map_err(|e| e.0) + } + (Err(e), _) | (_, Err(e)) => Err(format!("cannot compile: {e}")), }; self.recomputes += 1; let rev = self.revision; diff --git a/src/ephapax-repl/Cargo.toml b/src/ephapax-repl/Cargo.toml index 6acd5008..4e6899c0 100644 --- a/src/ephapax-repl/Cargo.toml +++ b/src/ephapax-repl/Cargo.toml @@ -15,6 +15,7 @@ repository.workspace = true ephapax-syntax.workspace = true ephapax-lexer.workspace = true ephapax-parser.workspace = true +ephapax-driver.workspace = true ephapax-typing.workspace = true ephapax-interp.workspace = true smol_str.workspace = true diff --git a/src/ephapax-repl/src/lib.rs b/src/ephapax-repl/src/lib.rs index 4f7fb745..38cf5c8e 100644 --- a/src/ephapax-repl/src/lib.rs +++ b/src/ephapax-repl/src/lib.rs @@ -8,9 +8,10 @@ //! Interactive environment for exploring and testing Ephapax code. use colored::Colorize; +use ephapax_driver::DriverError; use ephapax_interp::{Interpreter, RuntimeError, Value}; use ephapax_lexer::Lexer; -use ephapax_parser::{parse, parse_module, ParseError}; +use ephapax_parser::{parse, ParseError}; use ephapax_syntax::Ty; use ephapax_typing::{type_check, SpannedTypeError}; use rustyline::error::ReadlineError; @@ -168,13 +169,11 @@ impl Repl { if parts.len() < 2 { return CommandResult::Error("Usage: :load ".to_string()); } - match std::fs::read_to_string(parts[1]) { - Ok(content) => { - self.load_file(parts[1], &content); - } - Err(e) => { - eprintln!("{} Cannot read file: {}", "Error:".red().bold(), e); + match self.load_path(std::path::Path::new(parts[1])) { + Ok(decls) => { + println!("{} {} ({} declarations)", "Loaded".green(), parts[1], decls) } + Err(e) => eprintln!("{} {}", "Error:".red().bold(), e), } CommandResult::Continue } @@ -280,11 +279,8 @@ impl Repl { Err(errors) => { // Try parsing as declaration/module let module_src = format!("{}\n", line); - match parse_module(&module_src, "repl") { - Ok(module) => { - self.interpreter.load_module(&module); - println!("{}", "Loaded.".green()); - } + match self.load_module(&module_src, "repl") { + Ok(()) => println!("{}", "Loaded.".green()), Err(_) => { // Show parse errors for error in errors { @@ -324,33 +320,6 @@ impl Repl { } } - fn load_file(&mut self, filename: &str, content: &str) { - match parse_module(content, filename) { - Ok(module) => { - // Type check module - match ephapax_typing::type_check_module(&module) { - Ok(()) => { - self.interpreter.load_module(&module); - println!( - "{} {} ({} declarations)", - "Loaded".green(), - filename, - module.decls.len() - ); - } - Err(e) => { - eprintln!("{} {}", "Type error:".red().bold(), e); - } - } - } - Err(errors) => { - for error in errors { - eprintln!("{} {}", "Parse error:".red().bold(), error); - } - } - } - } - /// Evaluate a single expression and return the result pub fn eval_expr(&mut self, source: &str) -> Result { let expr = parse(source).map_err(ReplError::Parse)?; @@ -358,12 +327,33 @@ impl Repl { self.interpreter.eval(&expr).map_err(ReplError::Runtime) } - /// Load a module into the REPL + /// Load a file, and everything it imports, into the REPL. Returns the + /// number of declarations loaded. + pub fn load_path(&mut self, path: &std::path::Path) -> Result { + let stdlib = ephapax_driver::stdlib_dir(None); + self.load(ephapax_driver::load_program(path, stdlib.as_deref())) + } + + /// Load a module held in memory into the REPL. It has no directory, so + /// its imports resolve against the stdlib only. pub fn load_module(&mut self, source: &str, name: &str) -> Result<(), ReplError> { - let module = parse_module(source, name).map_err(ReplError::Parse)?; - ephapax_typing::type_check_module(&module).map_err(ReplError::Type)?; + let stdlib = ephapax_driver::stdlib_dir(None); + self.load(ephapax_driver::load_source( + source, + name, + None, + stdlib.as_deref(), + )) + .map(|_| ()) + } + + fn load( + &mut self, + program: Result, + ) -> Result { + let module = program.map_err(ReplError::Load)?.into_merged(); self.interpreter.load_module(&module); - Ok(()) + Ok(module.decls.len()) } } @@ -386,6 +376,8 @@ pub enum ReplError { Parse(Vec), Type(SpannedTypeError), Runtime(RuntimeError), + /// A module did not load through the front end (`ephapax-driver`). + Load(DriverError), } impl std::fmt::Display for ReplError { @@ -400,6 +392,7 @@ impl std::fmt::Display for ReplError { } ReplError::Type(e) => write!(f, "Type error: {}", e), ReplError::Runtime(e) => write!(f, "Runtime error: {}", e), + ReplError::Load(e) => write!(f, "{}", e), } } } diff --git a/tests/interp-known-failures.txt b/tests/interp-known-failures.txt new file mode 100644 index 00000000..ebe64ce4 --- /dev/null +++ b/tests/interp-known-failures.txt @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# interp-known-failures.txt — compiling corpus files on which the legacy +# interpreter (`ephapax run --interp`) disagrees with the wasm path +# (`ephapax run`). Gate: src/ephapax-cli/tests/run_wasm.rs (v2 plan E4). +# +# The wasm path is the reference. The gate fails if a listed file starts +# to agree (remove it here) or an unlisted file disagrees. +# +# Paths are repo-root-relative, one per line, '#' comments allowed. + +# The interpreter has no `extern` support: `Unbound variable: argv_count`. +tests/v2-grammar/fixtures/stdlib-import/app.eph diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv index 65439f76..d4c5f988 100644 --- a/tests/probes/expectations.tsv +++ b/tests/probes/expectations.tsv @@ -52,15 +52,15 @@ g5 compile error:TypeError error:* E1: named raw-FFI error, not an invalid modul 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 ok:3 ok:3 E7 (interpreter already prints 3: check and run disagree, E3) +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 qa/app2 check error:DesugarError ok E7: qualified M.Ctor(x) across modules qa/app2 compile error:DesugarError ok E7 -qa/app2 run error:TypeError ok:9 E7 + E3 (run goes through ephapax-driver::load_program) +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 p1 check ok ok already accepted p1 compile ok ok already compiles (959 B in the survey) -p1 run error:ParseError ok:162 E3/E4: ephapax run executes the wasm and prints 162 +p1 run ok:162 ok:162 E3/E4: ephapax run executes the wasm and prints 162 p1 wasm-run ok:162 ok:162 survey: main returns 162 under wasmtime CLI 36.0.2 total_loop check error:ParseError error:ParseError E1: @total is ParseError::AnnotationNotEnforced until enforced total_loop compile error:ParseError error:ParseError E1: ParseError::AnnotationNotEnforced diff --git a/tools/coproc/README.adoc b/tools/coproc/README.adoc index 633bb5e6..38e942b7 100644 --- a/tools/coproc/README.adoc +++ b/tools/coproc/README.adoc @@ -44,9 +44,11 @@ linear guarantee. == Build & use NOTE: Since v2 plan rung E1 (2026-11-09) the type checker rejects raw -`__ffi`, so `ephapax run ... -L libephapax_coproc.so` no longer reaches the +`__ffi`, so `ephapax run --interp ... -L libephapax_coproc.so` no longer reaches the shim: the interpreter's `-L` loader serves only raw `__ffi` calls and does -not yet bind `extern` declarations. The 42-vs-0 proof below is historical. +not yet bind `extern` declarations. Since rung E4 `ephapax run` executes the +compiled wasm, and `-L` is accepted only with `--interp` (the legacy +interpreter). The 42-vs-0 proof below is historical. Re-wiring the seam through `extern` is rung E10. [source,sh] @@ -56,7 +58,7 @@ zig build-lib -dynamic -O ReleaseSafe \ tools/coproc/ephapax_coproc.zig -femit-bin=libephapax_coproc.so # Run an ephapax program that uses Coproc, with the seam linked: -ephapax run myprog.eph -L libephapax_coproc.so +ephapax run --interp myprog.eph -L libephapax_coproc.so ---- Verified end-to-end: a program calling `eph_coproc_dispatch(_, _, 20, 22)` From 6021e59cd64a3d1a5db4586b47ed3f78f97cd79a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 11:10:59 +0000 Subject: [PATCH 02/13] fix(parser): unary operators in the surface parser; `-` on an integer is one literal Completes the E3 gate item on integer literals. - The surface parser read the unary operator from an inner pair, but pest gives anonymous tokens no pair, so it rejected every `-x` and `!x`. A program that also used `data` or `match` then failed with a core-parser error. The operator is now read from the text. - The core parser rejected `-` followed by a digit (`-5` was a parse error). Both parsers now fold `-` written directly on digits into the literal, so `-9223372036854775808` is `i64::MIN`. - `9223372036854775808`, `-9223372036854775809` and `-(9223372036854775808)` are each `ParseError::IntegerOutOfRange`. - The surface parser accepts I64 literals (it took I32 only), through the same helper as the core parser. Tests: a surface-parser unit test; the CLI literal test now covers both ends of the range on `run` and `run --interp`. TEST-NEEDS count 571. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 4 +- src/ephapax-cli/tests/driver_agreement.rs | 34 ++++-- src/ephapax-parser/src/error.rs | 10 +- src/ephapax-parser/src/lib.rs | 47 ++++++--- src/ephapax-parser/src/surface.rs | 120 ++++++++++++++-------- 5 files changed, 143 insertions(+), 72 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 2c83e9d4..27aa7a8f 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: *570 tests* (`+cargo test --workspace --all-targets+`); +* Total: *571 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 570 +* Documented all-target tests: 571 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs index d721b0a4..d1337c68 100644 --- a/src/ephapax-cli/tests/driver_agreement.rs +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -156,8 +156,9 @@ fn every_entry_point_agrees_on_every_corpus_file() { ); } -/// An integer literal above `i64::MAX` used to become 0 without a -/// diagnostic. It is now `ParseError::IntegerOutOfRange` on every command. +/// An integer literal outside the I64 range used to become 0 without a +/// diagnostic. It is now `ParseError::IntegerOutOfRange` on every command, +/// and `-9223372036854775808` is one literal, `i64::MIN`. #[test] fn out_of_range_integer_literal_is_a_parse_error() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -180,23 +181,36 @@ fn out_of_range_integer_literal_is_a_parse_error() { &["run", "--interp", "lit.eph"], ]; - for body in ["9223372036854775808", "-(9223372036854775808)"] { + // (program text, the literal the error names) + for (body, literal) in [ + ("9223372036854775808", "9223372036854775808"), + ("-9223372036854775809", "-9223372036854775809"), + ("-(9223372036854775808)", "9223372036854775808"), + ] { for args in commands { let out = run(body, args); let stderr = String::from_utf8_lossy(&out.stderr); assert!( !out.status.success() - && stderr.contains("Integer literal `9223372036854775808` is out of range"), + && stderr.contains(&format!("Integer literal `{literal}` is out of range")), "`{body}` under {args:?}: exit {:?}, stderr:\n{stderr}", out.status.code() ); } } - // The largest literal still works. - let out = run("9223372036854775807", &["run", "lit.eph"]); - assert_eq!( - String::from_utf8_lossy(&out.stdout).trim(), - "9223372036854775807i64" - ); + // Both ends of the range are literals, on both execution paths. + for (body, want) in [ + ("9223372036854775807", "9223372036854775807i64"), + ("-9223372036854775808", "-9223372036854775808i64"), + ] { + for args in [&commands[2], &commands[3]] { + let out = run(body, args); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + want, + "`{body}` under {args:?}" + ); + } + } } diff --git a/src/ephapax-parser/src/error.rs b/src/ephapax-parser/src/error.rs index 909d1fd3..9a0aaab9 100644 --- a/src/ephapax-parser/src/error.rs +++ b/src/ephapax-parser/src/error.rs @@ -36,9 +36,9 @@ pub enum ParseError { #[error("Annotation `@{name}` is not enforced: the compiler does not check it, so it is rejected rather than silently ignored")] AnnotationNotEnforced { name: String, span: Span }, - /// An integer literal above `i64::MAX`. It used to become 0 without - /// a diagnostic (v2 plan rung E3). - #[error("Integer literal `{literal}` is out of range: the largest integer is 9223372036854775807 (I64)")] + /// An integer literal outside the I64 range. It used to become 0 + /// without a diagnostic (v2 plan rung E3). + #[error("Integer literal `{literal}` is out of range for I64 (-9223372036854775808 to 9223372036854775807)")] IntegerOutOfRange { literal: String, span: Span }, } @@ -202,7 +202,9 @@ impl Report { .with_message(format!("Integer literal `{}` is out of range", literal)) .with_label( Label::new(location) - .with_message("larger than 9223372036854775807 (I64)") + .with_message( + "outside -9223372036854775808 to 9223372036854775807 (I64)", + ) .with_color(Color::Red), ) .finish() diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index 34026e28..0be8374f 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -102,21 +102,36 @@ pub fn parse_module(source: &str, name: &str) -> Result> // Helper Functions // ============================================================================ -/// An integer literal: `I32` when it fits, else `I64`. A literal above -/// `i64::MAX` is a [`ParseError::IntegerOutOfRange`]; it used to become 0. -fn integer_literal(pair: &pest::iterators::Pair) -> Result { +/// An integer literal: `I32` when it fits, else `I64`. `negative` is set +/// for a `-` written directly on the digits, so `-9223372036854775808` is +/// one literal, `i64::MIN`. A literal outside the I64 range is a +/// [`ParseError::IntegerOutOfRange`]; it used to become 0. +pub(crate) fn integer_literal( + digits: &str, + negative: bool, + span: Span, +) -> Result { + let out_of_range = || ParseError::IntegerOutOfRange { + literal: format!("{}{digits}", if negative { "-" } else { "" }), + span, + }; // Grammar `integer = ASCII_DIGIT+` (ephapax.pest): parse() can only // fail on overflow. - let n: i64 = pair - .as_str() - .parse() - .map_err(|_| ParseError::IntegerOutOfRange { - literal: pair.as_str().to_string(), - span: span_from_pair(pair), - })?; + let magnitude: i128 = digits.parse().map_err(|_| out_of_range())?; + let n = + i64::try_from(if negative { -magnitude } else { magnitude }).map_err(|_| out_of_range())?; Ok(i32::try_from(n).map_or(Literal::I64(n), Literal::I32)) } +/// The operand of a unary `-` when it is an integer literal with nothing +/// else around it (`-5`, not `-(5)` or `-x`): its digits. +pub(crate) fn negated_integer_digits<'a>( + operand: &pest::iterators::Pair<'a, Rule>, +) -> Option<&'a str> { + let text = operand.as_str().trim(); + (!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())).then_some(text) +} + fn span_from_pair(pair: &pest::iterators::Pair) -> Span { let span = pair.as_span(); Span::new(span.start(), span.end()) @@ -989,7 +1004,7 @@ fn parse_literal_value_core(pair: pest::iterators::Pair) -> Result Literal::Unit, Rule::boolean => Literal::Bool(pair.as_str() == "true"), Rule::float => Literal::F64(pair.as_str().parse().unwrap_or(0.0)), - Rule::integer => integer_literal(&pair)?, + Rule::integer => integer_literal(pair.as_str(), false, span_from_pair(&pair))?, Rule::string => { let s = pair.as_str(); let content = &s[1..s.len() - 1]; @@ -1568,11 +1583,17 @@ fn parse_unary_expr(pair: pest::iterators::Pair) -> Result) -> Result let n: f64 = inner.as_str().parse().unwrap_or(0.0); Literal::F64(n) } - Rule::integer => integer_literal(&inner)?, + Rule::integer => integer_literal(inner.as_str(), false, span_from_pair(&inner))?, Rule::string => { let s = inner.as_str(); // Remove quotes and process escapes diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index 976e2f8f..713ce789 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -1243,44 +1243,37 @@ fn parse_mul_expr(pair: pest::iterators::Pair) -> Result) -> Result { let span = span_from_pair(&pair); - let mut inner = pair.into_inner().peekable(); - - // Check for prefix operators - if let Some(first) = inner.peek() { - match first.as_str() { - "!" => { - inner.next(); - let operand = - parse_unary_expr(inner.next().ok_or_else(|| ParseError::missing("operand"))?)?; - return Ok(SurfaceExpr::new( - SurfaceExprKind::UnaryOp { - op: UnaryOp::Not, - operand: Box::new(operand), - }, - span, - )); - } - "-" => { - inner.next(); - let operand = - parse_unary_expr(inner.next().ok_or_else(|| ParseError::missing("operand"))?)?; - return Ok(SurfaceExpr::new( - SurfaceExprKind::UnaryOp { - op: UnaryOp::Neg, - operand: Box::new(operand), - }, - span, - )); - } - _ => {} - } - } - - // Must be postfix_expr - let next = inner + // `!` and `-` are anonymous tokens of `unary_expr`: pest gives them no + // pair, so the operator is read from the start of the text. No atom + // starts with either character. + let op = match pair.as_str().as_bytes().first() { + Some(b'!') => Some(UnaryOp::Not), + Some(b'-') => Some(UnaryOp::Neg), + _ => None, + }; + let inner = pair + .into_inner() .next() .ok_or_else(|| ParseError::unexpected_end("unary"))?; - parse_postfix_expr(next) + let Some(op) = op else { + return parse_postfix_expr(inner); + }; + if op == UnaryOp::Neg { + if let Some(digits) = crate::negated_integer_digits(&inner) { + return Ok(SurfaceExpr::new( + SurfaceExprKind::Lit(crate::integer_literal(digits, true, span)?), + span, + )); + } + } + let operand = parse_unary_expr(inner)?; + Ok(SurfaceExpr::new( + SurfaceExprKind::UnaryOp { + op, + operand: Box::new(operand), + }, + span, + )) } fn parse_postfix_expr(pair: pest::iterators::Pair) -> Result { @@ -1736,13 +1729,7 @@ fn parse_literal_value(pair: pest::iterators::Pair) -> Result { - let n: i32 = pair.as_str().parse().map_err(|_| ParseError::Syntax { - message: format!("Invalid integer: {}", pair.as_str()), - span: span_from_pair(&pair), - })?; - Ok(Literal::I32(n)) - } + Rule::integer => crate::integer_literal(pair.as_str(), false, span_from_pair(&pair)), Rule::float => { let f: f64 = pair.as_str().parse().map_err(|_| ParseError::Syntax { message: format!("Invalid float: {}", pair.as_str()), @@ -1967,6 +1954,53 @@ fn parse_base_ty(pair: pest::iterators::Pair) -> Result Date: Tue, 10 Nov 2026 11:24:58 +0000 Subject: [PATCH 03/13] feat(wasm): typed code generation from the checker's type table (E5, part 1) v2 plan rung E5. Codegen lowered every operator to i32 and guessed value types from syntax. It now reads the type the checker gave each expression. Type table - `Expr` gets an `ExprId` (fresh per `Expr::new`, kept by a clone, not part of equality, `Debug` or the serialised form). - The checker records the type of every expression it visits (`TypeChecker::expr_types`, `module_expr_types`). - Codegen checks the module it is given and reads that table. A program the checker rejects is a `CodegenError` ("not well-typed"): codegen only lowers well-typed programs. Seven unit-test fixtures that were ill-typed are now well-typed. Typed lowering - Binary operators, comparisons and negation select i32, i64, f32 or f64 instructions from the operand type. - `if`, `case` and `match` blocks take the result type of their arms. - `let` locals take the type of the bound value. - Tuples of three or more elements are a pointer to 8-byte slots, one per element, stored at the element's type. They were pushed as an i64 pointer, which did not validate. A 2-tuple is a pair. - `.0` and `.1` are tuple indices in both parsers (they parsed as `fst`/`snd`, which the checker rejects on a tuple of three or more). Defects fixed on the way - `String.len`: the runtime helper was declared `(i32, i32) -> i32` but takes one argument, so every use produced an invalid module. - Allocator state: the bump pointer (address 0) and the region stack pointer (address 8) started at 0, so the first allocation returned address 0 and writing to it overwrote the bump pointer. A data segment now starts the heap above the string literals. This was the cause of the wrong result for nested pairs (`fst(snd((1, (2, 3))))`). Interpreter - Integer arithmetic wraps, as the wasm does. `MIN / -1` is an `IntegerOverflow` error (wasm traps); `MIN % -1` is 0; negation wraps. i64 arithmetic used to panic on overflow in a debug build. Probes i64, s1, s6 and t3 now compile (were `InvalidWasm`). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- Cargo.lock | 1 + src/ephapax-analysis/src/lib.rs | 5 +- src/ephapax-interp/src/lib.rs | 101 +++++--- src/ephapax-parser/src/lib.rs | 31 ++- src/ephapax-parser/src/surface.rs | 25 +- src/ephapax-syntax/src/lib.rs | 47 +++- src/ephapax-typing/src/lib.rs | 36 ++- src/ephapax-wasm/Cargo.toml | 2 + src/ephapax-wasm/src/lib.rs | 386 ++++++++++++++++++------------ 9 files changed, 403 insertions(+), 231 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 608df693..9fd11a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,7 @@ version = "0.1.0" dependencies = [ "ephapax-ir", "ephapax-syntax", + "ephapax-typing", "gimli 0.31.1", "object 0.36.7", "proptest", diff --git a/src/ephapax-analysis/src/lib.rs b/src/ephapax-analysis/src/lib.rs index b1f2a16c..ef5f0cf3 100644 --- a/src/ephapax-analysis/src/lib.rs +++ b/src/ephapax-analysis/src/lib.rs @@ -70,10 +70,7 @@ mod tests { /// Helper to create an expression with a dummy span. fn e(kind: ExprKind) -> Expr { - Expr { - kind, - span: Span::dummy(), - } + Expr::dummy(kind) } #[test] diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index a90be8e7..d4b3f4b4 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -77,6 +77,10 @@ pub enum RuntimeError { #[error("Division by zero")] DivisionByZero, + /// `MIN / -1`: the quotient does not fit. Wasm traps here too. + #[error("Integer overflow")] + IntegerOverflow, + #[error("Unimplemented: {0}")] Unimplemented(String), @@ -523,6 +527,9 @@ impl Interpreter { ))) } } + // A 2-tuple is a pair. + Value::Pair(left, _) if *index == 0 => Ok(*left), + Value::Pair(_, right) if *index == 1 => Ok(*right), _ => Err(RuntimeError::Unimplemented( "tuple index on non-tuple".to_string(), )), @@ -546,29 +553,56 @@ impl Interpreter { let left_val = self.eval(left)?; let right_val = self.eval(right)?; + // Integer arithmetic wraps, as the compiled wasm does. `/` and `%` + // by zero, and `MIN / -1`, are errors (wasm traps); `MIN % -1` is 0. match op { - BinOp::Add => self.numeric_binop(left_val, right_val, |a, b| a + b, |a, b| a + b), - BinOp::Sub => self.numeric_binop(left_val, right_val, |a, b| a - b, |a, b| a - b), - BinOp::Mul => self.numeric_binop(left_val, right_val, |a, b| a * b, |a, b| a * b), - BinOp::Div => { - // Check for division by zero for integers - match (&left_val, &right_val) { - (Value::I32(_), Value::I32(0)) | (Value::I64(_), Value::I64(0)) => { - return Err(RuntimeError::DivisionByZero); - } - _ => {} - } - self.numeric_binop(left_val, right_val, |a, b| a / b, |a, b| a / b) - } - BinOp::Mod => { - match (&left_val, &right_val) { - (Value::I32(_), Value::I32(0)) | (Value::I64(_), Value::I64(0)) => { - return Err(RuntimeError::DivisionByZero); - } - _ => {} - } - self.numeric_binop(left_val, right_val, |a, b| a % b, |a, b| a % b) - } + BinOp::Add => self.numeric_binop( + left_val, + right_val, + |a, b| Ok(a.wrapping_add(b)), + |a, b| Ok(a.wrapping_add(b)), + |a, b| a + b, + ), + BinOp::Sub => self.numeric_binop( + left_val, + right_val, + |a, b| Ok(a.wrapping_sub(b)), + |a, b| Ok(a.wrapping_sub(b)), + |a, b| a - b, + ), + BinOp::Mul => self.numeric_binop( + left_val, + right_val, + |a, b| Ok(a.wrapping_mul(b)), + |a, b| Ok(a.wrapping_mul(b)), + |a, b| a * b, + ), + BinOp::Div => self.numeric_binop( + left_val, + right_val, + |a, b| match b { + 0 => Err(RuntimeError::DivisionByZero), + _ => a.checked_div(b).ok_or(RuntimeError::IntegerOverflow), + }, + |a, b| match b { + 0 => Err(RuntimeError::DivisionByZero), + _ => a.checked_div(b).ok_or(RuntimeError::IntegerOverflow), + }, + |a, b| a / b, + ), + BinOp::Mod => self.numeric_binop( + left_val, + right_val, + |a, b| match b { + 0 => Err(RuntimeError::DivisionByZero), + _ => Ok(a.wrapping_rem(b)), + }, + |a, b| match b { + 0 => Err(RuntimeError::DivisionByZero), + _ => Ok(a.wrapping_rem(b)), + }, + |a, b| a % b, + ), BinOp::Lt => self.compare_op(left_val, right_val, |a, b| a < b, |a, b| a < b), BinOp::Le => self.compare_op(left_val, right_val, |a, b| a <= b, |a, b| a <= b), BinOp::Gt => self.compare_op(left_val, right_val, |a, b| a > b, |a, b| a > b), @@ -592,20 +626,19 @@ impl Interpreter { } } - fn numeric_binop( + fn numeric_binop( &self, left: Value, right: Value, - int_op: I, - float_op: F, - ) -> Result - where - I: Fn(i64, i64) -> i64, - F: Fn(f64, f64) -> f64, - { + i32_op: impl Fn(i32, i32) -> Result, + i64_op: impl Fn(i64, i64) -> Result, + float_op: impl Fn(f64, f64) -> f64, + ) -> Result { match (left, right) { - (Value::I32(a), Value::I32(b)) => Ok(Value::I32(int_op(a as i64, b as i64) as i32)), - (Value::I64(a), Value::I64(b)) => Ok(Value::I64(int_op(a, b))), + (Value::I32(a), Value::I32(b)) => Ok(Value::I32(i32_op(a, b)?)), + (Value::I64(a), Value::I64(b)) => Ok(Value::I64(i64_op(a, b)?)), + // An F32 operation done in F64 and rounded once gives the + // correctly rounded F32 result. (Value::F32(a), Value::F32(b)) => Ok(Value::F32(float_op(a as f64, b as f64) as f32)), (Value::F64(a), Value::F64(b)) => Ok(Value::F64(float_op(a, b))), (l, r) => Err(RuntimeError::TypeError { @@ -662,8 +695,8 @@ impl Interpreter { }), }, UnaryOp::Neg => match val { - Value::I32(n) => Ok(Value::I32(-n)), - Value::I64(n) => Ok(Value::I64(-n)), + Value::I32(n) => Ok(Value::I32(n.wrapping_neg())), + Value::I64(n) => Ok(Value::I64(n.wrapping_neg())), Value::F32(n) => Ok(Value::F32(-n)), Value::F64(n) => Ok(Value::F64(-n)), v => Err(RuntimeError::TypeError { diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index 0be8374f..cc8906e0 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -1687,19 +1687,16 @@ fn parse_postfix_expr(pair: pest::iterators::Pair) -> Result { // Field access by name not currently supported @@ -2261,11 +2258,13 @@ mod tests { #[test] fn test_parse_projection() { + // `.0` is a tuple index: the checker resolves it on a pair or on + // a longer tuple (it used to parse as `fst`, which only a pair has). let expr = parse_ok("p.0"); - if let ExprKind::Fst(inner) = expr.kind { - assert!(matches!(inner.kind, ExprKind::Var(_))); + if let ExprKind::TupleIndex { tuple, index: 0 } = expr.kind { + assert!(matches!(tuple.kind, ExprKind::Var(_))); } else { - panic!("Expected fst projection"); + panic!("Expected tuple index 0"); } } diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index 713ce789..4193e522 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -1356,21 +1356,16 @@ fn parse_postfix_expr(pair: pest::iterators::Pair) -> Result { // Named member access `base.member` — qualified diff --git a/src/ephapax-syntax/src/lib.rs b/src/ephapax-syntax/src/lib.rs index 09204e68..02664260 100644 --- a/src/ephapax-syntax/src/lib.rs +++ b/src/ephapax-syntax/src/lib.rs @@ -433,11 +433,45 @@ pub struct HandlerClause { pub body: Expr, } +/// Identity of an expression node, for side tables keyed by expression +/// (the checker's type table, which codegen reads; v2 plan rung E5). +/// +/// [`Expr::new`] gives every node a fresh id. A clone keeps the id of its +/// original, so a table entry still applies to it. The id is not part of +/// equality, of the `Debug` form or of the serialised form: two parses of +/// the same text are equal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExprId(u64); + +impl ExprId { + fn fresh() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + ExprId(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) + } +} + /// Expressions -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Clone, Serialize)] pub struct Expr { pub kind: ExprKind, pub span: Span, + #[serde(skip)] + pub id: ExprId, +} + +impl PartialEq for Expr { + fn eq(&self, other: &Self) -> bool { + self.kind == other.kind && self.span == other.span + } +} + +impl std::fmt::Debug for Expr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Expr") + .field("kind", &self.kind) + .field("span", &self.span) + .finish() + } } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -615,15 +649,16 @@ pub enum ExprKind { impl Expr { pub fn new(kind: ExprKind, span: Span) -> Self { - Self { kind, span } - } - - pub fn dummy(kind: ExprKind) -> Self { Self { kind, - span: Span::dummy(), + span, + id: ExprId::fresh(), } } + + pub fn dummy(kind: ExprKind) -> Self { + Self::new(kind, Span::dummy()) + } } /// 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 2a9ed858..ab527270 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -20,7 +20,7 @@ pub mod discipline; use ephapax_syntax::{ - BaseTy, BinOp, Decl, Expr, ExprKind, ExternItem, Literal, MatchArm, Module, Pattern, + BaseTy, BinOp, Decl, Expr, ExprId, ExprKind, ExternItem, Literal, MatchArm, Module, Pattern, RegionName, Span, Ty, UnaryOp, Var, Visibility, }; use smol_str::SmolStr; @@ -856,8 +856,15 @@ fn is_useful( } /// Type checker state +/// The type of every expression the checker visited, by [`ExprId`], with +/// unification variables resolved. Codegen reads it to choose typed +/// instructions and layouts (v2 plan rung E5). +pub type ExprTypes = HashMap; + pub struct TypeChecker { ctx: Context, + /// Type of each checked expression; see [`TypeChecker::expr_types`]. + expr_types: ExprTypes, /// Next fresh unification variable ID. next_unif: u32, wildcard_obligations: Vec<(Span, Ty)>, @@ -874,6 +881,7 @@ impl TypeChecker { pub fn new() -> Self { Self { ctx: Context::new(), + expr_types: ExprTypes::new(), next_unif: 0, wildcard_obligations: Vec::new(), unif_solutions: HashMap::new(), @@ -1063,6 +1071,21 @@ impl TypeChecker { /// (most precise). Errors originating at THIS level carry this /// expression's span. pub fn check(&mut self, expr: &Expr) -> Result { + let ty = self.check_kind(expr)?; + self.expr_types.insert(expr.id, ty.clone()); + Ok(ty) + } + + /// The type of every expression checked so far, with unification + /// variables resolved. + pub fn expr_types(&self) -> ExprTypes { + self.expr_types + .iter() + .map(|(id, ty)| (*id, self.resolve(ty))) + .collect() + } + + fn check_kind(&mut self, expr: &Expr) -> Result { let s = expr.span; match &expr.kind { ExprKind::Lit(lit) => self.check_lit(s, lit), @@ -2488,6 +2511,15 @@ pub fn type_check_module_with_registry( module: &Module, registry: &mut ModuleRegistry, ) -> Result<(), SpannedTypeError> { + module_expr_types(module, registry).map(|_| ()) +} + +/// [`type_check_module_with_registry`], also returning the type of every +/// expression in the module. +pub fn module_expr_types( + module: &Module, + registry: &mut ModuleRegistry, +) -> Result { let mut tc = TypeChecker::new(); // Resolve imports: bring imported names into scope @@ -2522,7 +2554,7 @@ pub fn type_check_module_with_registry( // Register this module's declarations for dependents registry.register(module); - Ok(()) + Ok(tc.expr_types()) } /// Internal module checking logic shared by single-module and registry paths. diff --git a/src/ephapax-wasm/Cargo.toml b/src/ephapax-wasm/Cargo.toml index 1177e797..e28b3d1f 100644 --- a/src/ephapax-wasm/Cargo.toml +++ b/src/ephapax-wasm/Cargo.toml @@ -17,6 +17,8 @@ categories.workspace = true [dependencies] ephapax-syntax = { workspace = true } ephapax-ir = { workspace = true } +# The checker's expression-type table drives typed instruction selection. +ephapax-typing = { workspace = true } smol_str = { workspace = true } wasm-encoder = { workspace = true } gimli = { version = "0.31", features = ["write"] } diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 5924f7dd..8d84e5c3 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -61,12 +61,13 @@ use ephapax_syntax::{ BaseTy, BinOp, Decl, Expr, ExprKind, ExternItem, Linearity, Literal, MatchArm, Module as AstModule, Pattern, Ty, UnaryOp, }; +use ephapax_typing::ExprTypes; use smol_str::SmolStr; use std::collections::HashMap; use wasm_encoder::{ CodeSection, ConstExpr, ElementSection, Elements, ExportKind, ExportSection, Function, - FunctionSection, ImportSection, Instruction, MemArg, MemorySection, MemoryType, - Module as WasmModule, RefType, TableSection, TableType, TypeSection, ValType, + FunctionSection, ImportSection, Instruction, MemorySection, MemoryType, Module as WasmModule, + RefType, TableSection, TableType, TypeSection, ValType, }; // --------------------------------------------------------------------------- @@ -76,6 +77,11 @@ use wasm_encoder::{ /// WASM representation of a string: (pointer, length) pub const STRING_SIZE: u32 = 8; // 2 x i32 +/// Bytes per element of a tuple of three or more elements. One slot holds +/// any scalar or handle, so an element's offset does not depend on the +/// types of the other elements. +const TUPLE_SLOT: i32 = 8; + /// Region metadata size (bump_ptr + padding + region_sp + padding) pub const REGION_HEADER_SIZE: u32 = 16; @@ -173,6 +179,11 @@ impl std::error::Error for CodegenError {} /// module instead of planting an `unreachable` trap. pub const UNSUPPORTED_PREFIX: &str = "unsupported in codegen"; +/// Message prefix of the [`CodegenError`] for a program the type checker +/// rejects. Codegen reads the checker's type table (v2 plan rung E5), so +/// it only lowers well-typed programs. +pub const NOT_WELL_TYPED: &str = "not well-typed"; + /// Return the first construct in `expr` that codegen cannot lower: /// effect `perform` / `handle` (no lowering until E17) and raw variadic /// `__ffi` (no sound lowering at all; typed `extern` is the FFI path). @@ -400,15 +411,6 @@ impl LocalTracker { } } - /// Return a scratch local for temporary values, allocating one on first use. - fn scratch_local(&mut self) -> u32 { - if let Some(&idx) = self.name_to_idx.get("__scratch") { - idx - } else { - self.bind("__scratch", false) - } - } - /// Total number of extra (non-parameter) locals needed. fn num_extra_locals(&self) -> u32 { self.extra_local_types.len() as u32 @@ -444,6 +446,10 @@ pub struct Codegen { /// Local variable tracker for the function currently being compiled locals: LocalTracker, + /// The checker's type for each expression (v2 plan rung E5). Typed + /// instruction selection and layouts read it through [`Self::ty_of`]. + types: ExprTypes, + // -- Module-level bookkeeping ------------------------------------------- /// String literal data section entries: (offset, bytes) data_entries: Vec<(u32, Vec)>, @@ -564,6 +570,7 @@ impl Codegen { Self { module: WasmModule::new(), locals: LocalTracker::default(), + types: ExprTypes::new(), data_entries: Vec::new(), data_offset: 1024, // Start string data after metadata area user_fns: HashMap::new(), @@ -657,10 +664,7 @@ impl Codegen { fn fn_list_get(&self) -> u32 { self.import_count() + OFFSET_LIST_GET } - /// Region allocation aliases bump-allocation today. - fn fn_region_alloc(&self) -> u32 { - self.fn_bump_alloc() - } + /// First user-defined fn index, after imports and runtime helpers. fn first_user_fn(&self) -> u32 { self.import_count() + NUM_RUNTIME_FNS @@ -722,6 +726,11 @@ impl Codegen { if let Some(err) = first_unsupported(main_expr) { return Err(err); } + let mut checker = ephapax_typing::TypeChecker::new(); + checker + .check(main_expr) + .map_err(|e| CodegenError(format!("{NOT_WELL_TYPED}: {e}")))?; + self.types = checker.expr_types(); self.emit_types(); self.emit_imports(); @@ -795,6 +804,13 @@ impl Codegen { // (v2 plan E1: fail loudly, before any section is emitted.) validate_supported(ast)?; + // ----- Phase 0b: the checker's type for every expression ----------- + // The module is the whole program (imports already merged in), so + // it is checked on its own, against an empty registry. + self.types = + ephapax_typing::module_expr_types(ast, &mut ephapax_typing::ModuleRegistry::new()) + .map_err(|e| CodegenError(format!("{NOT_WELL_TYPED}: {e}")))?; + // ----- Phase 1: collect function metadata -------------------------- // Extern imports MUST be collected first so the import_count // is correct before any runtime helper or user fn indices are @@ -1189,8 +1205,8 @@ impl Codegen { code_sec.function(&f); self.record_access_sites(self.fn_string_new(), &sites); - // 4: string_len (i32, i32) -> i32 - func_sec.function(TYPE_I32_I32_I32); + // 4: string_len (i32) -> i32 + func_sec.function(TYPE_I32_TO_I32); let (f, sites) = self.gen_string_len(); code_sec.function(&f); self.record_access_sites(self.fn_string_len(), &sites); @@ -1512,10 +1528,19 @@ impl Codegen { } fn emit_data_section(&mut self) { - if self.data_entries.is_empty() { - return; - } let mut data = wasm_encoder::DataSection::new(); + + // Allocator state. The bump pointer (address 0) starts above the + // string literals, and the region stack pointer (address 8) starts + // so that the first saved bump pointer lands at address 16. Both + // used to start at 0: the first allocation then returned address + // 0 and writing to it overwrote the bump pointer itself. + let heap_base = align_up(self.data_offset as u64, 8) as u32; + let mut header = [0u8; 12]; + header[0..4].copy_from_slice(&heap_base.to_le_bytes()); + header[8..12].copy_from_slice(&(REGION_HEADER_SIZE - 4).to_le_bytes()); + data.active(0, &wasm_encoder::ConstExpr::i32_const(0), header); + for (offset, bytes) in &self.data_entries { data.active( 0, @@ -2151,10 +2176,22 @@ impl Codegen { func.instruction(&Instruction::Call(self.fn_string_len())); } - /// Recover the concrete WebAssembly value type needed by local lowering. - /// The typechecker has already accepted the AST, but inferred let types - /// are not written back into `ExprKind::Let`, so codegen reconstructs the - /// small amount of representation information it needs here. + /// The checker's type for `expr`, if it recorded one. + fn ty_of(&self, expr: &Expr) -> Option<&Ty> { + self.types.get(&expr.id) + } + + /// The wasm value type of `expr`: from the checker's type table, else + /// (an expression the checker did not record) the syntactic guess. + fn valtype_of(&self, expr: &Expr) -> ValType { + match self.ty_of(expr) { + Some(ty) => ty_to_valtype(ty), + None => self.infer_expr_valtype(expr), + } + } + + /// Syntactic guess at the wasm value type of an expression that has no + /// entry in the type table. [`Self::valtype_of`] is the entry point. fn infer_expr_valtype(&self, expr: &Expr) -> ValType { match &expr.kind { ExprKind::Lit(Literal::I64(_)) => ValType::I64, @@ -2211,7 +2248,7 @@ impl Codegen { ) { let local_ty = ty .map(ty_to_valtype) - .unwrap_or_else(|| self.infer_expr_valtype(value)); + .unwrap_or_else(|| self.valtype_of(value)); // Compile the value expression self.compile_expr(func, value); @@ -2688,7 +2725,7 @@ impl Codegen { // if tag != 0 (right branch) else (left branch) func.instruction(&Instruction::If(wasm_encoder::BlockType::Result( - ValType::I32, + self.valtype_of(left_body), ))); // Right branch (tag == 1) @@ -2785,8 +2822,11 @@ impl Codegen { func.instruction(&Instruction::LocalSet(payload_local)); } - // Linear arm dispatch. - func.instruction(&Instruction::Block(BlockType::Result(ValType::I32))); + // Linear arm dispatch. Every arm has the type of the match. + let result = arms + .first() + .map_or(ValType::I32, |arm| self.valtype_of(&arm.body)); + func.instruction(&Instruction::Block(BlockType::Result(result))); for arm in arms.iter() { // Open $arm_i_fail block. Inside, depth 0 = $arm_i_fail, @@ -3059,7 +3099,7 @@ impl Codegen { ) { self.compile_expr(func, cond); func.instruction(&Instruction::If(wasm_encoder::BlockType::Result( - ValType::I32, + self.valtype_of(then_branch), ))); self.compile_expr(func, then_branch); func.instruction(&Instruction::Else); @@ -3115,34 +3155,35 @@ impl Codegen { } fn compile_binop(&mut self, func: &mut Function, op: BinOp, left: &Expr, right: &Expr) { + // The checker unifies the two operand types, so the left one + // selects the instruction. + let operand = self.valtype_of(left); self.compile_expr(func, left); self.compile_expr(func, right); - - let instr = match op { - BinOp::Add => Instruction::I32Add, - BinOp::Sub => Instruction::I32Sub, - BinOp::Mul => Instruction::I32Mul, - BinOp::Div => Instruction::I32DivS, - BinOp::Mod => Instruction::I32RemS, - BinOp::Lt => Instruction::I32LtS, - BinOp::Le => Instruction::I32LeS, - BinOp::Gt => Instruction::I32GtS, - BinOp::Ge => Instruction::I32GeS, - BinOp::Eq => Instruction::I32Eq, - BinOp::Ne => Instruction::I32Ne, - BinOp::And => Instruction::I32And, - BinOp::Or => Instruction::I32Or, - }; - func.instruction(&instr); + func.instruction(&binop_instruction(op, operand)); } fn compile_unaryop(&mut self, func: &mut Function, op: UnaryOp, operand: &Expr) { - match op { - UnaryOp::Not => { + match (op, self.valtype_of(operand)) { + (UnaryOp::Not, _) => { self.compile_expr(func, operand); func.instruction(&Instruction::I32Eqz); } - UnaryOp::Neg => { + (UnaryOp::Neg, ValType::F32) => { + self.compile_expr(func, operand); + func.instruction(&Instruction::F32Neg); + } + (UnaryOp::Neg, ValType::F64) => { + self.compile_expr(func, operand); + func.instruction(&Instruction::F64Neg); + } + // Integer negation is `0 - x`, which wraps at the minimum. + (UnaryOp::Neg, ValType::I64) => { + func.instruction(&Instruction::I64Const(0)); + self.compile_expr(func, operand); + func.instruction(&Instruction::I64Sub); + } + (UnaryOp::Neg, _) => { func.instruction(&Instruction::I32Const(0)); self.compile_expr(func, operand); func.instruction(&Instruction::I32Sub); @@ -3186,77 +3227,46 @@ impl Codegen { } fn compile_tuple_lit(&mut self, func: &mut Function, elements: &[Expr]) { - if elements.is_empty() { - // Empty tuple = unit - func.instruction(&Instruction::I32Const(0)); - return; - } - - if elements.len() == 1 { - // Single element = just the element - self.compile_expr(func, &elements[0]); - return; - } - - // Tuples are compiled by placing all elements on the WASM stack. - // For 2-element tuples (pairs), this maps directly to two stack values. - // For 3+ elements, we allocate a struct in linear memory: - // [elem_count: i32, elem0: i64, elem1: i64, ...] - // and push the pointer as a single i64. - if elements.len() <= 2 { - // Small tuples: elements live on the stack - for elem in elements { - self.compile_expr(func, elem); + match elements { + // The checker types `()` as Unit, `(a)` as `a` and `(a, b)` as + // a pair; the layouts follow. + [] => { + func.instruction(&Instruction::I32Const(0)); } - } else { - // Large tuples: allocate in memory (8 bytes per element + 4 header) - let size = 4 + (elements.len() as i32) * 8; - func.instruction(&Instruction::I32Const(size)); - func.instruction(&Instruction::Call(self.fn_region_alloc())); - // Store count header - func.instruction(&Instruction::LocalTee(self.locals.scratch_local())); - func.instruction(&Instruction::I32Const(elements.len() as i32)); - func.instruction(&Instruction::I32Store(MemArg { - offset: 0, - align: 2, - memory_index: 0, - })); - // Store each element at offset 4 + i*8 - for (i, elem) in elements.iter().enumerate() { - func.instruction(&Instruction::LocalGet(self.locals.scratch_local())); - self.compile_expr(func, elem); - func.instruction(&Instruction::I64Store(MemArg { - offset: 4 + (i as u64) * 8, - align: 3, - memory_index: 0, - })); + [only] => self.compile_expr(func, only), + [left, right] => self.compile_pair(func, left, right), + // Three or more: a pointer to one 8-byte slot per element. + // Element `i` is at offset `i * 8`, stored at its own type. + _ => { + func.instruction(&Instruction::I32Const(elements.len() as i32 * TUPLE_SLOT)); + func.instruction(&Instruction::I32Const(0)); + func.instruction(&Instruction::Call(self.fn_bump_alloc())); + let tuple_local = self.locals.temp(); + func.instruction(&Instruction::LocalSet(tuple_local)); + for (i, elem) in elements.iter().enumerate() { + func.instruction(&Instruction::LocalGet(tuple_local)); + self.compile_expr(func, elem); + emit_typed_store(func, self.valtype_of(elem), i as u64 * TUPLE_SLOT as u64); + } + func.instruction(&Instruction::LocalGet(tuple_local)); } - // Push pointer as i64 - func.instruction(&Instruction::LocalGet(self.locals.scratch_local())); - func.instruction(&Instruction::I64ExtendI32U); } } fn compile_tuple_index(&mut self, func: &mut Function, tuple: &Expr, index: usize) { - // Compile the tuple expression. - self.compile_expr(func, tuple); - - // For 2-element tuples (pairs on stack): .0 and .1 are two stack values. - // For 3+ element tuples (memory-allocated): the pointer is on stack, - // and we load element at offset 4 + index*8. - // - // Heuristic: if the tuple type has <= 2 elements, it's on the stack. - // Otherwise, it's a pointer to a memory-allocated struct. - // For now, treat as memory-allocated (the general case) since - // stack-based pairs should use ExprKind::Fst/Snd instead. - // - // Stack has: [tuple_ptr as i64] - func.instruction(&Instruction::I32WrapI64); // ptr: i64 → i32 - func.instruction(&Instruction::I64Load(MemArg { - offset: 4 + (index as u64) * 8, - align: 3, - memory_index: 0, - })); + match self.ty_of(tuple).cloned() { + Some(Ty::Tuple(elems)) if index < elems.len() => { + self.compile_expr(func, tuple); + emit_typed_load( + func, + ty_to_valtype(&elems[index]), + index as u64 * TUPLE_SLOT as u64, + ); + } + // A 2-tuple is a pair. + _ if index == 0 => self.compile_fst(func, tuple), + _ => self.compile_snd(func, tuple), + } } // ----------------------------------------------------------------------- @@ -3317,6 +3327,64 @@ fn collect_first_ctor(pat: &Pattern) -> Option { } } +/// The instruction for `op` on two operands of wasm type `ty`. +/// +/// Integer arithmetic wraps. Integer `/` and `%` are the signed forms: +/// both trap on a zero divisor, `/` traps on `MIN / -1`, and `MIN % -1` +/// is 0. `&&` and `||` take Bool operands, which are i32. +fn binop_instruction(op: BinOp, ty: ValType) -> Instruction<'static> { + use Instruction as I; + use ValType as V; + match (op, ty) { + (BinOp::Add, V::I64) => I::I64Add, + (BinOp::Add, V::F32) => I::F32Add, + (BinOp::Add, V::F64) => I::F64Add, + (BinOp::Add, _) => I::I32Add, + (BinOp::Sub, V::I64) => I::I64Sub, + (BinOp::Sub, V::F32) => I::F32Sub, + (BinOp::Sub, V::F64) => I::F64Sub, + (BinOp::Sub, _) => I::I32Sub, + (BinOp::Mul, V::I64) => I::I64Mul, + (BinOp::Mul, V::F32) => I::F32Mul, + (BinOp::Mul, V::F64) => I::F64Mul, + (BinOp::Mul, _) => I::I32Mul, + (BinOp::Div, V::I64) => I::I64DivS, + (BinOp::Div, V::F32) => I::F32Div, + (BinOp::Div, V::F64) => I::F64Div, + (BinOp::Div, _) => I::I32DivS, + (BinOp::Mod, V::I64) => I::I64RemS, + // TODO(E5 fmod): float remainder + (BinOp::Mod, V::F32 | V::F64) => I::Unreachable, + (BinOp::Mod, _) => I::I32RemS, + (BinOp::Lt, V::I64) => I::I64LtS, + (BinOp::Lt, V::F32) => I::F32Lt, + (BinOp::Lt, V::F64) => I::F64Lt, + (BinOp::Lt, _) => I::I32LtS, + (BinOp::Le, V::I64) => I::I64LeS, + (BinOp::Le, V::F32) => I::F32Le, + (BinOp::Le, V::F64) => I::F64Le, + (BinOp::Le, _) => I::I32LeS, + (BinOp::Gt, V::I64) => I::I64GtS, + (BinOp::Gt, V::F32) => I::F32Gt, + (BinOp::Gt, V::F64) => I::F64Gt, + (BinOp::Gt, _) => I::I32GtS, + (BinOp::Ge, V::I64) => I::I64GeS, + (BinOp::Ge, V::F32) => I::F32Ge, + (BinOp::Ge, V::F64) => I::F64Ge, + (BinOp::Ge, _) => I::I32GeS, + (BinOp::Eq, V::I64) => I::I64Eq, + (BinOp::Eq, V::F32) => I::F32Eq, + (BinOp::Eq, V::F64) => I::F64Eq, + (BinOp::Eq, _) => I::I32Eq, + (BinOp::Ne, V::I64) => I::I64Ne, + (BinOp::Ne, V::F32) => I::F32Ne, + (BinOp::Ne, V::F64) => I::F64Ne, + (BinOp::Ne, _) => I::I32Ne, + (BinOp::And, _) => I::I32And, + (BinOp::Or, _) => I::I32Or, + } +} + fn mem_arg(offset: u64) -> wasm_encoder::MemArg { wasm_encoder::MemArg { offset, @@ -3525,6 +3593,24 @@ mod tests { } /// Shorthand for creating a dummy expression. + /// `{ drop(s); 0 }`: the body of a well-typed function that takes a + /// linear `s` and returns an I32. + fn drop_s_then_zero() -> Expr { + e(ExprKind::Block(vec![ + e(ExprKind::Drop(Box::new(e(ExprKind::Var("s".into()))))), + e(ExprKind::Lit(Literal::I32(0))), + ])) + } + + /// `region r { drop() }`: a string expression in a well-typed + /// program (its region is active, and it does not escape). + fn in_region_dropped(string: Expr) -> Expr { + e(ExprKind::Region { + name: "r".into(), + body: Box::new(e(ExprKind::Drop(Box::new(string)))), + }) + } + fn e(kind: ExprKind) -> Expr { Expr::new(kind, Span::dummy()) } @@ -4312,13 +4398,12 @@ mod tests { #[test] fn compile_drop_expression() { let mut codegen = Codegen::new(); - // let x = 42 in drop(x) -- simplified: drop returns unit - let expr = e(ExprKind::Let { - name: "x".into(), - ty: None, - value: Box::new(e(ExprKind::Lit(Literal::I32(42)))), - body: Box::new(e(ExprKind::Drop(Box::new(e(ExprKind::Var("x".into())))))), - }); + // region r { drop(String.new@r("x")) }: only a linear value can + // be dropped. + let expr = in_region_dropped(e(ExprKind::StringNew { + region: "r".into(), + value: "x".to_string(), + })); let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); @@ -4421,10 +4506,10 @@ mod tests { #[test] fn compile_string_new() { let mut codegen = Codegen::new(); - let expr = e(ExprKind::StringNew { + let expr = in_region_dropped(e(ExprKind::StringNew { region: "r".into(), value: "hello".to_string(), - }); + })); let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); @@ -4433,7 +4518,7 @@ mod tests { #[test] fn compile_string_concat() { let mut codegen = Codegen::new(); - let expr = e(ExprKind::StringConcat { + let expr = in_region_dropped(e(ExprKind::StringConcat { left: Box::new(e(ExprKind::StringNew { region: "r".into(), value: "hello ".to_string(), @@ -4442,7 +4527,7 @@ mod tests { region: "r".into(), value: "world".to_string(), })), - }); + })); let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); @@ -4451,12 +4536,17 @@ mod tests { #[test] fn compile_string_in_region() { let mut codegen = Codegen::new(); - // region r { String.new@r("test") } + // region r { let! s = String.new@r("test") in drop(s) } let expr = e(ExprKind::Region { name: "r".into(), - body: Box::new(e(ExprKind::StringNew { - region: "r".into(), - value: "test".to_string(), + body: Box::new(e(ExprKind::LetLin { + name: "s".into(), + ty: None, + value: Box::new(e(ExprKind::StringNew { + region: "r".into(), + value: "test".to_string(), + })), + body: Box::new(e(ExprKind::Drop(Box::new(e(ExprKind::Var("s".into())))))), })), }); let wasm = codegen.compile_program(&expr).unwrap(); @@ -4472,27 +4562,15 @@ mod tests { fn compile_simple_lambda() { // Test: (lambda (x : I32) (+ x 1)) let mut codegen = Codegen::new(); - let expr = Expr { - kind: ExprKind::Lambda { - param: "x".into(), - param_ty: Ty::Base(BaseTy::I32), - body: Box::new(Expr { - kind: ExprKind::BinOp { - op: BinOp::Add, - left: Box::new(Expr { - kind: ExprKind::Var("x".into()), - span: Span::dummy(), - }), - right: Box::new(Expr { - kind: ExprKind::Lit(Literal::I32(1)), - span: Span::dummy(), - }), - }, - span: Span::dummy(), - }), - }, - span: Span::dummy(), - }; + let expr = Expr::dummy(ExprKind::Lambda { + param: "x".into(), + param_ty: Ty::Base(BaseTy::I32), + body: Box::new(Expr::dummy(ExprKind::BinOp { + op: BinOp::Add, + left: Box::new(Expr::dummy(ExprKind::Var("x".into()))), + right: Box::new(Expr::dummy(ExprKind::Lit(Literal::I32(1)))), + })), + }); let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); @@ -5348,7 +5426,7 @@ mod tests { type_params: vec![], params: vec![("s".into(), Ty::String("r".into()))], ret_ty: Ty::Base(BaseTy::I32), - body: e(ExprKind::Lit(Literal::I32(0))), + body: drop_s_then_zero(), }], }; let wasm = compile_module(&module).expect("compilation failed"); @@ -5412,7 +5490,7 @@ mod tests { type_params: vec![], params: vec![("s".into(), Ty::String("r".into()))], ret_ty: Ty::Base(BaseTy::I32), - body: e(ExprKind::Lit(Literal::I32(0))), + body: drop_s_then_zero(), }, Decl::Fn { name: "second_consume".into(), @@ -5420,7 +5498,7 @@ mod tests { type_params: vec![], params: vec![("s".into(), Ty::String("r".into()))], ret_ty: Ty::Base(BaseTy::I32), - body: e(ExprKind::Lit(Literal::I32(0))), + body: drop_s_then_zero(), }, ], }; @@ -5457,7 +5535,7 @@ mod tests { type_params: vec![], params: vec![("s".into(), Ty::String("r".into()))], ret_ty: Ty::Base(BaseTy::I32), - body: e(ExprKind::Lit(Literal::I32(0))), + body: drop_s_then_zero(), }, ], }; From 111feee5b7e03515399b2bf983625ee46c82b653 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 11:36:14 +0000 Subject: [PATCH 04/13] feat(wasm): typed containers, float remainder, string equality, f32 literals (E5, part 2) v2 plan rung E5, continued. Typed layouts - A pair is `[left @ 0][right]` with each component at its natural alignment and stored at its own type. `(i32, i32)` is unchanged. - A sum is `[tag @ 0][payload]`; an 8-byte payload sits at offset 8. `case` binds the payload at its type. - `copy(e)` is the typed pair `(e, e)`. - A list element is an 8-byte slot holding the i64 bit pattern of any scalar or handle. `list_get` traps on a negative index or one not below the length (it loaded unchecked). Operators - Float `%` calls an exact `fmod` runtime helper (the bit-level algorithm, after musl). An F32 remainder is taken in F64 and demoted. - `==` / `!=` on strings compare contents (a `string_eq` helper); they compared handles. - The checker now rejects ordering (`<`, `<=`, `>`, `>=`) on a non-numeric type, and equality on a pair, sum, list or function (`TypeError::EqualityNotSupported`). It accepted both; the interpreter then failed at run time and the wasm compared handles. Language surface - `1.5f32` is an F32 literal (pest grammar, both parsers, EBNF). - The surface parser handles `fst(e)` and `snd(e)`. It had no arm for them, so they failed in any program that also used `data` or `match`. - When both parsers reject a file, the driver reports the surface parser's errors. The core parser's complaint about a surface-only construct hid the real error. Not lowered yet, and refused - `match` (or a destructuring `let`) on a value with an I64, F32 or F64 component is a `CodegenError`. Patterns still read every component as an i32. Use `fst`, `snd`, `.N` or `case`. Gate: tests/typed_codegen_matrix.rs. A generated type x operation matrix (3255 cases) runs each case as wasm and in the interpreter: 0 disagreements, 31 cases with a pinned expected value. A second test passes each numeric type through an extern parameter and result. Probe battery: i64, s1, s6 and t3 are at their targets. The four `wasm-run` baselines are recorded from `ephapax run` (same engine); the wasmtime CLI was not installed here. TEST-NEEDS count 573. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 4 +- spec/ephapax-v2-grammar.ebnf | 6 +- src/ephapax-cli/tests/typed_codegen_matrix.rs | 417 ++++++++++ src/ephapax-driver/src/resolver.rs | 10 +- src/ephapax-parser/src/ephapax.pest | 4 + src/ephapax-parser/src/lib.rs | 10 +- src/ephapax-parser/src/surface.rs | 21 + src/ephapax-typing/src/lib.rs | 28 +- src/ephapax-wasm/src/lib.rs | 731 +++++++++++++++--- tests/probes/expectations.tsv | 16 +- 10 files changed, 1113 insertions(+), 134 deletions(-) create mode 100644 src/ephapax-cli/tests/typed_codegen_matrix.rs diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 27aa7a8f..fab3e166 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: *571 tests* (`+cargo test --workspace --all-targets+`); +* Total: *573 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 571 +* Documented all-target tests: 573 === CRG Testing Taxonomy — Status diff --git a/spec/ephapax-v2-grammar.ebnf b/spec/ephapax-v2-grammar.ebnf index 0ff21ebc..e8994a86 100644 --- a/spec/ephapax-v2-grammar.ebnf +++ b/spec/ephapax-v2-grammar.ebnf @@ -521,7 +521,7 @@ expr_list = expr , { "," , expr } ; variable = IDENT ; -literal = "()" | BOOLEAN | FLOAT | INTEGER | STRING ; +literal = "()" | BOOLEAN | FLOAT32 | FLOAT | INTEGER | STRING ; (* ================================================================ *) (* 13. COMPTIME EXPRESSIONS (META PLANE) *) @@ -603,6 +603,10 @@ CTOR_IDENT = UPPER_ALPHA , { ALPHA_NUM | "_" } ; INTEGER = DIGIT , { DIGIT } ; FLOAT = DIGIT , { DIGIT } , "." , DIGIT , { DIGIT } ; +(* An F32 literal carries the `f32` suffix: 1.5f32. An unsuffixed float + literal is F64. An INTEGER is I32 when it fits, else I64; a `-` written + directly on the digits is part of the literal. *) +FLOAT32 = FLOAT , "f32" ; BOOLEAN = "true" | "false" ; STRING = '"' , { CHAR } , '"' ; diff --git a/src/ephapax-cli/tests/typed_codegen_matrix.rs b/src/ephapax-cli/tests/typed_codegen_matrix.rs new file mode 100644 index 00000000..fcc0231d --- /dev/null +++ b/src/ephapax-cli/tests/typed_codegen_matrix.rs @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// typed_codegen_matrix.rs: v2 plan rung E5 gate. +// +// A generated type x operation conformance matrix. Every case is a +// zero-parameter function in one module. The module is compiled once and +// each case runs twice: as wasm (wasmtime, a fresh instance per case) and +// in the interpreter. The two must agree on the printed value, or both +// must fail (a wasm trap against an interpreter error). +// +// Covered: every binary operator, unary operator and comparison on every +// operand type the checker accepts, and each value type in each container +// position (pair field, tuple field, list element, sum payload, `copy`, +// `if` / `let` / `match` result, extern parameter and result). + +use ephapax_interp::Interpreter; +use wasmtime::{Engine, Linker, Module, Store, Val}; + +/// One case: the result type and the expression. +struct Case { + ty: &'static str, + expr: String, +} + +fn case(ty: &'static str, expr: impl Into) -> Case { + Case { + ty, + expr: expr.into(), + } +} + +/// Sample operands of each numeric type, boundary values included. +fn samples(ty: &str) -> Vec { + let v: &[&str] = match ty { + "I32" => &["0", "1", "-1", "7", "-3", "2147483647", "-2147483648"], + // A small I64 has no literal form (`1` is an I32), so it is + // written as a difference of two I64 literals. + "I64" => &[ + "(5000000000 - 5000000000)", + "(5000000000 - 4999999999)", + "(4999999999 - 5000000000)", + "5000000000", + "-5000000000", + "9223372036854775807", + "-9223372036854775808", + ], + "F32" => &[ + "0.0f32", + "(-(0.0f32))", + "1.5f32", + "(-(2.25f32))", + "0.1f32", + "3.0f32", + "340282346638528860000000000000000000000.0f32", + "(1.0f32 / 0.0f32)", + "(0.0f32 / 0.0f32)", + ], + "F64" => &[ + "0.0", + "(-(0.0))", + "1.5", + "(-(2.25))", + "0.1", + "3.0", + // 1e308: the grammar has no exponent form. + "1e308", + "(1.0 / 0.0)", + "(0.0 / 0.0)", + ], + "Bool" => &["true", "false"], + other => panic!("no samples for {other}"), + }; + v.iter() + .map(|s| { + if *s == "1e308" { + format!("1{}.0", "0".repeat(308)) + } else { + s.to_string() + } + }) + .collect() +} + +const NUMERIC: [&str; 4] = ["I32", "I64", "F32", "F64"]; +const VALUE_TYPES: [&str; 5] = ["I32", "I64", "F32", "F64", "Bool"]; + +fn cases_matrix() -> Vec { + let mut out = Vec::new(); + + // Arithmetic and comparisons: every operator on every pair of samples. + for ty in NUMERIC { + let vals = samples(ty); + for a in &vals { + for b in &vals { + for op in ["+", "-", "*", "/", "%"] { + out.push(case(ty, format!("{a} {op} {b}"))); + } + for op in ["<", "<=", ">", ">=", "==", "!="] { + out.push(case("Bool", format!("{a} {op} {b}"))); + } + } + out.push(case(ty, format!("-({a})"))); + } + } + for a in samples("Bool") { + for b in samples("Bool") { + for op in ["&&", "||", "==", "!="] { + out.push(case("Bool", format!("{a} {op} {b}"))); + } + } + out.push(case("Bool", format!("!{a}"))); + } + out.push(case("Bool", "() == ()")); + out.push(case("Bool", "() != ()")); + + // Strings compare by content, not by handle. + for (a, b) in [("abc", "abc"), ("abc", "abd"), ("abc", "ab"), ("", "")] { + for op in ["==", "!="] { + out.push(case( + "Bool", + format!("region r {{ String.new@r(\"{a}\") {op} String.new@r(\"{b}\") }}"), + )); + } + } + + // Each value type in each container position, next to each other type. + for ty in VALUE_TYPES { + let v = &samples(ty)[1]; + let w = &samples(ty)[0]; + for other in VALUE_TYPES { + let o = &samples(other)[1]; + out.push(case(ty, format!("fst(({v}, {o}))"))); + out.push(case(ty, format!("snd(({o}, {v}))"))); + out.push(case(ty, format!("let p = ({o}, {v})\n p.1"))); + out.push(case(ty, format!("let p = ({v}, {o})\n p.0"))); + out.push(case(ty, format!("snd(fst((({o}, {v}), {o})))"))); + for (i, t) in [ + format!("({v}, {o}, {o})"), + format!("({o}, {v}, {o})"), + format!("({o}, {o}, {v})"), + ] + .iter() + .enumerate() + { + out.push(case(ty, format!("let t = {t}\n t.{i}"))); + } + // Sum payload, on either side of a sum whose other side is `other`. + out.push(case( + ty, + format!("case inl[{other}]({v}) of\n inl(a) -> a\n inr(b) -> {w}\n end"), + )); + out.push(case( + ty, + format!("case inr[{other}]({v}) of\n inl(a) -> {w}\n inr(b) -> b\n end"), + )); + } + out.push(case(ty, format!("let xs = [{w}, {v}]\n xs[1]"))); + out.push(case(ty, format!("let xs = [{w}, {v}]\n xs[0]"))); + // An index out of range, or negative, fails on both paths. + out.push(case(ty, format!("let xs = [{w}, {v}]\n xs[2]"))); + out.push(case(ty, format!("let xs = [{w}, {v}]\n xs[-1]"))); + out.push(case(ty, format!("fst(copy({v}))"))); + out.push(case(ty, format!("snd(copy({v}))"))); + out.push(case(ty, format!("if true then {v} else {w}"))); + out.push(case(ty, format!("if false then {v} else {w}"))); + out.push(case(ty, format!("let x = {v}\n x"))); + out.push(case(ty, format!("let! x = {v}\n x"))); + out.push(case( + ty, + format!("match Some(1) of\n | None => {w}\n | Some(n) => {v}\n end"), + )); + } + out +} + +/// Cases with a known answer (`None`: both paths must fail). Agreement +/// alone would also hold if both paths were wrong the same way. +fn pinned() -> Vec<(Case, Option<&'static str>)> { + let e308 = format!("1{}.0", "0".repeat(308)); + vec![ + // An I64 literal outside the I32 range, and a negative I64 result. + ( + case("I64", "5000000000 + 5000000000"), + Some("10000000000i64"), + ), + (case("I64", "-(5000000000)"), Some("-5000000000i64")), + // Integer arithmetic wraps at the type limits. + (case("I32", "2147483647 + 1"), Some("-2147483648")), + (case("I32", "-2147483648 - 1"), Some("2147483647")), + (case("I32", "-(-2147483648)"), Some("-2147483648")), + ( + case("I64", "9223372036854775807 + 9223372036854775807"), + Some("-2i64"), + ), + ( + case("I64", "-(-9223372036854775808)"), + Some("-9223372036854775808i64"), + ), + // `/` and `%` by zero, and `MIN / -1`, fail; `MIN % -1` is 0. + (case("I32", "1 / 0"), None), + (case("I32", "1 % 0"), None), + (case("I32", "-2147483648 / -1"), None), + (case("I32", "-2147483648 % -1"), Some("0")), + ( + case("I64", "-9223372036854775808 / (4999999999 - 5000000000)"), + None, + ), + ( + case("I64", "-9223372036854775808 % (4999999999 - 5000000000)"), + Some("0i64"), + ), + // Float literals, arithmetic and exact remainder, in both widths. + (case("F32", "1.5f32 + 0.25f32"), Some("1.75f32")), + (case("F32", "5.5f32 % 2.0f32"), Some("1.5f32")), + (case("F32", "-(1.5f32)"), Some("-1.5f32")), + (case("F64", "1.5 + 2.25"), Some("3.75")), + (case("F64", "1.0 % 0.1"), Some("0.09999999999999995")), + (case("F64", "-(0.0) % 1.0"), Some("-0")), + (case("F64", format!("{e308} % 3.0")), Some("2")), + (case("F64", "5.5 % (1.0 / 0.0)"), Some("5.5")), + (case("F64", "5.5 % 0.0"), Some("NaN")), + (case("F64", "-(1.5)"), Some("-1.5")), + // Containers keep the full width of an element. + (case("I64", "snd((1, 5000000000))"), Some("5000000000i64")), + ( + case("I64", "let xs = [5000000000, 6000000000]\n xs[1]"), + Some("6000000000i64"), + ), + (case("F64", "let xs = [1.5, 2.5]\n xs[1]"), Some("2.5")), + (case("I32", "let t = (1, 2, 3)\n t.2"), Some("3")), + (case("I32", "fst(snd((1, (2, 3))))"), Some("2")), + // Strings: length, and equality by content. + ( + case( + "I32", + "region r {\n let! s = String.new@r(\"hi\")\n String.len(s)\n }", + ), + Some("2"), + ), + ( + case( + "Bool", + "region r { String.new@r(\"abc\") == String.new@r(\"abc\") }", + ), + Some("true"), + ), + ] +} + +/// The module: `data Option`, then one function per case. +fn module_source(cases: &[Case]) -> String { + let mut src = String::from("module matrix\n\ndata Option(a) = None | Some(a)\n\n"); + for (i, c) in cases.iter().enumerate() { + src.push_str(&format!("fn c{i}(): {} =\n {}\n\n", c.ty, c.expr)); + } + src +} + +/// A wasm result, printed the way the interpreter prints a value. +fn show(ty: &str, val: Val) -> String { + match (ty, val) { + ("Unit", _) => "()".to_string(), + ("Bool", Val::I32(n)) => (n != 0).to_string(), + ("I32", Val::I32(n)) => n.to_string(), + ("I64", Val::I64(n)) => format!("{n}i64"), + ("F32", Val::F32(bits)) => format!("{}f32", f32::from_bits(bits)), + ("F64", Val::F64(bits)) => f64::from_bits(bits).to_string(), + (ty, val) => panic!("result {val:?} does not have the wasm type of {ty}"), + } +} + +fn load(src: &str) -> ephapax_syntax::Module { + let stdlib = ephapax_driver::stdlib_dir(None); + ephapax_driver::load_source(src, "matrix", None, stdlib.as_deref()) + .unwrap_or_else(|e| panic!("the matrix module must load: {e}")) + .into_merged() +} + +fn linker(engine: &Engine) -> Linker<()> { + let mut linker = Linker::new(engine); + linker.func_wrap("env", "print_i32", |_: i32| {}).unwrap(); + linker + .func_wrap("env", "print_string", |_: i32, _: i32| {}) + .unwrap(); + linker +} + +#[test] +fn wasm_and_interpreter_agree_on_the_type_operation_matrix() { + let (pinned_cases, expected): (Vec, Vec<_>) = pinned().into_iter().unzip(); + let n_pinned = pinned_cases.len(); + let mut cases = pinned_cases; + cases.extend(cases_matrix()); + let module = load(&module_source(&cases)); + let wasm = ephapax_wasm::compile_module(&module).expect("the matrix module must compile"); + wasmparser::validate(&wasm).expect("the matrix module must be valid wasm"); + + let engine = Engine::default(); + let compiled = Module::new(&engine, &wasm).expect("wasmtime loads the module"); + let linker = linker(&engine); + let mut interp = Interpreter::new(); + interp.load_module(&module); + + let mut disagreements = Vec::new(); + let mut values = 0; + for (i, c) in cases.iter().enumerate() { + let name = format!("c{i}"); + + let mut store = Store::new(&engine, ()); + let instance = linker.instantiate(&mut store, &compiled).unwrap(); + let func = instance.get_func(&mut store, &name).unwrap(); + let mut results = [Val::I32(0)]; + let wasm_out = func + .call(&mut store, &[], &mut results) + .ok() + .map(|()| show(c.ty, results[0])); + + let interp_out = interp.call(&name, vec![]).ok().map(|v| v.to_string()); + if interp_out.is_none() { + // An error can leave bindings behind: start clean. + interp = Interpreter::new(); + interp.load_module(&module); + } + + values += usize::from(wasm_out.is_some()); + if i < n_pinned && wasm_out.as_deref() != expected[i] { + disagreements.push(format!( + "{}: `{}`\n wasm: {:?}\n expected: {:?}", + c.ty, + c.expr.replace('\n', " "), + wasm_out, + expected[i] + )); + } + if wasm_out != interp_out { + disagreements.push(format!( + "{}: `{}`\n wasm: {:?}\n interpreter: {:?}", + c.ty, + c.expr.replace('\n', " "), + wasm_out, + interp_out + )); + } + } + + println!( + "typed codegen matrix: {} cases, {} with a value, {} that fail on both paths", + cases.len(), + values, + cases.len() - values + ); + // Most cases compute a value; only a zero divisor, `MIN / -1` and a + // list index out of range fail. + assert!( + values * 10 > cases.len() * 9, + "too few cases produce a value" + ); + assert!( + disagreements.is_empty(), + "{} of {} cases disagree:\n{}", + disagreements.len(), + cases.len(), + disagreements.join("\n") + ); +} + +/// The interpreter has no `extern`, so a value passed through a host +/// identity function is compared with the same value not passed through. +#[test] +fn extern_parameters_and_results_keep_their_type() { + let host = [ + ("I32", "id_i32"), + ("I64", "id_i64"), + ("F32", "id_f32"), + ("F64", "id_f64"), + ]; + let mut src = String::from("module ext\n\nextern \"env\" {\n"); + for (ty, name) in host { + src.push_str(&format!(" fn {name}(x: {ty}): {ty}\n")); + } + src.push_str("}\n\n"); + let mut cases = Vec::new(); + for (ty, name) in host { + for v in samples(ty) { + src.push_str(&format!( + "fn c{n}(): {ty} =\n {name}({v})\n\nfn c{m}(): {ty} =\n {v}\n\n", + n = cases.len(), + m = cases.len() + 1 + )); + cases.push(ty); + cases.push(ty); + } + } + + let wasm = ephapax_wasm::compile_module(&load(&src)).expect("extern module compiles"); + wasmparser::validate(&wasm).expect("valid wasm"); + let engine = Engine::default(); + let compiled = Module::new(&engine, &wasm).unwrap(); + let mut linker = linker(&engine); + linker.func_wrap("env", "id_i32", |x: i32| x).unwrap(); + linker.func_wrap("env", "id_i64", |x: i64| x).unwrap(); + linker.func_wrap("env", "id_f32", |x: f32| x).unwrap(); + linker.func_wrap("env", "id_f64", |x: f64| x).unwrap(); + let mut store = Store::new(&engine, ()); + let instance = linker.instantiate(&mut store, &compiled).unwrap(); + + let mut run = |i: usize| { + let func = instance.get_func(&mut store, &format!("c{i}")).unwrap(); + let mut results = [Val::I32(0)]; + func.call(&mut store, &[], &mut results).unwrap(); + show(cases[i], results[0]) + }; + for i in (0..cases.len()).step_by(2) { + assert_eq!(run(i), run(i + 1), "case {i} ({})", cases[i]); + } +} diff --git a/src/ephapax-driver/src/resolver.rs b/src/ephapax-driver/src/resolver.rs index 7b21e351..12e05990 100644 --- a/src/ephapax-driver/src/resolver.rs +++ b/src/ephapax-driver/src/resolver.rs @@ -162,13 +162,15 @@ impl Resolver<'_> { }; // Surface grammar first. A file it rejects may still be a - // core-syntax file, so fall back to the core parser and report - // that parser's errors if both fail. + // core-syntax file, so fall back to the core parser. If both + // fail, report the surface parser's errors: the surface grammar + // is the language, and the core parser's complaint about a + // surface-only construct would hide the real error. let parsed = match parse_surface_module(&source, logical) { Ok(m) => Parsed::Surface(m), - Err(_) => match parse_module(&source, logical) { + Err(errors) => match parse_module(&source, logical) { Ok(m) => Parsed::Core(m), - Err(errors) => { + Err(_) => { return Err(DriverError::Parse { path: file_path, errors, diff --git a/src/ephapax-parser/src/ephapax.pest b/src/ephapax-parser/src/ephapax.pest index 90b784d7..2b88e839 100644 --- a/src/ephapax-parser/src/ephapax.pest +++ b/src/ephapax-parser/src/ephapax.pest @@ -425,6 +425,7 @@ variable = { identifier } literal = { unit_literal | boolean + | float32 | float | integer | string @@ -435,6 +436,9 @@ boolean = { "true" | "false" } integer = @{ ASCII_DIGIT+ } float = @{ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ } +// An F32 literal carries the `f32` suffix (`1.5f32`); an unsuffixed float +// literal is F64. +float32 = @{ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ ~ "f32" } string = @{ "\"" ~ string_inner ~ "\"" } string_inner = @{ (escape | !("\"" | "\\") ~ ANY)* } diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index cc8906e0..f8f94e80 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 // Owner: Jonathan D.A. Jewell // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// hypatia: allow code_safety/unwrap_dangerous_default -- the two float-literal `parse().unwrap_or(0.0)` sites cannot take the default: grammar `float = ASCII_DIGIT+ "." ASCII_DIGIT+` (ephapax.pest) always parses as f64. +// hypatia: allow code_safety/unwrap_dangerous_default -- the float-literal `parse().unwrap_or(0.0)` sites cannot take the default: grammar `float = ASCII_DIGIT+ "." ASCII_DIGIT+` (and `float32`, the same with an `f32` suffix that is trimmed first; ephapax.pest) always parses. //! Ephapax Parser //! @@ -123,6 +123,12 @@ pub(crate) fn integer_literal( Ok(i32::try_from(n).map_or(Literal::I64(n), Literal::I32)) } +/// An F32 literal, `.f32`: the decimal rounded once to F32. +pub(crate) fn float32_literal(text: &str) -> Literal { + // Grammar `float32` (ephapax.pest): the digits always parse. + Literal::F32(text.trim_end_matches("f32").parse().unwrap_or(0.0)) +} + /// The operand of a unary `-` when it is an integer literal with nothing /// else around it (`-5`, not `-(5)` or `-x`): its digits. pub(crate) fn negated_integer_digits<'a>( @@ -1004,6 +1010,7 @@ fn parse_literal_value_core(pair: pest::iterators::Pair) -> Result Literal::Unit, Rule::boolean => Literal::Bool(pair.as_str() == "true"), Rule::float => Literal::F64(pair.as_str().parse().unwrap_or(0.0)), + Rule::float32 => float32_literal(pair.as_str()), Rule::integer => integer_literal(pair.as_str(), false, span_from_pair(&pair))?, Rule::string => { let s = pair.as_str(); @@ -2034,6 +2041,7 @@ fn parse_literal(pair: pest::iterators::Pair) -> Result let n: f64 = inner.as_str().parse().unwrap_or(0.0); Literal::F64(n) } + Rule::float32 => float32_literal(inner.as_str()), Rule::integer => integer_literal(inner.as_str(), false, span_from_pair(&inner))?, Rule::string => { let s = inner.as_str(); diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index 4193e522..f50ff931 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -1540,6 +1540,26 @@ fn parse_atom_expr(pair: pest::iterators::Pair) -> Result { + let is_fst = inner.as_rule() == Rule::fst_expr; + let operand = Box::new(parse_expression( + inner + .into_inner() + .next() + .ok_or_else(|| ParseError::missing("pair"))?, + )?); + Ok(SurfaceExpr::new( + if is_fst { + SurfaceExprKind::Fst(operand) + } else { + SurfaceExprKind::Snd(operand) + }, + span, + )) + } + Rule::drop_expr => { let inner_expr = parse_expression( inner @@ -1725,6 +1745,7 @@ fn parse_literal_value(pair: pest::iterators::Pair) -> Result crate::integer_literal(pair.as_str(), false, span_from_pair(&pair)), + Rule::float32 => Ok(crate::float32_literal(pair.as_str())), Rule::float => { let f: f64 = pair.as_str().parse().map_err(|_| ParseError::Syntax { message: format!("Invalid float: {}", pair.as_str()), diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index ab527270..a7411adf 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -46,6 +46,9 @@ pub enum TypeError { #[error("Type mismatch: expected {expected:?}, found {found:?}")] TypeMismatch { expected: Ty, found: Ty }, + #[error("`==` and `!=` are not defined on type {0:?}: only the scalar types and String have equality")] + EqualityNotSupported(Ty), + #[error("Cannot copy linear type {0:?}")] CannotCopyLinear(Ty), @@ -2000,9 +2003,28 @@ impl TypeChecker { }, )), }, - BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge | BinOp::Eq | BinOp::Ne => { - Ok(Ty::Base(BaseTy::Bool)) - } + // Ordering is defined on the numeric types only. (It used to + // accept any two operands of one type; the interpreter then + // failed at run time and the wasm compared handles.) + BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => match &left_ty { + Ty::Base(BaseTy::I32) + | Ty::Base(BaseTy::I64) + | Ty::Base(BaseTy::F32) + | Ty::Base(BaseTy::F64) => Ok(Ty::Base(BaseTy::Bool)), + _ => Err(self.at( + s, + TypeError::TypeMismatch { + expected: Ty::Base(BaseTy::I32), + found: left_ty, + }, + )), + }, + // Equality is defined on the scalar types and on strings (by + // content). A pair, sum, list or function has no equality. + BinOp::Eq | BinOp::Ne => match &left_ty { + Ty::Base(_) | Ty::String(_) => Ok(Ty::Base(BaseTy::Bool)), + _ => Err(self.at(s, TypeError::EqualityNotSupported(left_ty))), + }, BinOp::And | BinOp::Or => { if left_ty != Ty::Base(BaseTy::Bool) { return Err(self.at( diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 8d84e5c3..fcaa5b99 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -124,9 +124,11 @@ const OFFSET_REGION_EXIT: u32 = 6; const OFFSET_LIST_NEW: u32 = 7; const OFFSET_LIST_APPEND: u32 = 8; const OFFSET_LIST_GET: u32 = 9; +const OFFSET_STRING_EQ: u32 = 10; +const OFFSET_FMOD: u32 = 11; /// Number of runtime helper functions emitted in the code section. -const NUM_RUNTIME_FNS: u32 = 10; +const NUM_RUNTIME_FNS: u32 = 12; // --------------------------------------------------------------------------- // Well-known WASM type indices @@ -151,8 +153,19 @@ const TYPE_I32_TO_I32: u32 = 6; /// Same signature as TYPE_I32_I32_I32 (type 3), aliased for clarity. const TYPE_CLOSURE_CALL: u32 = TYPE_I32_I32_I32; +/// Type 7: `(i32, i64) -> i32` (list_append: handle + element slot) +const TYPE_LIST_APPEND: u32 = 7; +/// Type 8: `(i32, i32) -> i64` (list_get: handle + index -> element slot) +const TYPE_LIST_GET: u32 = 8; +/// Type 9: `(f64, f64) -> f64` (fmod) +const TYPE_F64_F64_F64: u32 = 9; + /// Number of fixed type entries in the type section. -const NUM_FIXED_TYPES: u32 = 7; +const NUM_FIXED_TYPES: u32 = 10; + +/// Bytes per list element. A slot holds the i64 bit pattern of any scalar +/// or handle, so one pair of runtime helpers serves every element type. +const LIST_SLOT: i32 = 8; // --------------------------------------------------------------------------- // (Mode removed — dyadic property is per-binding, not a global switch) @@ -386,11 +399,16 @@ impl LocalTracker { idx } - /// Allocate an anonymous temporary. + /// Allocate an anonymous i32 temporary. fn temp(&mut self) -> u32 { + self.temp_typed(ValType::I32) + } + + /// Allocate an anonymous temporary of the given wasm type. + fn temp_typed(&mut self, ty: ValType) -> u32 { let idx = self.next_idx; self.next_idx += 1; - self.extra_local_types.push(ValType::I32); + self.extra_local_types.push(ty); idx } @@ -450,6 +468,12 @@ pub struct Codegen { /// instruction selection and layouts read it through [`Self::ty_of`]. types: ExprTypes, + /// The first construct met while lowering that has no correct + /// lowering yet. `compile_expr` cannot return an error, so it is + /// recorded here and the public entry points fail with it instead of + /// returning a module that computes a wrong value. + unsupported: Option, + // -- Module-level bookkeeping ------------------------------------------- /// String literal data section entries: (offset, bytes) data_entries: Vec<(u32, Vec)>, @@ -571,6 +595,7 @@ impl Codegen { module: WasmModule::new(), locals: LocalTracker::default(), types: ExprTypes::new(), + unsupported: None, data_entries: Vec::new(), data_offset: 1024, // Start string data after metadata area user_fns: HashMap::new(), @@ -664,6 +689,12 @@ impl Codegen { fn fn_list_get(&self) -> u32 { self.import_count() + OFFSET_LIST_GET } + fn fn_string_eq(&self) -> u32 { + self.import_count() + OFFSET_STRING_EQ + } + fn fn_fmod(&self) -> u32 { + self.import_count() + OFFSET_FMOD + } /// First user-defined fn index, after imports and runtime helpers. fn first_user_fn(&self) -> u32 { @@ -781,6 +812,9 @@ impl Codegen { self.emit_ownership_section(); self.emit_carrier_sections(); self.emit_debug_sections(); + if let Some(err) = self.unsupported.take() { + return Err(err); + } Ok(self.module.clone().finish()) } @@ -851,6 +885,9 @@ impl Codegen { // was called (`compile --debug`). self.emit_debug_sections(); + if let Some(err) = self.unsupported.take() { + return Err(err); + } Ok(self.module.clone().finish()) } @@ -1033,6 +1070,18 @@ impl Codegen { ); // Type 6: (i32) -> i32 types.ty().function(vec![ValType::I32], vec![ValType::I32]); + // Type 7: (i32, i64) -> i32 + types + .ty() + .function(vec![ValType::I32, ValType::I64], vec![ValType::I32]); + // Type 8: (i32, i32) -> i64 + types + .ty() + .function(vec![ValType::I32, ValType::I32], vec![ValType::I64]); + // Type 9: (f64, f64) -> f64 + types + .ty() + .function(vec![ValType::F64, ValType::F64], vec![ValType::F64]); // Dynamic extra types for (params, results) in &self.extra_types { @@ -1233,13 +1282,23 @@ impl Codegen { func_sec.function(TYPE_I32_TO_I32); code_sec.function(&self.gen_list_new()); - // 10: list_append (i32, i32) -> i32 - func_sec.function(TYPE_I32_I32_I32); + // 10: list_append (i32, i64) -> i32 + func_sec.function(TYPE_LIST_APPEND); code_sec.function(&self.gen_list_append()); - // 11: list_get (i32, i32) -> i32 - func_sec.function(TYPE_I32_I32_I32); + // 11: list_get (i32, i32) -> i64 + func_sec.function(TYPE_LIST_GET); code_sec.function(&self.gen_list_get()); + + // 12: string_eq (i32, i32) -> i32 + func_sec.function(TYPE_I32_I32_I32); + let (f, sites) = self.gen_string_eq(); + code_sec.function(&f); + self.record_access_sites(self.fn_string_eq(), &sites); + + // 13: fmod (f64, f64) -> f64 + func_sec.function(TYPE_F64_F64_F64); + code_sec.function(&gen_fmod()); } /// Append user-defined functions from the AST. @@ -1875,9 +1934,9 @@ impl Codegen { fn gen_list_new(&self) -> Function { let mut f = Function::new(vec![(1, ValType::I32)]); // local for handle - // Calculate size: 8 (header) + capacity * 4 (elements) + // Calculate size: 8 (header) + capacity * LIST_SLOT (elements) f.instruction(&Instruction::LocalGet(0)); // capacity - f.instruction(&Instruction::I32Const(4)); + f.instruction(&Instruction::I32Const(LIST_SLOT)); f.instruction(&Instruction::I32Mul); f.instruction(&Instruction::I32Const(8)); f.instruction(&Instruction::I32Add); @@ -1903,7 +1962,7 @@ impl Codegen { f } - /// Append element to list: `(handle: i32, value: i32) -> handle: i32` + /// Append element to list: `(handle: i32, value: i64) -> handle: i32` /// May return same handle or new handle if resize needed fn gen_list_append(&self) -> Function { let mut f = Function::new(vec![ @@ -1938,7 +1997,7 @@ impl Codegen { // Uses memory.copy (bulk memory operations) when available, // falls back to element-by-element copy loop. // Source: old_handle + 8 (skip header), Dest: new_handle + 8 - // Length: old_length * 4 bytes (i32 elements) + // Length: old_length * LIST_SLOT bytes f.instruction(&Instruction::LocalGet(4)); // dest = new_handle f.instruction(&Instruction::I32Const(8)); f.instruction(&Instruction::I32Add); // dest + 8 @@ -1946,8 +2005,8 @@ impl Codegen { f.instruction(&Instruction::I32Const(8)); f.instruction(&Instruction::I32Add); // src + 8 f.instruction(&Instruction::LocalGet(3)); // length - f.instruction(&Instruction::I32Const(4)); - f.instruction(&Instruction::I32Mul); // length * 4 = byte count + f.instruction(&Instruction::I32Const(LIST_SLOT)); + f.instruction(&Instruction::I32Mul); // byte count f.instruction(&Instruction::MemoryCopy { src_mem: 0, dst_mem: 0, @@ -1960,16 +2019,16 @@ impl Codegen { // No resize needed, use existing handle f.instruction(&Instruction::End); - // Append element at handle + 8 + length*4 + // Append element at handle + 8 + length * LIST_SLOT f.instruction(&Instruction::LocalGet(0)); // handle f.instruction(&Instruction::I32Const(8)); f.instruction(&Instruction::I32Add); f.instruction(&Instruction::LocalGet(3)); // length - f.instruction(&Instruction::I32Const(4)); + f.instruction(&Instruction::I32Const(LIST_SLOT)); f.instruction(&Instruction::I32Mul); - f.instruction(&Instruction::I32Add); // address = handle + 8 + length*4 + f.instruction(&Instruction::I32Add); f.instruction(&Instruction::LocalGet(1)); // value - f.instruction(&Instruction::I32Store(mem_arg(0))); + f.instruction(&Instruction::I64Store(typed_mem_arg(ValType::I64, 0))); // Increment length f.instruction(&Instruction::LocalGet(0)); // handle @@ -1984,25 +2043,103 @@ impl Codegen { f } - /// Get element from list: `(handle: i32, index: i32) -> element: i32` + /// Get element from list: `(handle: i32, index: i32) -> element: i64`. + /// Traps when the index is negative or not below the length. fn gen_list_get(&self) -> Function { let mut f = Function::new(vec![]); - // Calculate address: handle + 8 + index*4 + // Bounds check, unsigned, so a negative index is out of range too. + f.instruction(&Instruction::LocalGet(1)); // index + f.instruction(&Instruction::LocalGet(0)); + f.instruction(&Instruction::I32Load(mem_arg(4))); // length + f.instruction(&Instruction::I32GeU); + f.instruction(&Instruction::If(wasm_encoder::BlockType::Empty)); + f.instruction(&Instruction::Unreachable); + f.instruction(&Instruction::End); + + // Address: handle + 8 + index * LIST_SLOT f.instruction(&Instruction::LocalGet(0)); // handle f.instruction(&Instruction::I32Const(8)); f.instruction(&Instruction::I32Add); f.instruction(&Instruction::LocalGet(1)); // index - f.instruction(&Instruction::I32Const(4)); + f.instruction(&Instruction::I32Const(LIST_SLOT)); f.instruction(&Instruction::I32Mul); f.instruction(&Instruction::I32Add); // Load element - f.instruction(&Instruction::I32Load(mem_arg(0))); + f.instruction(&Instruction::I64Load(typed_mem_arg(ValType::I64, 0))); f.instruction(&Instruction::End); f } + /// String equality by content: `(a: i32, b: i32) -> i32` (1 or 0). + fn gen_string_eq(&self) -> (Function, Vec<(u32, u32)>) { + use wasm_encoder::BlockType::Empty; + // locals: 2 = len, 3 = bytes of a, 4 = bytes of b, 5 = i + let mut f = Function::new(vec![(4, ValType::I32)]); + let mut sites = Vec::new(); + let byte = wasm_encoder::MemArg { + offset: 0, + align: 0, + memory_index: 0, + }; + + // Different lengths: not equal. + f.instruction(&Instruction::LocalGet(0)); + sites.push((carriers::FIELD_LEN, f.byte_len() as u32)); + f.instruction(&Instruction::I32Load(mem_arg(4))); + f.instruction(&Instruction::LocalTee(2)); + f.instruction(&Instruction::LocalGet(1)); + sites.push((carriers::FIELD_LEN, f.byte_len() as u32)); + f.instruction(&Instruction::I32Load(mem_arg(4))); + f.instruction(&Instruction::I32Ne); + f.instruction(&Instruction::If(Empty)); + f.instruction(&Instruction::I32Const(0)); + f.instruction(&Instruction::Return); + f.instruction(&Instruction::End); + + f.instruction(&Instruction::LocalGet(0)); + sites.push((carriers::FIELD_PTR, f.byte_len() as u32)); + f.instruction(&Instruction::I32Load(mem_arg(0))); + f.instruction(&Instruction::LocalSet(3)); + f.instruction(&Instruction::LocalGet(1)); + sites.push((carriers::FIELD_PTR, f.byte_len() as u32)); + f.instruction(&Instruction::I32Load(mem_arg(0))); + f.instruction(&Instruction::LocalSet(4)); + + // Compare byte by byte. + f.instruction(&Instruction::Loop(Empty)); + f.instruction(&Instruction::LocalGet(5)); + f.instruction(&Instruction::LocalGet(2)); + f.instruction(&Instruction::I32Eq); + f.instruction(&Instruction::If(Empty)); + f.instruction(&Instruction::I32Const(1)); + f.instruction(&Instruction::Return); + f.instruction(&Instruction::End); + f.instruction(&Instruction::LocalGet(3)); + f.instruction(&Instruction::LocalGet(5)); + f.instruction(&Instruction::I32Add); + f.instruction(&Instruction::I32Load8U(byte)); + f.instruction(&Instruction::LocalGet(4)); + f.instruction(&Instruction::LocalGet(5)); + f.instruction(&Instruction::I32Add); + f.instruction(&Instruction::I32Load8U(byte)); + f.instruction(&Instruction::I32Ne); + f.instruction(&Instruction::If(Empty)); + f.instruction(&Instruction::I32Const(0)); + f.instruction(&Instruction::Return); + f.instruction(&Instruction::End); + f.instruction(&Instruction::LocalGet(5)); + f.instruction(&Instruction::I32Const(1)); + f.instruction(&Instruction::I32Add); + f.instruction(&Instruction::LocalSet(5)); + f.instruction(&Instruction::Br(0)); + f.instruction(&Instruction::End); + f.instruction(&Instruction::Unreachable); + f.instruction(&Instruction::End); + (f, sites) + } + // ----------------------------------------------------------------------- // String literal handling // ----------------------------------------------------------------------- @@ -2633,77 +2770,101 @@ impl Codegen { }); } - fn compile_pair(&mut self, func: &mut Function, left: &Expr, right: &Expr) { - // Allocate 8 bytes for pair (2 x i32) - func.instruction(&Instruction::I32Const(8)); + /// Wasm types of the two components of the pair `expr` evaluates to + /// (through a borrow). `(i32, i32)` when the type table has no pair + /// type for it. + fn pair_fields(&self, expr: &Expr) -> (ValType, ValType) { + fn of(ty: &Ty) -> Option<(ValType, ValType)> { + match ty { + Ty::Prod { left, right } => Some((ty_to_valtype(left), ty_to_valtype(right))), + Ty::Borrow { inner, .. } | Ty::Ref { inner, .. } => of(inner), + _ => None, + } + } + self.ty_of(expr) + .and_then(of) + .unwrap_or((ValType::I32, ValType::I32)) + } + + /// Wasm types of the left and right payloads of the sum `expr` + /// evaluates to (through a borrow); i32 where the table has none. + fn sum_payloads(&self, expr: &Expr) -> (ValType, ValType) { + fn of(ty: &Ty) -> Option<(ValType, ValType)> { + match ty { + Ty::Sum { left, right } => Some((ty_to_valtype(left), ty_to_valtype(right))), + Ty::Borrow { inner, .. } | Ty::Ref { inner, .. } => of(inner), + _ => None, + } + } + self.ty_of(expr) + .and_then(of) + .unwrap_or((ValType::I32, ValType::I32)) + } + + /// Allocate `size` bytes and leave the pointer in a fresh local. + fn alloc_to_local(&mut self, func: &mut Function, size: u64) -> u32 { + func.instruction(&Instruction::I32Const(size as i32)); func.instruction(&Instruction::I32Const(0)); func.instruction(&Instruction::Call(self.fn_bump_alloc())); + let local = self.locals.temp(); + func.instruction(&Instruction::LocalSet(local)); + local + } - let pair_local = self.locals.temp(); - func.instruction(&Instruction::LocalTee(pair_local)); + /// A pair is a pointer to its two components, each at its natural + /// alignment: `[left @ 0][right @ pair_layout]`. + fn compile_pair(&mut self, func: &mut Function, left: &Expr, right: &Expr) { + let (left_ty, right_ty) = (self.valtype_of(left), self.valtype_of(right)); + let (right_offset, size) = pair_layout(left_ty, right_ty); + let pair_local = self.alloc_to_local(func, size); - // Store left at offset 0 + func.instruction(&Instruction::LocalGet(pair_local)); self.compile_expr(func, left); - func.instruction(&Instruction::I32Store(mem_arg(0))); + emit_typed_store(func, left_ty, 0); - // Store right at offset 4 func.instruction(&Instruction::LocalGet(pair_local)); self.compile_expr(func, right); - func.instruction(&Instruction::I32Store(mem_arg(4))); + emit_typed_store(func, right_ty, right_offset); - // Return pair pointer func.instruction(&Instruction::LocalGet(pair_local)); } fn compile_fst(&mut self, func: &mut Function, inner: &Expr) { + let (left_ty, _) = self.pair_fields(inner); self.compile_expr(func, inner); - func.instruction(&Instruction::I32Load(mem_arg(0))); + emit_typed_load(func, left_ty, 0); } fn compile_snd(&mut self, func: &mut Function, inner: &Expr) { + let (left_ty, right_ty) = self.pair_fields(inner); self.compile_expr(func, inner); - func.instruction(&Instruction::I32Load(mem_arg(4))); + emit_typed_load(func, right_ty, pair_layout(left_ty, right_ty).0); } - fn compile_inl(&mut self, func: &mut Function, value: &Expr) { - // Sum types: tag (i32) + value (i32) = 8 bytes - func.instruction(&Instruction::I32Const(8)); - func.instruction(&Instruction::I32Const(0)); - func.instruction(&Instruction::Call(self.fn_bump_alloc())); - - let sum_local = self.locals.temp(); - func.instruction(&Instruction::LocalTee(sum_local)); + /// A sum is a pointer to `[tag: i32 @ 0][payload @ sum_payload_offset]`; + /// tag 0 is left, tag 1 is right. + fn compile_injection(&mut self, func: &mut Function, tag: i32, value: &Expr) { + let payload_ty = self.valtype_of(value); + let offset = sum_payload_offset(payload_ty); + let sum_local = self.alloc_to_local(func, offset + valtype_storage(payload_ty).0); - // Store tag 0 (left) - func.instruction(&Instruction::I32Const(0)); + func.instruction(&Instruction::LocalGet(sum_local)); + func.instruction(&Instruction::I32Const(tag)); func.instruction(&Instruction::I32Store(mem_arg(0))); - // Store value at offset 4 func.instruction(&Instruction::LocalGet(sum_local)); self.compile_expr(func, value); - func.instruction(&Instruction::I32Store(mem_arg(4))); + emit_typed_store(func, payload_ty, offset); func.instruction(&Instruction::LocalGet(sum_local)); } - fn compile_inr(&mut self, func: &mut Function, value: &Expr) { - func.instruction(&Instruction::I32Const(8)); - func.instruction(&Instruction::I32Const(0)); - func.instruction(&Instruction::Call(self.fn_bump_alloc())); - - let sum_local = self.locals.temp(); - func.instruction(&Instruction::LocalTee(sum_local)); - - // Store tag 1 (right) - func.instruction(&Instruction::I32Const(1)); - func.instruction(&Instruction::I32Store(mem_arg(0))); - - // Store value at offset 4 - func.instruction(&Instruction::LocalGet(sum_local)); - self.compile_expr(func, value); - func.instruction(&Instruction::I32Store(mem_arg(4))); + fn compile_inl(&mut self, func: &mut Function, value: &Expr) { + self.compile_injection(func, 0, value); + } - func.instruction(&Instruction::LocalGet(sum_local)); + fn compile_inr(&mut self, func: &mut Function, value: &Expr) { + self.compile_injection(func, 1, value); } fn compile_case( @@ -2715,6 +2876,7 @@ impl Codegen { right_var: &str, right_body: &Expr, ) { + let (left_ty, right_ty) = self.sum_payloads(scrutinee); self.compile_expr(func, scrutinee); let scrutinee_local = self.locals.temp(); @@ -2730,8 +2892,8 @@ impl Codegen { // Right branch (tag == 1) func.instruction(&Instruction::LocalGet(scrutinee_local)); - func.instruction(&Instruction::I32Load(mem_arg(4))); - let right_local = self.locals.bind(right_var, false); + emit_typed_load(func, right_ty, sum_payload_offset(right_ty)); + let right_local = self.locals.bind_typed(right_var, false, right_ty); func.instruction(&Instruction::LocalSet(right_local)); self.compile_expr(func, right_body); @@ -2739,8 +2901,8 @@ impl Codegen { // Left branch (tag == 0) func.instruction(&Instruction::LocalGet(scrutinee_local)); - func.instruction(&Instruction::I32Load(mem_arg(4))); - let left_local = self.locals.bind(left_var, false); + emit_typed_load(func, left_ty, sum_payload_offset(left_ty)); + let left_local = self.locals.bind_typed(left_var, false, left_ty); func.instruction(&Instruction::LocalSet(left_local)); self.compile_expr(func, left_body); @@ -2786,6 +2948,23 @@ impl Codegen { /// through on inner refutation (needed once #66 admits patterns /// like `Some(Some(_))` + `Some(None)` + `None`). fn compile_match(&mut self, func: &mut Function, scrutinee: &Expr, arms: &[MatchArm]) { + // Patterns read every component as an i32 at the i32 layout. A + // scrutinee with an I64, F32 or F64 component would be read at + // the wrong offset and width, so it is refused, not miscompiled. + let wide = self + .ty_of(scrutinee) + .filter(|ty| has_non_i32_component(ty)) + .cloned(); + if let Some(ty) = wide { + self.unsupported.get_or_insert_with(|| { + CodegenError(format!( + "{UNSUPPORTED_PREFIX}: `match` (or a destructuring `let`) on a value \ + with an I64, F32 or F64 component (type {ty:?}); read the \ + component with `fst`, `snd`, `.N` or `case`" + )) + }); + } + use wasm_encoder::BlockType; // Recover data type info from the first constructor pattern (if any). @@ -3113,30 +3292,20 @@ impl Codegen { func.instruction(&Instruction::Call(self.fn_region_exit())); } + /// `copy(e)` is the pair `(e, e)`. fn compile_copy(&mut self, func: &mut Function, inner: &Expr) { - let tmp = self.locals.temp(); + let ty = self.valtype_of(inner); + let (second_offset, size) = pair_layout(ty, ty); + let value = self.locals.temp_typed(ty); self.compile_expr(func, inner); - func.instruction(&Instruction::LocalSet(tmp)); // save value, consume from stack - - // Allocate pair (8 bytes) and store value twice - func.instruction(&Instruction::I32Const(8)); - func.instruction(&Instruction::I32Const(0)); - func.instruction(&Instruction::Call(self.fn_bump_alloc())); - - let pair_local = self.locals.temp(); - func.instruction(&Instruction::LocalSet(pair_local)); // save pair ptr + func.instruction(&Instruction::LocalSet(value)); - // Store value at pair+0 - func.instruction(&Instruction::LocalGet(pair_local)); - func.instruction(&Instruction::LocalGet(tmp)); - func.instruction(&Instruction::I32Store(mem_arg(0))); - - // Store value at pair+4 - func.instruction(&Instruction::LocalGet(pair_local)); - func.instruction(&Instruction::LocalGet(tmp)); - func.instruction(&Instruction::I32Store(mem_arg(4))); - - // Return pair pointer + let pair_local = self.alloc_to_local(func, size); + for offset in [0, second_offset] { + func.instruction(&Instruction::LocalGet(pair_local)); + func.instruction(&Instruction::LocalGet(value)); + emit_typed_store(func, ty, offset); + } func.instruction(&Instruction::LocalGet(pair_local)); } @@ -3158,9 +3327,39 @@ impl Codegen { // The checker unifies the two operand types, so the left one // selects the instruction. let operand = self.valtype_of(left); - self.compile_expr(func, left); - self.compile_expr(func, right); - func.instruction(&binop_instruction(op, operand)); + let on_strings = matches!(self.ty_of(left), Some(Ty::String(_))); + match (op, operand) { + // Strings are equal when their contents are. + (BinOp::Eq | BinOp::Ne, _) if on_strings => { + self.compile_expr(func, left); + self.compile_expr(func, right); + func.instruction(&Instruction::Call(self.fn_string_eq())); + if op == BinOp::Ne { + func.instruction(&Instruction::I32Eqz); + } + } + // Wasm has no float remainder: call the exact `fmod` helper. + // An F32 remainder taken in F64 is exact, so it is demoted + // without a second rounding. + (BinOp::Mod, ValType::F32) => { + self.compile_expr(func, left); + func.instruction(&Instruction::F64PromoteF32); + self.compile_expr(func, right); + func.instruction(&Instruction::F64PromoteF32); + func.instruction(&Instruction::Call(self.fn_fmod())); + func.instruction(&Instruction::F32DemoteF64); + } + (BinOp::Mod, ValType::F64) => { + self.compile_expr(func, left); + self.compile_expr(func, right); + func.instruction(&Instruction::Call(self.fn_fmod())); + } + _ => { + self.compile_expr(func, left); + self.compile_expr(func, right); + func.instruction(&binop_instruction(op, operand)); + } + } } fn compile_unaryop(&mut self, func: &mut Function, op: UnaryOp, operand: &Expr) { @@ -3192,38 +3391,28 @@ impl Codegen { } fn compile_list_lit(&mut self, func: &mut Function, elements: &[Expr]) { - // Create list with capacity = length - let capacity = elements.len() as i32; - func.instruction(&Instruction::I32Const(capacity)); + // Create the list with capacity = length; the handle stays on the + // stack. Each append takes the handle and the element (widened to + // a slot) and leaves the handle. + func.instruction(&Instruction::I32Const(elements.len() as i32)); func.instruction(&Instruction::Call(self.fn_list_new())); - - // list_new returns handle on stack - // For each element: dup handle, append element, drop status, keep new handle for elem in elements { - // Duplicate handle for append call - func.instruction(&Instruction::LocalTee(self.locals.temp())); - - // Compile element self.compile_expr(func, elem); - - // Call list_append (consumes handle + element, returns new handle) + emit_to_slot(func, self.valtype_of(elem)); func.instruction(&Instruction::Call(self.fn_list_append())); - - // list_append returns new handle on stack } - - // Final handle is on stack } fn compile_list_index(&mut self, func: &mut Function, list: &Expr, index: &Expr) { - // Compile list expression + let elem = match self.ty_of(list) { + Some(Ty::List(elem)) => ty_to_valtype(elem), + _ => ValType::I32, + }; self.compile_expr(func, list); - - // Compile index expression self.compile_expr(func, index); - - // Call list_get(handle, idx) -> element + // list_get(handle, idx) -> slot; traps when idx is out of range. func.instruction(&Instruction::Call(self.fn_list_get())); + emit_from_slot(func, elem); } fn compile_tuple_lit(&mut self, func: &mut Function, elements: &[Expr]) { @@ -3353,7 +3542,7 @@ fn binop_instruction(op: BinOp, ty: ValType) -> Instruction<'static> { (BinOp::Div, V::F64) => I::F64Div, (BinOp::Div, _) => I::I32DivS, (BinOp::Mod, V::I64) => I::I64RemS, - // TODO(E5 fmod): float remainder + // Float remainder is a call to the `fmod` helper (`compile_binop`). (BinOp::Mod, V::F32 | V::F64) => I::Unreachable, (BinOp::Mod, _) => I::I32RemS, (BinOp::Lt, V::I64) => I::I64LtS, @@ -3385,6 +3574,318 @@ fn binop_instruction(op: BinOp, ty: ValType) -> Instruction<'static> { } } +/// Widen the value of wasm type `ty` on the stack to a list slot (i64). +fn emit_to_slot(func: &mut Function, ty: ValType) { + match ty { + ValType::I64 => {} + ValType::F64 => { + func.instruction(&Instruction::I64ReinterpretF64); + } + ValType::F32 => { + func.instruction(&Instruction::I32ReinterpretF32); + func.instruction(&Instruction::I64ExtendI32U); + } + _ => { + func.instruction(&Instruction::I64ExtendI32S); + } + } +} + +/// Narrow the list slot (i64) on the stack back to wasm type `ty`. +fn emit_from_slot(func: &mut Function, ty: ValType) { + match ty { + ValType::I64 => {} + ValType::F64 => { + func.instruction(&Instruction::F64ReinterpretI64); + } + ValType::F32 => { + func.instruction(&Instruction::I32WrapI64); + func.instruction(&Instruction::F32ReinterpretI32); + } + _ => { + func.instruction(&Instruction::I32WrapI64); + } + } +} + +/// `fmod(x: f64, y: f64) -> f64`: the exact IEEE remainder with the sign +/// of `x`, as Rust's `%` (and C's `fmod`) compute it. `x - trunc(x / y) * y` +/// is not exact, so this is the bit-level algorithm (after musl's `fmod`): +/// align the two significands by exponent and subtract-and-shift. +fn gen_fmod() -> Function { + use wasm_encoder::BlockType::Empty; + use Instruction as I; + // params: 0 = x, 1 = y (f64) + // locals: 2 = ux, 3 = uy (bits), 4 = ex, 5 = ey (exponents, signed), + // 6 = t (scratch), 7 = sx (sign bit of x) + const UX: u32 = 2; + const UY: u32 = 3; + const EX: u32 = 4; + const EY: u32 = 5; + const T: u32 = 6; + const SX: u32 = 7; + let mut f = Function::new(vec![(6, ValType::I64)]); + let mut emit = |ins: &[I<'static>]| { + for i in ins { + f.instruction(i); + } + }; + // `0.0 * x`: a zero with the sign of x. + let zero_like_x = [I::F64Const(0.0), I::LocalGet(0), I::F64Mul, I::Return]; + + emit(&[ + I::LocalGet(0), + I::I64ReinterpretF64, + I::LocalSet(UX), + I::LocalGet(1), + I::I64ReinterpretF64, + I::LocalSet(UY), + // ex = (ux >> 52) & 0x7ff + I::LocalGet(UX), + I::I64Const(52), + I::I64ShrU, + I::I64Const(0x7ff), + I::I64And, + I::LocalSet(EX), + I::LocalGet(UY), + I::I64Const(52), + I::I64ShrU, + I::I64Const(0x7ff), + I::I64And, + I::LocalSet(EY), + I::LocalGet(UX), + I::I64Const(63), + I::I64ShrU, + I::LocalSet(SX), + // y is zero, y is NaN, or x is infinite or NaN: the result is NaN. + I::LocalGet(UY), + I::I64Const(1), + I::I64Shl, + I::I64Eqz, + I::LocalGet(1), + I::LocalGet(1), + I::F64Ne, + I::I32Or, + I::LocalGet(EX), + I::I64Const(0x7ff), + I::I64Eq, + I::I32Or, + I::If(Empty), + I::LocalGet(0), + I::LocalGet(1), + I::F64Mul, + I::LocalGet(0), + I::LocalGet(1), + I::F64Mul, + I::F64Div, + I::Return, + I::End, + // |x| <= |y|: the result is x, or a signed zero when they are equal. + I::LocalGet(UX), + I::I64Const(1), + I::I64Shl, + I::LocalGet(UY), + I::I64Const(1), + I::I64Shl, + I::I64LeU, + I::If(Empty), + I::LocalGet(UX), + I::I64Const(1), + I::I64Shl, + I::LocalGet(UY), + I::I64Const(1), + I::I64Shl, + I::I64Eq, + I::If(Empty), + ]); + emit(&zero_like_x); + emit(&[I::End, I::LocalGet(0), I::Return, I::End]); + + // Normalise one operand: leave its significand with the implicit bit + // at position 52, and its exponent in `e` (below 1 for a subnormal). + let normalise = |u: u32, e: u32| -> Vec> { + vec![ + I::LocalGet(e), + I::I64Eqz, + I::If(Empty), + // Subnormal: count the leading zeros of the significand. + I::LocalGet(u), + I::I64Const(12), + I::I64Shl, + I::LocalSet(T), + I::Block(Empty), + I::Loop(Empty), + I::LocalGet(T), + I::I64Const(63), + I::I64ShrU, + I::I32WrapI64, + I::BrIf(1), + I::LocalGet(e), + I::I64Const(1), + I::I64Sub, + I::LocalSet(e), + I::LocalGet(T), + I::I64Const(1), + I::I64Shl, + I::LocalSet(T), + I::Br(0), + I::End, + I::End, + // u <<= 1 - e + I::LocalGet(u), + I::I64Const(1), + I::LocalGet(e), + I::I64Sub, + I::I64Shl, + I::LocalSet(u), + I::Else, + // Normal: keep the 52 significand bits, set the implicit bit. + I::LocalGet(u), + I::I64Const(0x000f_ffff_ffff_ffff), + I::I64And, + I::I64Const(1 << 52), + I::I64Or, + I::LocalSet(u), + I::End, + ] + }; + emit(&normalise(UX, EX)); + emit(&normalise(UY, EY)); + + // One subtract step: t = ux - uy; when t >= 0, ux = t (and a zero t + // ends the whole computation with a signed zero). + let subtract_step: Vec> = [ + vec![ + I::LocalGet(UX), + I::LocalGet(UY), + I::I64Sub, + I::LocalTee(T), + I::I64Const(63), + I::I64ShrU, + I::I64Eqz, + I::If(Empty), + I::LocalGet(T), + I::I64Eqz, + I::If(Empty), + ], + zero_like_x.to_vec(), + vec![I::End, I::LocalGet(T), I::LocalSet(UX), I::End], + ] + .concat(); + + // for (; ex > ey; ex--) { subtract step; ux <<= 1 } + emit(&[ + I::Block(Empty), + I::Loop(Empty), + I::LocalGet(EX), + I::LocalGet(EY), + I::I64LeS, + I::BrIf(1), + ]); + emit(&subtract_step); + emit(&[ + I::LocalGet(UX), + I::I64Const(1), + I::I64Shl, + I::LocalSet(UX), + I::LocalGet(EX), + I::I64Const(1), + I::I64Sub, + I::LocalSet(EX), + I::Br(0), + I::End, + I::End, + ]); + emit(&subtract_step); + + emit(&[ + // for (; ux >> 52 == 0; ux <<= 1, ex--); + I::Block(Empty), + I::Loop(Empty), + I::LocalGet(UX), + I::I64Const(52), + I::I64ShrU, + I::I64Eqz, + I::I32Eqz, + I::BrIf(1), + I::LocalGet(UX), + I::I64Const(1), + I::I64Shl, + I::LocalSet(UX), + I::LocalGet(EX), + I::I64Const(1), + I::I64Sub, + I::LocalSet(EX), + I::Br(0), + I::End, + I::End, + // Scale the result back. + I::LocalGet(EX), + I::I64Const(0), + I::I64GtS, + I::If(Empty), + // Normal: drop the implicit bit, put the exponent back. + I::LocalGet(UX), + I::I64Const(1 << 52), + I::I64Sub, + I::LocalGet(EX), + I::I64Const(52), + I::I64Shl, + I::I64Or, + I::LocalSet(UX), + I::Else, + // Subnormal: ux >>= 1 - ex + I::LocalGet(UX), + I::I64Const(1), + I::LocalGet(EX), + I::I64Sub, + I::I64ShrU, + I::LocalSet(UX), + I::End, + // Sign of x. + I::LocalGet(UX), + I::LocalGet(SX), + I::I64Const(63), + I::I64Shl, + I::I64Or, + I::F64ReinterpretI64, + I::End, + ]); + f +} + +/// Layout of a pair: the offset of the second component and the total +/// size, with each component at its natural alignment. `(i32, i32)` is +/// `(4, 8)`. +fn pair_layout(first: ValType, second: ValType) -> (u64, u64) { + let (first_size, _, _) = valtype_storage(first); + let (second_size, second_align, _) = valtype_storage(second); + let offset = align_up(first_size, second_align); + (offset, offset + second_size) +} + +/// Offset of the payload of a sum cell, after its i32 tag: 4, or 8 for +/// an 8-byte payload. +fn sum_payload_offset(payload: ValType) -> u64 { + align_up(4, valtype_storage(payload).1) +} + +/// True when a value of type `ty` has a component that is not an i32 at +/// run time (an I64, F32 or F64 inside a pair, sum, tuple or list, or +/// the value itself). +fn has_non_i32_component(ty: &Ty) -> bool { + match ty { + Ty::Base(BaseTy::I64 | BaseTy::F32 | BaseTy::F64) => true, + Ty::Prod { left, right } | Ty::Sum { left, right } => { + has_non_i32_component(left) || has_non_i32_component(right) + } + Ty::Tuple(elems) => elems.iter().any(has_non_i32_component), + Ty::List(elem) => has_non_i32_component(elem), + Ty::Borrow { inner, .. } | Ty::Ref { inner, .. } => has_non_i32_component(inner), + _ => false, + } +} + fn mem_arg(offset: u64) -> wasm_encoder::MemArg { wasm_encoder::MemArg { offset, @@ -5375,8 +5876,8 @@ mod tests { let entries = carriers::parse_access_sites_payload(&payload).expect("access-sites payload parses"); // string_new (2 stores) + string_len (1 load) + string_concat - // (4 loads + 2 stores) = 9 typed accesses. - assert_eq!(entries.len(), 9); + // (4 loads + 2 stores) + string_eq (4 loads) = 13 typed accesses. + assert_eq!(entries.len(), 13); // Collect function bodies in index order + the import count so // entry.func_idx (imports-included space) maps to a body. diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv index d4c5f988..d0c83b5c 100644 --- a/tests/probes/expectations.tsv +++ b/tests/probes/expectations.tsv @@ -33,17 +33,17 @@ rec1 check error:DesugarError error:DesugarError E0: recursive data is a named D rec1 compile error:DesugarError error:DesugarError E0 rec1 wasm-run no-module no-module E0 i64 check ok ok E5 (already accepted) -i64 compile error:InvalidWasm ok E5 typed side table: I64 binops lower to i64 -i64 wasm-run no-module ok:0 E5 +i64 compile ok ok E5 typed side table: I64 binops lower to i64 +i64 wasm-run ok:0 ok:0 E5 (baseline recorded from `ephapax run`, which uses the same engine: the wasmtime CLI was not installed) s1 check ok ok E5 (already accepted) -s1 compile error:InvalidWasm ok E5: String.len lowering -s1 wasm-run no-module ok:2 E5: wasm_e2e s1 returns the length of "hi" +s1 compile ok ok E5: String.len lowering +s1 wasm-run ok:2 ok:2 E5: wasm_e2e s1 returns the length of "hi" (baseline recorded from `ephapax run`, which uses the same engine: the wasmtime CLI was not installed) s6 check ok ok E5 (already accepted) -s6 compile error:InvalidWasm ok E5: String.len on a String@r parameter -s6 wasm-run no-module ok:0 E5 +s6 compile ok ok E5: String.len on a String@r parameter +s6 wasm-run ok:0 ok:0 E5 (baseline recorded from `ephapax run`, which uses the same engine: the wasmtime CLI was not installed) t3 check ok ok E5 (already accepted) -t3 compile error:InvalidWasm ok E5: n-ary tuples -t3 wasm-run no-module ok:0 E5 +t3 compile ok ok E5: n-ary tuples +t3 wasm-run ok:0 ok:0 E5 (baseline recorded from `ephapax run`, which uses the same engine: the wasmtime CLI was not installed) perform check error:CodegenError error:CodegenError E1 + E3: check runs the same unsupported-feature validation as codegen perform compile error:CodegenError error:CodegenError E1 tests/fail_loud.rs: perform/handle is a CodegenError perform wasm-run no-module no-module E1 From f939dc205268789b7371b13aa111cc5e4e0a4299 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 11:39:35 +0000 Subject: [PATCH 05/13] fix(driver,repl,lsp): review findings on the one-front-end change Findings from the Codex and CodeRabbit reviews of this pull request, each checked against the code. - Merge: extern blocks were deduplicated by ABI, so a second module's `extern "env"` block was dropped whole and its functions were unknown to codegen. Extern items are now deduplicated one by one. - Resolver: a source with no directory no longer reads an imported module relative to the process's working directory; a miss is an error. The empty parent of a bare file name is the current directory for every caller (the query engine did not get the module index). - Resolver: the module index walk skips a directory it already walked (a symlink cycle), and `module_size(x)` is not a `module` header. - CLI: an explicit `--stdlib` that is not a directory is an error. It was ignored without a message. - LSP: the analysis reads files, so it runs on the blocking pool. - REPL: a declaration typed at the prompt is not type-checked in isolation, so it can use a function from an earlier line again (a regression from routing it through the full check). A declaration that parses but does not load now shows the driver's error. Tests: 3 driver unit tests, 1 REPL test. TEST-NEEDS count updated. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- Cargo.lock | 1 + TEST-NEEDS.adoc | 4 +- src/ephapax-cli/src/main.rs | 3 + src/ephapax-driver/Cargo.toml | 3 + src/ephapax-driver/src/lib.rs | 138 +++++++++++++++++++++++------ src/ephapax-driver/src/resolver.rs | 46 ++++++++-- src/ephapax-lsp/src/main.rs | 17 +++- src/ephapax-repl/src/lib.rs | 35 +++++++- 8 files changed, 208 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fd11a3f..97b1706f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,6 +799,7 @@ dependencies = [ "ephapax-syntax", "ephapax-typing", "ephapax-wasm", + "tempfile", "thiserror 2.0.18", ] diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index fab3e166..599f4d73 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: *573 tests* (`+cargo test --workspace --all-targets+`); +* Total: *577 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 573 +* Documented all-target tests: 577 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/src/main.rs b/src/ephapax-cli/src/main.rs index 535d5094..ddf42196 100644 --- a/src/ephapax-cli/src/main.rs +++ b/src/ephapax-cli/src/main.rs @@ -275,6 +275,9 @@ fn run_repl(preload: Option, verbose: bool) -> Result<(), String> { /// Load a program through the one front end. On failure, print the /// details to stderr and return the one-line summary. fn load_program(path: &Path, stdlib: Option) -> Result { + if let Some(dir) = stdlib.as_ref().filter(|d| !d.is_dir()) { + return Err(format!("--stdlib {}: not a directory", dir.display())); + } let stdlib_dir = ephapax_driver::stdlib_dir(stdlib); ephapax_driver::load_program(path, stdlib_dir.as_deref()).map_err(|e| report_load_error(&e)) } diff --git a/src/ephapax-driver/Cargo.toml b/src/ephapax-driver/Cargo.toml index ad11ff74..43735774 100644 --- a/src/ephapax-driver/Cargo.toml +++ b/src/ephapax-driver/Cargo.toml @@ -19,3 +19,6 @@ ephapax-desugar = { path = "../ephapax-desugar" } ephapax-typing.workspace = true ephapax-wasm.workspace = true thiserror.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index e1e33fca..e23b4582 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -23,7 +23,7 @@ use std::path::{Path, PathBuf}; use ephapax_desugar::{DataRegistry, DesugarError, Desugarer}; use ephapax_parser::ParseError; -use ephapax_syntax::{Decl, Module}; +use ephapax_syntax::{Decl, ExternItem, Module}; use ephapax_typing::{type_check_module_with_registry, ModuleRegistry, SpannedTypeError}; use ephapax_wasm::CodegenError; @@ -56,19 +56,19 @@ impl Program { /// 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. Duplicate - /// fn / extern-block names across modules are dropped (first-seen - /// wins, the root first). + /// `Fn` declarations become part of the same wasm module. A function, + /// or an extern item, that an earlier module already declared is + /// dropped (first-seen wins, the root first). pub fn into_merged(self) -> Module { - // Dedup keys: `fn:` for functions, `extern:` for whole - // extern blocks (an extern block emits one wasm import per fn item; - // re-emitting the same ABI block from another module would produce - // duplicate imports). - fn dedup_key(d: &Decl) -> Option { - match d { - Decl::Fn { name, .. } => Some(format!("fn:{name}")), - Decl::Extern { abi, .. } => Some(format!("extern:{abi}")), - _ => None, + // 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 + // deduplicated item by item: two modules may each declare + // different functions under one ABI, and both sets are needed. + fn extern_key(abi: &str, item: &ExternItem) -> String { + match item { + ExternItem::Fn { name, .. } => format!("extern:{abi}:fn:{name}"), + ExternItem::Type { name } => format!("extern:{abi}:type:{name}"), } } let mut modules = self.modules; @@ -76,16 +76,34 @@ impl Program { .pop() .expect("a program has at least its root module") .core; - let mut seen: std::collections::HashSet = - merged.decls.iter().filter_map(dedup_key).collect(); + let mut seen = std::collections::HashSet::new(); + for decl in &merged.decls { + match decl { + Decl::Fn { name, .. } => { + seen.insert(format!("fn:{name}")); + } + Decl::Extern { abi, items } => { + seen.extend(items.iter().map(|i| extern_key(abi, i))); + } + _ => {} + } + } for dep in modules { for decl in dep.core.decls { - if let Some(key) = dedup_key(&decl) { - if !seen.insert(key) { - continue; + match decl { + Decl::Fn { ref name, .. } => { + if seen.insert(format!("fn:{name}")) { + merged.decls.push(decl); + } + } + Decl::Extern { abi, mut items } => { + items.retain(|i| seen.insert(extern_key(&abi, i))); + if !items.is_empty() { + merged.decls.push(Decl::Extern { abi, items }); + } } + other => merged.decls.push(other), } - merged.decls.push(decl); } } merged @@ -156,18 +174,13 @@ pub fn parse_program(root: &Path, stdlib_dir: Option<&Path>) -> Result Vec { + let mut names = Vec::new(); + for decl in &module.decls { + if let Decl::Extern { items, .. } = decl { + for item in items { + if let ExternItem::Fn { name, .. } = item { + names.push(name.to_string()); + } + } + } + } + names.sort(); + names + } + + /// Two modules may declare different functions under one ABI. The + /// merge used to keep the first block and drop the other whole. + #[test] + fn merge_keeps_extern_items_of_every_module() { + let dir = tempfile::tempdir().expect("tempdir"); + let write = |name: &str, text: &str| { + std::fs::write(dir.path().join(name), text).expect("write module") + }; + write( + "lib.eph", + "module lib\n\nextern \"env\" {\n fn shared(): I32\n fn only_lib(): I32\n}\n", + ); + write( + "app.eph", + "module app\n\nimport lib\n\nextern \"env\" {\n fn shared(): I32\n fn only_app(): I32\n}\n\n\ + 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(); + // `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"); + } + + /// A source with no directory resolves imports in the stdlib only: a + /// miss is an error, not a read relative to the working directory. + #[test] + fn fileless_source_does_not_read_the_working_directory() { + // The test runs in the crate directory, which has a `Cargo.toml`; + // `import Cargo` must not turn into a read of `./Cargo.eph`. + let err = parse_source( + "module m\n\nimport Cargo\n\nfn main(): I32 = 0\n", + "m", + None, + None, + ) + .expect_err("no such module"); + assert!( + matches!(&err, DriverError::Io { message, .. } if message.contains("module not found")), + "{err}" + ); + } + + /// `module_size(x)` is not a `module` header. + #[test] + fn module_keyword_needs_a_word_boundary() { + let program = parse_source("fn module_size(x: I32): I32 = x\n", "app.eph", None, None) + .expect("parses"); + assert_eq!(program.root().logical_path, "app"); + } +} diff --git a/src/ephapax-driver/src/resolver.rs b/src/ephapax-driver/src/resolver.rs index 12e05990..469f1594 100644 --- a/src/ephapax-driver/src/resolver.rs +++ b/src/ephapax-driver/src/resolver.rs @@ -63,6 +63,15 @@ pub(crate) fn resolve( base_dir: Option<&Path>, stdlib_dir: Option<&Path>, ) -> Result, DriverError> { + // A bare file name has the empty path as its parent: that is the + // current directory. + let base_dir = base_dir.map(|d| { + if d.as_os_str().is_empty() { + Path::new(".") + } else { + d + } + }); let mut r = Resolver { base_dir, stdlib_dir, @@ -116,11 +125,13 @@ impl Resolver<'_> { /// name. /// /// On a miss, the literal path is returned so the IO error names a - /// useful location. - fn locate(&mut self, logical: &str) -> PathBuf { - let direct = logical_to_file_path(logical, self.base_dir.unwrap_or(Path::new(""))); - if self.base_dir.is_some() && direct.exists() { - return direct; + /// useful location. A root with no directory (`base_dir` is `None`) + /// has no literal path: a miss is an error here, never a read + /// relative to the process's working directory. + fn locate(&mut self, logical: &str) -> Result { + let direct = self.base_dir.map(|dir| logical_to_file_path(logical, dir)); + if let Some(direct) = direct.as_ref().filter(|p| p.exists()) { + return Ok(direct.clone()); } let (base_dir, stdlib_dir) = (self.base_dir, self.stdlib_dir); let index = self.mod_index.get_or_insert_with(|| { @@ -132,7 +143,16 @@ impl Resolver<'_> { } idx }); - index.get(logical).cloned().unwrap_or(direct) + index + .get(logical) + .cloned() + .or(direct) + .ok_or_else(|| DriverError::Io { + path: logical_to_file_path(logical, Path::new("")), + message: "module not found: the importing source has no directory, \ + and the stdlib does not declare this module" + .to_string(), + }) } /// `root` is the root module's `(file, source)`; imports pass `None` @@ -152,7 +172,7 @@ impl Resolver<'_> { let (file_path, source) = match root { Some(r) => r, None => { - let file_path = self.locate(logical); + let file_path = self.locate(logical)?; let source = std::fs::read_to_string(&file_path).map_err(|e| DriverError::Io { path: file_path.clone(), message: e.to_string(), @@ -212,7 +232,13 @@ impl Resolver<'_> { fn scan_module_index(base_dir: &Path) -> HashMap { let mut idx = HashMap::new(); let mut stack = vec![base_dir.to_path_buf()]; + // Directories already walked, by canonical path: a symlink cycle + // would otherwise be walked until the path gets too long. + let mut walked = HashSet::new(); while let Some(dir) = stack.pop() { + if !dir.canonicalize().is_ok_and(|real| walked.insert(real)) { + continue; + } let entries = match std::fs::read_dir(&dir) { Ok(e) => e, Err(_) => continue, @@ -240,7 +266,11 @@ fn scan_module_index(base_dir: &Path) -> HashMap { fn first_module_declaration(source: &str) -> Option { for line in source.lines() { let line = line.trim_start(); - if let Some(rest) = line.strip_prefix("module") { + // The keyword, then whitespace: not `module_size(x)` or `modules`. + if let Some(rest) = line + .strip_prefix("module") + .filter(|r| r.starts_with(char::is_whitespace)) + { let rest = rest.trim(); // Take everything up to a whitespace, comma, or comment marker. let end = rest diff --git a/src/ephapax-lsp/src/main.rs b/src/ephapax-lsp/src/main.rs index 67ff7a3c..4826d119 100644 --- a/src/ephapax-lsp/src/main.rs +++ b/src/ephapax-lsp/src/main.rs @@ -82,7 +82,22 @@ impl Backend { /// Parse, type-check, and cache analysis for a document. async fn analyze_document(&self, uri: &Url, text: &str) -> Vec { - let (diagnostics, module_opt) = analyze(uri, text); + // The analysis reads imported files from disk, so it runs on the + // blocking pool, not on the async worker that serves requests. + let (owned_uri, owned_text) = (uri.clone(), text.to_string()); + let (diagnostics, module_opt) = + match tokio::task::spawn_blocking(move || analyze(&owned_uri, &owned_text)).await { + Ok(result) => result, + Err(e) => ( + vec![Diagnostic { + severity: Some(DiagnosticSeverity::ERROR), + source: Some("ephapax".to_string()), + message: format!("analysis failed: {e}"), + ..Diagnostic::default() + }], + None, + ), + }; let declarations = module_opt .as_ref() .map(|m| extract_declarations(m, text)) diff --git a/src/ephapax-repl/src/lib.rs b/src/ephapax-repl/src/lib.rs index 38cf5c8e..06a7cbc9 100644 --- a/src/ephapax-repl/src/lib.rs +++ b/src/ephapax-repl/src/lib.rs @@ -279,8 +279,13 @@ impl Repl { Err(errors) => { // Try parsing as declaration/module let module_src = format!("{}\n", line); - match self.load_module(&module_src, "repl") { + match self.load_declarations(&module_src) { Ok(()) => println!("{}", "Loaded.".green()), + // The line parses as declarations but does not load: + // that is the error to show, not the expression's. + Err(e) if !matches!(e, DriverError::Parse { .. }) => { + eprintln!("{} {}", "Error:".red().bold(), e); + } Err(_) => { // Show parse errors for error in errors { @@ -347,6 +352,17 @@ impl Repl { .map(|_| ()) } + /// Load declarations typed at the prompt. They go through the driver's + /// front end (imports, parse, desugar) but are not type-checked: a + /// line may use a function an earlier line defined, which only the + /// interpreter knows. (The REPL never checked prompt declarations.) + 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()); + Ok(()) + } + fn load( &mut self, program: Result, @@ -462,6 +478,23 @@ mod tests { assert!(matches!(result, Value::I32(1))); } + /// A declaration typed at the prompt may use one from an earlier line. + /// (One parameter each: the interpreter cannot yet call a function + /// of zero or several parameters by name.) + #[test] + fn prompt_declarations_see_earlier_ones() { + let mut repl = Repl::new().expect("REPL should initialize"); + repl.load_declarations("fn f(x: I32): I32 = x + 41\n") + .expect("f loads"); + repl.load_declarations("fn g(y: I32): I32 = f(y) + 1\n") + .expect("g loads although only the interpreter knows f"); + let result = repl + .interpreter + .call("g", vec![Value::I32(0)]) + .expect("g runs"); + assert!(matches!(result, Value::I32(42))); + } + #[test] fn test_eval_if() { let mut repl = Repl::new().expect("REPL should initialize"); From 9b61a4dbdc992147f0aaa3e0c01e464c78ca318b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 11:57:17 +0000 Subject: [PATCH 06/13] fix: second round of review findings (merged imports, typed locals, entry point, agreement) Six findings from the Codex review of the full pull request, each checked against the code. - Codegen type-checks the merged module on its own. The merge kept the root's `import` list, so a selective import (`import lib(inc)`) did not resolve there and `compile` / `run` failed after `check` passed. The merged module now has no imports. - The single-expression path (`compile_program`, and the fallback of `compile_module_with_main`) declared 16 i32 locals. An I64, F32 or F64 binding gave an invalid module. Each local is now declared at its own type. - `run` took `main` from the merged module, so it ran the `main` of an imported module when the root had none. The entry point is now the root module's `main`, on both execution paths. - The limit of the typed lowering (a pair or tuple pattern on a value with a 64-bit component) was found only during codegen, so `check` accepted what `compile` refused. It is now part of the validation the driver runs, with the checker's types (`validate_supported_typed`). The deferred-error field in codegen is gone. - Query engine: a program with imports, or a failed load, is not served from the parse memo, and a result that changed with no input change starts a new revision. An edit to an imported file is now seen. - LSP: an analysis runs on the blocking pool, so an older one could finish after a newer one and overwrite it. Each analysis has a number and only the latest commits and publishes. Tests: 4 CLI tests and 1 codegen unit test. TEST-NEEDS count 582. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 4 +- src/ephapax-cli/src/main.rs | 36 +++--- src/ephapax-cli/tests/driver_agreement.rs | 48 ++++++++ src/ephapax-cli/tests/run_wasm.rs | 39 +++++++ src/ephapax-driver/src/lib.rs | 23 ++-- src/ephapax-lsp/src/main.rs | 52 ++++++--- src/ephapax-query/src/lib.rs | 21 +++- src/ephapax-wasm/src/lib.rs | 134 +++++++++++++++------- 8 files changed, 265 insertions(+), 92 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 599f4d73..d53943da 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: *577 tests* (`+cargo test --workspace --all-targets+`); +* Total: *582 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 577 +* Documented all-target tests: 582 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/src/main.rs b/src/ephapax-cli/src/main.rs index ddf42196..14a1983f 100644 --- a/src/ephapax-cli/src/main.rs +++ b/src/ephapax-cli/src/main.rs @@ -321,13 +321,9 @@ fn run_file( if verbose { println!("{} Type check passed", "✓".green()); } - let module = program.into_merged(); - - if interp { - return run_interp(&module, ffi_libs, verbose); - } - - let main = module.decls.iter().find_map(|d| match d { + // The entry point is the `main` of the module that was asked for, + // never the `main` of a module it imports. + let main = program.root().core.decls.iter().find_map(|d| match d { Decl::Fn { name, params, @@ -336,12 +332,18 @@ fn run_file( } if name == "main" => Some((params.len(), ret_ty.clone())), _ => None, }); + let module = program.into_merged(); let Some((n_params, ret_ty)) = main else { if verbose { println!("{} Module loaded (no main function)", "✓".green()); } return Ok(()); }; + + if interp { + return run_interp(&module, ffi_libs, verbose); + } + if n_params != 0 { return Err(format!( "`main` takes {n_params} parameter(s); `ephapax run` calls `main` with none" @@ -397,21 +399,13 @@ fn run_interp( interp.load_module(module); - // Look for main function and run it - if interp.has_function("main") { - match interp.call_main() { - Ok(result) => { - println!("{}", result); - Ok(()) - } - Err(e) => Err(format!("Runtime error: {}", e)), - } - } else { - // No main, just report success - if verbose { - println!("{} Module loaded (no main function)", "✓".green()); + // The caller has checked that the root module has a `main`. + match interp.call_main() { + Ok(result) => { + println!("{}", result); + Ok(()) } - Ok(()) + Err(e) => Err(format!("Runtime error: {}", e)), } } diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs index d1337c68..c1dc7bda 100644 --- a/src/ephapax-cli/tests/driver_agreement.rs +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -214,3 +214,51 @@ fn out_of_range_integer_literal_is_a_parse_error() { } } } + +/// A limit of the typed lowering is a load error too: `check` must not +/// accept a program that `compile` refuses. (A pair or tuple pattern on a +/// value with a 64-bit component is not lowered yet.) +#[test] +fn check_rejects_what_typed_codegen_cannot_lower() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("wide.eph"), + "module wide\n\nfn main(): I64 =\n let (a, b) = (1, 5000000000)\n b\n", + ) + .expect("write program"); + for args in [&["check", "wide.eph"][..], &["compile", "wide.eph"]] { + let out = Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(tmp.path()) + .args(args) + .output() + .expect("spawn ephapax"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains("Codegen error") && stderr.contains("`match`"), + "{args:?}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } +} + +/// The query engine reads a program's imports from disk, and an imported +/// file is not one of its inputs. A program with imports is therefore +/// never served from the parse memo: an edit to the imported file shows. +#[test] +fn query_engine_sees_an_edit_to_an_imported_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + let lib = tmp.path().join("lib.eph"); + let app = tmp.path().join("app.eph"); + std::fs::write(&lib, "module lib\n\npub fn inc(n: I32): I32 = n + 1\n").expect("write lib"); + let app_text = "module app\n\nimport lib\n\nfn main(): I32 = inc(41)\n"; + std::fs::write(&app, app_text).expect("write app"); + + let id = app.to_string_lossy(); + let mut db = ephapax_query::QueryDb::new(); + db.set_source_text(&id, app_text); + assert!(db.typed(&id).is_ok(), "the program checks"); + + // The root text is unchanged; only the imported file changes. + std::fs::write(&lib, "module lib\n\npub fn inc(n: Bool): Bool = n\n").expect("rewrite lib"); + assert!(db.typed(&id).is_err(), "the stale memo was served"); +} diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index e569e7da..fd719c4e 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -116,3 +116,42 @@ fn interpreter_and_wasm_agree_except_known_failures() { "interpreter/wasm disagreements differ from tests/interp-known-failures.txt" ); } + +/// A selective import (`import lib(inc)`) compiles and runs. Codegen +/// type-checks the merged module on its own; the import used to be kept +/// there and did not resolve. +#[test] +fn selective_import_runs() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("lib.eph"), + "module lib\n\npub fn inc(n: I32): I32 = n + 1\n", + ) + .expect("write lib"); + std::fs::write( + tmp.path().join("app.eph"), + "module app\n\nimport lib(inc)\n\nfn main(): I32 = inc(41)\n", + ) + .expect("write app"); + assert_eq!(stdout_of(tmp.path(), &["run", "app.eph"]), "42"); +} + +/// The entry point is the `main` of the module that was asked for. A +/// `main` in an imported module is not run. +#[test] +fn run_does_not_execute_the_main_of_an_imported_module() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("lib.eph"), + "module lib\n\npub fn inc(n: I32): I32 = n + 1\n\nfn main(): I32 = 99\n", + ) + .expect("write lib"); + std::fs::write( + tmp.path().join("app.eph"), + "module app\n\nimport lib\n\nfn twice(n: I32): I32 = inc(inc(n))\n", + ) + .expect("write app"); + for args in [&["run", "app.eph"][..], &["run", "--interp", "app.eph"]] { + assert_eq!(stdout_of(tmp.path(), args), "", "{args:?}"); + } +} diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index e23b4582..10a73e0a 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -24,7 +24,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::{type_check_module_with_registry, ModuleRegistry, SpannedTypeError}; +use ephapax_typing::{module_expr_types, ModuleRegistry, SpannedTypeError}; use ephapax_wasm::CodegenError; /// One module of a loaded program, desugared to core. @@ -76,6 +76,10 @@ impl Program { .pop() .expect("a program has at least its root module") .core; + // The merged module is the whole program: nothing is left to + // import. Codegen type-checks it on its own, and a kept + // `import lib(inc)` would not resolve there. + merged.imports.clear(); let mut seen = std::collections::HashSet::new(); for decl in &merged.decls { match decl { @@ -225,20 +229,25 @@ pub fn parse_source( /// runs, so no tool accepts a program that `compile` must reject. pub fn check_program(program: &Program) -> Result<(), DriverError> { let mut registry = ModuleRegistry::new(); + let mut types = Vec::with_capacity(program.modules.len()); for m in &program.modules { - type_check_module_with_registry(&m.core, &mut registry).map_err(|error| { + types.push(module_expr_types(&m.core, &mut registry).map_err(|error| { DriverError::Type { module: m.core.name.to_string(), path: m.file_path.clone(), source_text: m.source.clone(), error: Box::new(error), } - })?; + })?); } - for m in &program.modules { - ephapax_wasm::validate_supported(&m.core).map_err(|error| DriverError::Unsupported { - path: m.file_path.clone(), - error, + // With the types, so a limit of the typed lowering (a `match` on a + // 64-bit component) is an error here and not only in `compile`. + for (m, types) in program.modules.iter().zip(&types) { + ephapax_wasm::validate_supported_typed(&m.core, types).map_err(|error| { + DriverError::Unsupported { + path: m.file_path.clone(), + error, + } })?; } Ok(()) diff --git a/src/ephapax-lsp/src/main.rs b/src/ephapax-lsp/src/main.rs index 4826d119..bcdf82bc 100644 --- a/src/ephapax-lsp/src/main.rs +++ b/src/ephapax-lsp/src/main.rs @@ -70,6 +70,10 @@ enum DeclKind { struct Backend { client: Client, documents: Mutex>, + /// Number of the latest analysis started for each document. An + /// analysis runs on the blocking pool, so an older one can finish + /// after a newer one; only the latest may commit its result. + generations: Mutex>, } impl Backend { @@ -77,11 +81,20 @@ impl Backend { Self { client, documents: Mutex::new(HashMap::new()), + generations: Mutex::new(HashMap::new()), } } - /// Parse, type-check, and cache analysis for a document. - async fn analyze_document(&self, uri: &Url, text: &str) -> Vec { + /// Parse, type-check, and cache analysis for a document. `None`: a + /// newer analysis of the same document started meanwhile, so this + /// result is stale and nothing was cached; do not publish it. + async fn analyze_document(&self, uri: &Url, text: &str) -> Option> { + let generation = { + let mut generations = self.generations.lock().ok()?; + let latest = generations.entry(uri.clone()).or_insert(0); + *latest += 1; + *latest + }; // The analysis reads imported files from disk, so it runs on the // blocking pool, not on the async worker that serves requests. let (owned_uri, owned_text) = (uri.clone(), text.to_string()); @@ -98,6 +111,9 @@ impl Backend { None, ), }; + if self.generations.lock().ok()?.get(uri) != Some(&generation) { + return None; + } let declarations = module_opt .as_ref() .map(|m| extract_declarations(m, text)) @@ -115,7 +131,7 @@ impl Backend { ); } - diagnostics + Some(diagnostics) } /// Find the declaration at a given position (for go-to-definition). @@ -319,30 +335,34 @@ impl LanguageServer for Backend { let uri = params.text_document.uri; let text = params.text_document.text; - let diagnostics = self.analyze_document(&uri, &text).await; - self.client - .publish_diagnostics(uri, diagnostics, None) - .await; + if let Some(diagnostics) = self.analyze_document(&uri, &text).await { + self.client + .publish_diagnostics(uri, diagnostics, None) + .await; + } } async fn did_change(&self, params: DidChangeTextDocumentParams) { let uri = params.text_document.uri; let text = ¶ms.content_changes[0].text; - let diagnostics = self.analyze_document(&uri, text).await; - self.client - .publish_diagnostics(uri, diagnostics, None) - .await; + if let Some(diagnostics) = self.analyze_document(&uri, text).await { + self.client + .publish_diagnostics(uri, diagnostics, None) + .await; + } } async fn did_save(&self, params: DidSaveTextDocumentParams) { if let Some(text) = params.text { - let diagnostics = self + if let Some(diagnostics) = self .analyze_document(¶ms.text_document.uri, &text) - .await; - self.client - .publish_diagnostics(params.text_document.uri, diagnostics, None) - .await; + .await + { + self.client + .publish_diagnostics(params.text_document.uri, diagnostics, None) + .await; + } } } diff --git a/src/ephapax-query/src/lib.rs b/src/ephapax-query/src/lib.rs index 44f467ab..ebffce2b 100644 --- a/src/ephapax-query/src/lib.rs +++ b/src/ephapax-query/src/lib.rs @@ -36,8 +36,9 @@ //! threaded by hand rather than tracked through a general query trait. A //! production engine would: (a) track an arbitrary dependency DAG (needed //! for the multi-module `import` case: `parsed` resolves imports through -//! `ephapax-driver`, but an imported file is not an input here, so an edit -//! to it does not invalidate the importer's memos), (b) return +//! `ephapax-driver`, but an imported file is not an input here, so a +//! program with imports is re-read from disk on every demand and never +//! served from the parse memo), (b) return //! `Arc`/references instead of cloning memoised values, and (c) //! optionally persist memos through a `QueryStore` backing (the disabled //! `ephapax-vram-cache` is a natural candidate once `ephapax-proven` @@ -144,7 +145,13 @@ impl QueryDb { let dep = self.input_changed_at(id); let rev = self.revision; if let Some(m) = self.parsed.get_mut(id) { - if dep <= m.verified_at { + // A program with imports also depends on files that are not + // inputs of this database, and a failed load may be due to + // one of them. Only a successful, import-free result is + // served from the memo; anything else is loaded again. + // (Downstream memos still skip when the result is unchanged.) + let self_contained = matches!(&m.value, Ok(p) if p.modules.len() == 1); + if dep <= m.verified_at && self_contained { let value = m.value.clone(); m.verified_at = rev; return value; @@ -159,6 +166,14 @@ impl QueryDb { let stdlib = ephapax_driver::stdlib_dir(None); let new = parse_source(&text, id, base_dir, stdlib.as_deref()); self.recomputes += 1; + // The result changed although the input did not: an imported file + // changed on disk. That is a new revision, so that the queries + // that depend on this one recompute. + if matches!(self.parsed.get(id), Some(old) + if dep <= old.verified_at && !parsed_equiv(&old.value, &new)) + { + self.revision.0 += 1; + } let rev = self.revision; let changed_at = match self.parsed.get(id) { Some(old) if parsed_equiv(&old.value, &new) => old.changed_at, diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index fcaa5b99..a132bde2 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -201,6 +201,17 @@ pub const NOT_WELL_TYPED: &str = "not well-typed"; /// effect `perform` / `handle` (no lowering until E17) and raw variadic /// `__ffi` (no sound lowering at all; typed `extern` is the FFI path). pub fn first_unsupported(expr: &Expr) -> Option { + first_unsupported_in(expr, None) +} + +/// [`first_unsupported`], plus the constructs whose lowering depends on a +/// type: with the checker's table, a `match` on a value that has an I64, +/// F32 or F64 component (patterns read every component as an i32). +pub fn first_unsupported_typed(expr: &Expr, types: &ExprTypes) -> Option { + first_unsupported_in(expr, Some(types)) +} + +fn first_unsupported_in(expr: &Expr, types: Option<&ExprTypes>) -> Option { fn unsupported(what: &str, span: ephapax_syntax::Span) -> CodegenError { CodegenError(format!( "{UNSUPPORTED_PREFIX}: {what} (at byte {}..{})", @@ -222,6 +233,19 @@ pub fn first_unsupported(expr: &Expr) -> Option { ), expr.span, )), + ExprKind::Match { scrutinee, .. } => types + .and_then(|t| t.get(&scrutinee.id)) + .filter(|ty| has_non_i32_component(ty)) + .map(|ty| { + unsupported( + &format!( + "`match` (or a destructuring `let`) on a value with an I64, F32 \ + or F64 component (type {ty:?}); read the component with `fst`, \ + `snd`, `.N` or `case`" + ), + expr.span, + ) + }), _ => None, }; if here.is_some() { @@ -277,7 +301,9 @@ pub fn first_unsupported(expr: &Expr) -> Option { ExprKind::ListIndex { list, index } => vec![list, index], ExprKind::TupleIndex { tuple, .. } => vec![tuple], }; - children.into_iter().find_map(first_unsupported) + children + .into_iter() + .find_map(|child| first_unsupported_in(child, types)) } /// Check every function body using [`first_unsupported`]. @@ -285,13 +311,27 @@ pub fn first_unsupported(expr: &Expr) -> Option { /// Returns the first unsupported-construct error in declaration order, with /// the function name added. Non-function declarations are ignored. pub fn validate_supported(module: &AstModule) -> Result<(), CodegenError> { + validate_supported_in(module, None) +} + +/// [`validate_supported`] with the checker's type table, so the +/// type-dependent limits ([`first_unsupported_typed`]) are checked too. +/// The driver runs this, so `check` rejects what `compile` must reject. +pub fn validate_supported_typed(module: &AstModule, types: &ExprTypes) -> Result<(), CodegenError> { + validate_supported_in(module, Some(types)) +} + +fn validate_supported_in( + module: &AstModule, + types: Option<&ExprTypes>, +) -> Result<(), CodegenError> { for decl in &module.decls { let (name, expr, kind) = match decl { Decl::Fn { name, body, .. } => (name, body, "function"), Decl::Const { name, value, .. } => (name, value, "constant"), _ => continue, }; - if let Some(CodegenError(msg)) = first_unsupported(expr) { + if let Some(CodegenError(msg)) = first_unsupported_in(expr, types) { return Err(CodegenError(format!("{msg} in {kind} `{name}`"))); } } @@ -468,12 +508,6 @@ pub struct Codegen { /// instruction selection and layouts read it through [`Self::ty_of`]. types: ExprTypes, - /// The first construct met while lowering that has no correct - /// lowering yet. `compile_expr` cannot return an error, so it is - /// recorded here and the public entry points fail with it instead of - /// returning a module that computes a wrong value. - unsupported: Option, - // -- Module-level bookkeeping ------------------------------------------- /// String literal data section entries: (offset, bytes) data_entries: Vec<(u32, Vec)>, @@ -595,7 +629,6 @@ impl Codegen { module: WasmModule::new(), locals: LocalTracker::default(), types: ExprTypes::new(), - unsupported: None, data_entries: Vec::new(), data_offset: 1024, // Start string data after metadata area user_fns: HashMap::new(), @@ -762,6 +795,9 @@ impl Codegen { .check(main_expr) .map_err(|e| CodegenError(format!("{NOT_WELL_TYPED}: {e}")))?; self.types = checker.expr_types(); + if let Some(err) = first_unsupported_typed(main_expr, &self.types) { + return Err(err); + } self.emit_types(); self.emit_imports(); @@ -785,12 +821,7 @@ impl Codegen { // Main function (index self.first_user_fn()) func_sec.function(TYPE_VOID_VOID); - self.locals = LocalTracker::new(0); - let mut main_func = Function::new(vec![(16, ValType::I32)]); - self.compile_expr(&mut main_func, main_expr); - main_func.instruction(&Instruction::Drop); - main_func.instruction(&Instruction::End); - code_sec.function(&main_func); + code_sec.function(&self.compile_standalone_body(main_expr)); // Lambda functions discovered during expression compilation let _ = self.append_lambda_funcs(&mut func_sec, &mut code_sec); @@ -812,12 +843,30 @@ impl Codegen { self.emit_ownership_section(); self.emit_carrier_sections(); self.emit_debug_sections(); - if let Some(err) = self.unsupported.take() { - return Err(err); - } Ok(self.module.clone().finish()) } + /// Compile `body` as the body of a parameterless function whose value + /// is dropped, declaring each local at the type it turned out to need. + /// + /// The locals are known only once the body is compiled, but they are + /// encoded before it. So the body is compiled into a function with no + /// locals, and its instructions are copied behind the declarations. + /// (This path declared 16 i32 locals, so an I64, F32 or F64 binding + /// gave an invalid module.) + fn compile_standalone_body(&mut self, body: &Expr) -> Function { + self.locals = LocalTracker::new(0); + let mut scratch = Function::new(Vec::<(u32, ValType)>::new()); + self.compile_expr(&mut scratch, body); + scratch.instruction(&Instruction::Drop); + scratch.instruction(&Instruction::End); + let raw = scratch.into_raw_body(); + let mut func = Function::new(self.locals.wasm_local_declarations()); + // Skip the empty locals vector: one zero byte. + func.raw(raw[1..].iter().copied()); + func + } + /// Compile a module encoded as an S-expression string. pub fn compile_sexpr_module(&mut self, sexpr: &str) -> Result, String> { let module = module_from_sexpr(sexpr).map_err(|e| e.to_string())?; @@ -844,6 +893,7 @@ impl Codegen { self.types = ephapax_typing::module_expr_types(ast, &mut ephapax_typing::ModuleRegistry::new()) .map_err(|e| CodegenError(format!("{NOT_WELL_TYPED}: {e}")))?; + validate_supported_typed(ast, &self.types)?; // ----- Phase 1: collect function metadata -------------------------- // Extern imports MUST be collected first so the import_count @@ -885,9 +935,6 @@ impl Codegen { // was called (`compile --debug`). self.emit_debug_sections(); - if let Some(err) = self.unsupported.take() { - return Err(err); - } Ok(self.module.clone().finish()) } @@ -925,12 +972,7 @@ impl Codegen { // Main function (index self.first_user_fn()) func_sec.function(TYPE_VOID_VOID); - self.locals = LocalTracker::new(0); - let mut main_func = Function::new(vec![(16, ValType::I32)]); - self.compile_expr(&mut main_func, main_body); - main_func.instruction(&Instruction::Drop); - main_func.instruction(&Instruction::End); - code_sec.function(&main_func); + code_sec.function(&self.compile_standalone_body(main_body)); // Lambda functions discovered during expression compilation let _ = self.append_lambda_funcs(&mut func_sec, &mut code_sec); @@ -2949,22 +2991,8 @@ impl Codegen { /// like `Some(Some(_))` + `Some(None)` + `None`). fn compile_match(&mut self, func: &mut Function, scrutinee: &Expr, arms: &[MatchArm]) { // Patterns read every component as an i32 at the i32 layout. A - // scrutinee with an I64, F32 or F64 component would be read at - // the wrong offset and width, so it is refused, not miscompiled. - let wide = self - .ty_of(scrutinee) - .filter(|ty| has_non_i32_component(ty)) - .cloned(); - if let Some(ty) = wide { - self.unsupported.get_or_insert_with(|| { - CodegenError(format!( - "{UNSUPPORTED_PREFIX}: `match` (or a destructuring `let`) on a value \ - with an I64, F32 or F64 component (type {ty:?}); read the \ - component with `fst`, `snd`, `.N` or `case`" - )) - }); - } - + // scrutinee with an I64, F32 or F64 component never gets here: + // `first_unsupported_typed` refuses it before any code is emitted. use wasm_encoder::BlockType; // Recover data type info from the first constructor pattern (if any). @@ -4910,6 +4938,26 @@ mod tests { validate_wasm(&wasm); } + /// The single-expression path declares each local at its own type. + /// (It declared 16 i32 locals, so this module did not validate.) + #[test] + fn compile_program_declares_typed_locals() { + for value in [ + Literal::F64(1.5), + Literal::I64(5_000_000_000), + Literal::F32(1.5), + ] { + let expr = e(ExprKind::Let { + name: "x".into(), + ty: None, + value: Box::new(e(ExprKind::Lit(value))), + body: Box::new(e(ExprKind::Var("x".into()))), + }); + let wasm = Codegen::new().compile_program(&expr).unwrap(); + validate_wasm(&wasm); + } + } + #[test] fn compile_linear_let_binding() { let mut codegen = Codegen::new(); From 2beaac1dfba30b9e635138bef5580a8924217e9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 12:19:09 +0000 Subject: [PATCH 07/13] fix(driver,typing,parser): third round of review findings (module names and the merge) - A function name declared in two modules of one program is now a load error. The merge keeps one function of each name, so the callers of the other one ran the wrong code, without a diagnostic. - `import foo.bar` reaches `module foo/bar` (and the reverse): the module registry uses one key for both spellings. - The module index reads the header with the parser's own rule (`declared_module_name`), so a line in a comment that looks like a header is not one. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 2 +- src/ephapax-driver/src/lib.rs | 105 +++++++++++++++++++++++++++- src/ephapax-driver/src/resolver.rs | 24 ++----- src/ephapax-parser/src/ephapax.pest | 3 + src/ephapax-parser/src/lib.rs | 13 ++++ src/ephapax-typing/src/lib.rs | 28 +++++--- 6 files changed, 143 insertions(+), 32 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index d53943da..eaad8c6c 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -23,7 +23,7 @@ performance, correctness, 13 tests) `+src/ephapax-vram-cache/benches/cache_bench.rs+` * Total: *582 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 582 +* Documented all-target tests: 585 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index 10a73e0a..3bd75faf 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -58,7 +58,8 @@ impl Program { /// interpreter. Wasm produces a single binary; each imported module's /// `Fn` declarations become part of the same wasm module. A function, /// or an extern item, that an earlier module already declared is - /// dropped (first-seen wins, the root first). + /// dropped (first-seen wins, the root first). [`check_program`] + /// refuses a program in which that would drop a function. pub fn into_merged(self) -> Module { // Dedup keys: `fn:` for a function, `extern::` for // an extern item (each extern fn is one wasm import, so the same @@ -240,6 +241,32 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { } })?); } + // A program is one wasm module with one flat function namespace + // (`Program::into_merged`). If two modules declared a function of the + // same name, the merge would keep one, and the callers of the other + // would run the wrong code. + // snip: an error; lift it with module-qualified names in the merge. + let mut declared_in = std::collections::HashMap::new(); + for m in &program.modules { + for decl in &m.core.decls { + let Decl::Fn { name, .. } = decl else { + continue; + }; + let first = declared_in.insert(name, &m.logical_path); + if let Some(first) = first.filter(|first| *first != &m.logical_path) { + return Err(DriverError::Unsupported { + path: m.file_path.clone(), + error: CodegenError(format!( + "{}: function `{name}` is declared in module `{first}` and in \ + module `{}` (a program compiles to one wasm module, which \ + holds one function of each name)", + ephapax_wasm::UNSUPPORTED_PREFIX, + m.logical_path + )), + }); + } + } + } // With the types, so a limit of the typed lowering (a `match` on a // 64-bit component) is an error here and not only in `compile`. for (m, types) in program.modules.iter().zip(&types) { @@ -334,6 +361,82 @@ mod tests { ); } + /// Two modules that each declare `helper` cannot share one wasm + /// module. The merge used to keep one `helper` for both, without a + /// diagnostic. + #[test] + fn same_function_name_in_two_modules_is_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let write = |name: &str, text: &str| { + std::fs::write(dir.path().join(name), text).expect("write module") + }; + write( + "deep.eph", + "module deep\n\nfn helper(n: I32): I32 = n + 100\n\n\ + pub fn deep_val(n: I32): I32 = helper(n)\n", + ); + write( + "app.eph", + "module app\n\nimport deep\n\nfn helper(n: I32): I32 = n + 7\n\n\ + fn main(): I32 = helper(1) + deep_val(0)\n", + ); + let err = load_program(&dir.path().join("app.eph"), None).expect_err("refused"); + assert!( + matches!(&err, DriverError::Unsupported { error, .. } if error.0.contains("`helper`")), + "{err}" + ); + } + + /// `foo.bar` and `foo/bar` name the same module: an import may use + /// either spelling, whichever one the header uses. + #[test] + fn import_and_header_may_spell_the_module_path_differently() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(dir.path().join("foo")).expect("mkdir"); + let write = |name: &str, text: &str| { + std::fs::write(dir.path().join(name), text).expect("write module") + }; + write( + "foo/bar.eph", + "module foo/bar\n\npub fn inc(n: I32): I32 = n + 1\n", + ); + write( + "foo/baz.eph", + "module foo.baz\n\npub fn dec(n: I32): I32 = n - 1\n", + ); + for imports in [ + "import foo.bar\nimport foo/baz", + "import foo.bar(inc)\nimport foo/baz(dec)", + ] { + write( + "app.eph", + &format!("module app\n\n{imports}\n\nfn main(): I32 = inc(dec(41))\n"), + ); + load_program(&dir.path().join("app.eph"), None) + .unwrap_or_else(|e| panic!("`{imports}`: {e}")); + } + } + + /// The module index reads the header the parser reads: a line in a + /// comment that looks like a header is not one. + #[test] + fn module_index_ignores_a_header_inside_a_comment() { + let dir = tempfile::tempdir().expect("tempdir"); + let write = |name: &str, text: &str| { + std::fs::write(dir.path().join(name), text).expect("write module") + }; + // Not at the literal path `Real.eph`: only the index finds it. + write( + "impl.eph", + "/*\nmodule Example\n*/\nmodule Real\n\npub fn inc(n: I32): I32 = n + 1\n", + ); + write( + "app.eph", + "module app\n\nimport Real\n\nfn main(): I32 = inc(41)\n", + ); + load_program(&dir.path().join("app.eph"), None).expect("loads"); + } + /// `module_size(x)` is not a `module` header. #[test] fn module_keyword_needs_a_word_boundary() { diff --git a/src/ephapax-driver/src/resolver.rs b/src/ephapax-driver/src/resolver.rs index 469f1594..16561a95 100644 --- a/src/ephapax-driver/src/resolver.rs +++ b/src/ephapax-driver/src/resolver.rs @@ -226,7 +226,7 @@ impl Resolver<'_> { } } -/// Walk `base_dir` recursively, reading the first `module a/b/c` line of +/// Walk `base_dir` recursively, reading the `module a/b/c` header of /// every `.eph` file we find, and return a map from declared module name /// to file path. Files without a `module` header are skipped. fn scan_module_index(base_dir: &Path) -> HashMap { @@ -263,26 +263,10 @@ fn scan_module_index(base_dir: &Path) -> HashMap { idx } +/// The module the source declares, from the parser's own header rule: a +/// line that looks like a header inside a comment is not one. fn first_module_declaration(source: &str) -> Option { - for line in source.lines() { - let line = line.trim_start(); - // The keyword, then whitespace: not `module_size(x)` or `modules`. - if let Some(rest) = line - .strip_prefix("module") - .filter(|r| r.starts_with(char::is_whitespace)) - { - let rest = rest.trim(); - // Take everything up to a whitespace, comma, or comment marker. - let end = rest - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '/')) - .unwrap_or(rest.len()); - let name = &rest[..end]; - if !name.is_empty() { - return Some(normalise_path(name)); - } - } - } - None + ephapax_parser::declared_module_name(source).map(|name| normalise_path(&name)) } fn normalise_path(raw: &str) -> String { diff --git a/src/ephapax-parser/src/ephapax.pest b/src/ephapax-parser/src/ephapax.pest index 2b88e839..a5a2f4e6 100644 --- a/src/ephapax-parser/src/ephapax.pest +++ b/src/ephapax-parser/src/ephapax.pest @@ -16,6 +16,9 @@ expression_only = { SOI ~ expression ~ EOI } module_decl = { "module" ~ qualified_name } +// Only the header, for `declared_module_name`. +module_header = { SOI ~ module_decl } + // Qualified name: Foo.Bar.Baz OR Foo/Bar/Baz (slash- and dot-segments both // allowed and treated as equivalent at the AST level — see canonicalisation // in `parse_module` / `parse_surface_module`). Slash-paths matter for the diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index f8f94e80..332ca19a 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -98,6 +98,19 @@ pub fn parse_module(source: &str, name: &str) -> Result> }) } +/// The name in the `module` header of `source`, as written, if it has +/// one. Only the header is parsed (comments before it are skipped), so +/// this also works on a file with an error further down. +pub fn declared_module_name(source: &str) -> Option { + let header = EphapaxParser::parse(Rule::module_header, source) + .ok()? + .next()?; + let decl = header + .into_inner() + .find(|p| p.as_rule() == Rule::module_decl)?; + Some(decl.into_inner().next()?.as_str().to_string()) +} + // ============================================================================ // Helper Functions // ============================================================================ diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index a7411adf..63830306 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -2401,6 +2401,12 @@ pub struct ModuleRegistry { modules: HashMap>, } +/// `Foo.Bar` and `Foo/Bar` name the same module (`qualified_name` in +/// ephapax.pest), so an import may spell it either way. +fn module_key(name: &Var) -> Var { + name.replace('.', "/").into() +} + impl ModuleRegistry { pub fn new() -> Self { Self::default() @@ -2494,26 +2500,28 @@ impl ModuleRegistry { Decl::Data { .. } => {} } } - self.modules.insert(module.name.clone(), entries); + self.modules.insert(module_key(&module.name), entries); } /// Look up a public name from a registered module. pub fn lookup(&self, module_name: &Var, decl_name: &Var) -> Option<&Ty> { - self.modules.get(module_name).and_then(|entries| { - entries.iter().find_map(|(name, ty, vis)| { - if name == decl_name && *vis == Visibility::Public { - Some(ty) - } else { - None - } + self.modules + .get(&module_key(module_name)) + .and_then(|entries| { + entries.iter().find_map(|(name, ty, vis)| { + if name == decl_name && *vis == Visibility::Public { + Some(ty) + } else { + None + } + }) }) - }) } /// Get all public names from a module. pub fn public_names(&self, module_name: &Var) -> Vec<(Var, Ty)> { self.modules - .get(module_name) + .get(&module_key(module_name)) .map(|entries| { entries .iter() From a3f559f09e30e61a6119238ac07e5bd54d4e9bf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 12:32:07 +0000 Subject: [PATCH 08/13] fix(driver,wasm,lsp): fourth round of review findings (extern names, wide lambdas, close) - The check for one function name in two modules now includes extern functions: codegen finds a callee by its bare name, and a function takes precedence over an extern. Two modules may still declare the same host import (same ABI, same signature). - A lambda with an I64, F32 or F64 parameter or result is a load error. Every closure has the signature (i32, i32) -> i32, so typed code generation gave an invalid module that `check` accepted. - The LSP makes an analysis that is in flight stale when its document closes, so it cannot publish diagnostics for a closed document. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- src/ephapax-cli/tests/driver_agreement.rs | 50 ++++++++------ src/ephapax-driver/src/lib.rs | 80 ++++++++++++++++------- src/ephapax-lsp/src/main.rs | 7 ++ src/ephapax-wasm/src/lib.rs | 14 ++++ 4 files changed, 109 insertions(+), 42 deletions(-) diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs index c1dc7bda..fdf5d9ce 100644 --- a/src/ephapax-cli/tests/driver_agreement.rs +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -216,28 +216,40 @@ fn out_of_range_integer_literal_is_a_parse_error() { } /// A limit of the typed lowering is a load error too: `check` must not -/// accept a program that `compile` refuses. (A pair or tuple pattern on a -/// value with a 64-bit component is not lowered yet.) +/// accept a program that `compile` refuses. Not lowered yet: a pair or +/// tuple pattern on a value with a 64-bit component, and a lambda with a +/// parameter or result that is not 32 bits wide. #[test] fn check_rejects_what_typed_codegen_cannot_lower() { let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join("wide.eph"), - "module wide\n\nfn main(): I64 =\n let (a, b) = (1, 5000000000)\n b\n", - ) - .expect("write program"); - for args in [&["check", "wide.eph"][..], &["compile", "wide.eph"]] { - let out = Command::new(env!("CARGO_BIN_EXE_ephapax")) - .current_dir(tmp.path()) - .args(args) - .output() - .expect("spawn ephapax"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !out.status.success() && stderr.contains("Codegen error") && stderr.contains("`match`"), - "{args:?}: exit {:?}, stderr:\n{stderr}", - out.status.code() - ); + // (result type, body, text the error names) + for (ty, body, what) in [ + ("I64", "let (a, b) = (1, 5000000000)\n b", "`match`"), + ( + "F32", + "let f = fn(x: F32) -> x + 1.0f32\n f(2.0f32)", + "lambda", + ), + ("F64", "let f = fn(x: I32) -> 1.5\n f(1)", "lambda"), + ] { + std::fs::write( + tmp.path().join("wide.eph"), + format!("module wide\n\nfn main(): {ty} =\n {body}\n"), + ) + .expect("write program"); + for args in [&["check", "wide.eph"][..], &["compile", "wide.eph"]] { + let out = Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(tmp.path()) + .args(args) + .output() + .expect("spawn ephapax"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains("Codegen error") && stderr.contains(what), + "`{body}` under {args:?}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } } } diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index 3bd75faf..289707a7 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -241,30 +241,46 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { } })?); } - // A program is one wasm module with one flat function namespace - // (`Program::into_merged`). If two modules declared a function of the - // same name, the merge would keep one, and the callers of the other - // would run the wrong code. + // A program is one wasm module, and codegen finds a callee by its + // bare name (`Program::into_merged`). If two modules declared a + // function or an extern function of the same name, the callers in one + // of them would run the declaration of the other. // snip: an error; lift it with module-qualified names in the merge. - let mut declared_in = std::collections::HashMap::new(); + // `None` is a function; an extern function has its ABI and item. + let mut declared = std::collections::HashMap::new(); for m in &program.modules { + let mut callables = Vec::new(); for decl in &m.core.decls { - let Decl::Fn { name, .. } = decl else { + match decl { + Decl::Fn { 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)))), + ExternItem::Type { .. } => None, + })) + } + _ => {} + } + } + for (name, ext) in callables { + let Some((first, first_ext)) = declared.insert(name, (&m.logical_path, ext)) else { continue; }; - let first = declared_in.insert(name, &m.logical_path); - if let Some(first) = first.filter(|first| *first != &m.logical_path) { - return Err(DriverError::Unsupported { - path: m.file_path.clone(), - error: CodegenError(format!( - "{}: function `{name}` is declared in module `{first}` and in \ - module `{}` (a program compiles to one wasm module, which \ - holds one function of each name)", - ephapax_wasm::UNSUPPORTED_PREFIX, - m.logical_path - )), - }); + // Two modules may declare the same host import: it is one + // wasm import (`into_merged` keeps one). + if first == &m.logical_path || (ext.is_some() && first_ext == ext) { + continue; } + return Err(DriverError::Unsupported { + path: m.file_path.clone(), + error: CodegenError(format!( + "{}: function `{name}` is declared in module `{first}` and in \ + module `{}` (a program compiles to one wasm module, which \ + holds one function of each name)", + ephapax_wasm::UNSUPPORTED_PREFIX, + m.logical_path + )), + }); } } // With the types, so a limit of the typed lowering (a `match` on a @@ -380,11 +396,29 @@ mod tests { "module app\n\nimport deep\n\nfn helper(n: I32): I32 = n + 7\n\n\ fn main(): I32 = helper(1) + deep_val(0)\n", ); - let err = load_program(&dir.path().join("app.eph"), None).expect_err("refused"); - assert!( - matches!(&err, DriverError::Unsupported { error, .. } if error.0.contains("`helper`")), - "{err}" - ); + let refused = || { + let err = load_program(&dir.path().join("app.eph"), None).expect_err("refused"); + assert!( + matches!(&err, DriverError::Unsupported { error, .. } if error.0.contains("`helper`")), + "{err}" + ); + }; + refused(); + // An extern function is in the same namespace: under another name + // kind, and under another ABI. + let app_calls_deep = "module app\n\nimport deep\n\nfn main(): I32 = deep_val(0)\n"; + for helper in [ + "fn helper(n: I32): I32 = n + 7", + "extern \"other\" {\n fn helper(n: I32): I32\n}", + ] { + write( + "deep.eph", + "module deep\n\nextern \"env\" {\n fn helper(n: I32): I32\n}\n\n\ + pub fn deep_val(n: I32): I32 = helper(n)\n", + ); + write("app.eph", &format!("{app_calls_deep}\n{helper}\n")); + refused(); + } } /// `foo.bar` and `foo/bar` name the same module: an import may use diff --git a/src/ephapax-lsp/src/main.rs b/src/ephapax-lsp/src/main.rs index bcdf82bc..22e8f3be 100644 --- a/src/ephapax-lsp/src/main.rs +++ b/src/ephapax-lsp/src/main.rs @@ -367,6 +367,13 @@ impl LanguageServer for Backend { } async fn did_close(&self, params: DidCloseTextDocumentParams) { + // An analysis still in flight is now stale: it must not cache + // state or publish diagnostics for a closed document. + if let Ok(mut generations) = self.generations.lock() { + *generations + .entry(params.text_document.uri.clone()) + .or_insert(0) += 1; + } // Clear diagnostics and cached state if let Ok(mut docs) = self.documents.lock() { docs.remove(¶ms.text_document.uri); diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index a132bde2..3c52fe6f 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -233,6 +233,20 @@ fn first_unsupported_in(expr: &Expr, types: Option<&ExprTypes>) -> Option i32 + // until the closure ABI is typed (v2 plan rung E9). + ExprKind::Lambda { param_ty, body, .. } + if ty_to_valtype(param_ty) != ValType::I32 + || types + .and_then(|t| t.get(&body.id)) + .is_some_and(|ty| ty_to_valtype(ty) != ValType::I32) => + { + Some(unsupported( + "a lambda with an I64, F32 or F64 parameter or result (a closure \ + passes 32-bit values only); use a named function", + expr.span, + )) + } ExprKind::Match { scrutinee, .. } => types .and_then(|t| t.get(&scrutinee.id)) .filter(|ty| has_non_i32_component(ty)) From ce81f1b23aa8ec330c474a2848caeb57b7a352a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 12:38:13 +0000 Subject: [PATCH 09/13] test(driver): make the module word-boundary test reach its case The input started with `fn module_size(...)`, so the trimmed line began with `fn` and the old line scanner also found no header: the test could not fail. It now has a line that starts with `module_size(x)`, which the old scanner read as a header named `_size` (CodeRabbit, #426). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZnmBahbhfkFEP6ark4 --- src/ephapax-driver/src/lib.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index 289707a7..fda6511d 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -471,11 +471,17 @@ mod tests { load_program(&dir.path().join("app.eph"), None).expect("loads"); } - /// `module_size(x)` is not a `module` header. + /// A line that starts with `module_size(x)` is not a `module` header. + /// (The old line scanner read it as a header named `_size`.) #[test] fn module_keyword_needs_a_word_boundary() { - let program = parse_source("fn module_size(x: I32): I32 = x\n", "app.eph", None, None) - .expect("parses"); + let program = parse_source( + "fn module_size(x: I32): I32 = x\nfn f(x: I32): I32 =\n module_size(x)\n", + "app.eph", + None, + None, + ) + .expect("parses"); assert_eq!(program.root().logical_path, "app"); } } From d9a02211907c3e78d0ebbf31967402a7315508bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 12:45:50 +0000 Subject: [PATCH 10/13] feat(driver): module-qualified function names in the merge When two modules of one program declare a function of the same name, the merge now keeps both. The root keeps its names; after it, the first module that declares a name keeps it, and the other function becomes `::`. Each reference follows the function that the type checker resolved it to: the imports in order (a later one shadows an earlier one), then the declarations of the module. The rename covers binders too, so a local or a parameter with that name still shadows the function. Before 2beaac1 the merge kept one function for both modules and the callers of the other one ran the wrong code. Since 2beaac1 such a program was a load error. Now it loads and each module calls its own function. An extern function is not renamed: its name is the name of the host import. A name collision that involves an extern function stays a load error (owner decision). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 2 +- src/ephapax-cli/tests/run_wasm.rs | 55 +++++++ src/ephapax-driver/src/lib.rs | 102 +++++++------ src/ephapax-driver/src/qualify.rs | 231 ++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 47 deletions(-) create mode 100644 src/ephapax-driver/src/qualify.rs diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index af941d60..c53eb27d 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -23,7 +23,7 @@ performance, correctness, 13 tests) `+src/ephapax-vram-cache/benches/cache_bench.rs+` * Total: *590 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 590 +* Documented all-target tests: 591 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index fd719c4e..22fad56e 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -155,3 +155,58 @@ fn run_does_not_execute_the_main_of_an_imported_module() { assert_eq!(stdout_of(tmp.path(), args), "", "{args:?}"); } } + +/// Each module calls its own function when two modules use one name. The +/// merge used to keep one `helper` for both modules. +#[test] +fn each_module_calls_its_own_function_of_a_shared_name() { + let tmp = tempfile::tempdir().expect("tempdir"); + let write = + |name: &str, text: &str| std::fs::write(tmp.path().join(name), text).expect("write module"); + // `helper` is private in each module. + write( + "deep.eph", + "module deep\n\nfn helper(n: I32): I32 = n + 100\n\n\ + pub fn deep_val(n: I32): I32 = helper(n)\n\n\ + pub fn shadow(helper: I32): I32 = helper + 1000\n\n\ + pub fn pick(n: I32): I32 = n + 1\n", + ); + write( + "mid.eph", + "module mid\n\nimport deep\n\nfn helper(n: I32): I32 = n + 1\n\n\ + pub fn mid_val(n: I32): I32 = helper(n) + deep_val(0)\n\n\ + pub fn pick(n: I32): I32 = n + 2\n", + ); + // 2 + 100. + write( + "app.eph", + "module app\n\nimport mid\n\nfn main(): I32 = mid_val(1)\n", + ); + // The root has its own `helper` too: 8 + 100. + write( + "own.eph", + "module own\n\nimport deep\n\nfn helper(n: I32): I32 = n + 7\n\n\ + fn main(): I32 = helper(1) + deep_val(0)\n", + ); + // Two imports export `pick`: the later import is the one in scope. + write( + "later.eph", + "module later\n\nimport deep\nimport mid\n\nfn main(): I32 = pick(0)\n", + ); + for (file, want) in [("app.eph", "102"), ("own.eph", "108"), ("later.eph", "2")] { + for args in [&["run", file][..], &["run", "--interp", file]] { + assert_eq!(stdout_of(tmp.path(), args), want, "{args:?}"); + } + } + + // `shadow` in `deep` has a parameter with the name of the function + // that the merge renames; the parameter still shadows the function. + // Wasm only: the interpreter reads a parameter that has the name of a + // function as that function, also in a program of one module. + write( + "param.eph", + "module param\n\nimport deep\n\nfn helper(n: I32): I32 = n\n\n\ + fn main(): I32 = shadow(0)\n", + ); + assert_eq!(stdout_of(tmp.path(), &["run", "param.eph"]), "1000"); +} diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index fda6511d..ce9c768e 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -17,6 +17,7 @@ //! [`check_program`] the last two; [`load_program`] / [`load_source`] run //! all five. +mod qualify; mod resolver; use std::path::{Path, PathBuf}; @@ -56,10 +57,11 @@ impl Program { /// 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. A function, - /// or an extern item, that an earlier module already declared is - /// dropped (first-seen wins, the root first). [`check_program`] - /// refuses a program in which that would drop a function. + /// `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 { // Dedup keys: `fn:` for a function, `extern::` for // an extern item (each extern fn is one wasm import, so the same @@ -73,6 +75,7 @@ impl Program { } } let mut modules = self.modules; + qualify::qualify_functions(&mut modules); let mut merged = modules .pop() .expect("a program has at least its root module") @@ -242,10 +245,11 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { })?); } // A program is one wasm module, and codegen finds a callee by its - // bare name (`Program::into_merged`). If two modules declared a - // function or an extern function of the same name, the callers in one - // of them would run the declaration of the other. - // snip: an error; lift it with module-qualified names in the merge. + // bare name. Two functions of the same name get module-qualified names + // in the merge (`qualify`). An extern function cannot: its name is the + // name of the host import. So a name that two modules declare, where + // at least one declares it as an extern function, is an error, unless + // both declare the same host import (same ABI, same signature). // `None` is a function; an extern function has its ABI and item. let mut declared = std::collections::HashMap::new(); for m in &program.modules { @@ -266,17 +270,15 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { let Some((first, first_ext)) = declared.insert(name, (&m.logical_path, ext)) else { continue; }; - // Two modules may declare the same host import: it is one - // wasm import (`into_merged` keeps one). - if first == &m.logical_path || (ext.is_some() && first_ext == ext) { + if first == &m.logical_path || first_ext == ext { continue; } return Err(DriverError::Unsupported { path: m.file_path.clone(), error: CodegenError(format!( - "{}: function `{name}` is declared in module `{first}` and in \ - module `{}` (a program compiles to one wasm module, which \ - holds one function of each name)", + "{}: `{name}` is declared in module `{first}` and in module `{}`, \ + and at least one of the two is an extern function (an extern \ + function keeps its name, which is the name of the host import)", ephapax_wasm::UNSUPPORTED_PREFIX, m.logical_path )), @@ -377,47 +379,55 @@ mod tests { ); } - /// Two modules that each declare `helper` cannot share one wasm - /// module. The merge used to keep one `helper` for both, without a - /// diagnostic. + /// Two modules that each declare a function `helper`: the root keeps + /// the name, the other one becomes `deep::helper`. An extern function + /// cannot be renamed, so a collision with one is refused. #[test] - fn same_function_name_in_two_modules_is_refused() { + fn same_function_name_in_two_modules() { let dir = tempfile::tempdir().expect("tempdir"); let write = |name: &str, text: &str| { std::fs::write(dir.path().join(name), text).expect("write module") }; - write( - "deep.eph", - "module deep\n\nfn helper(n: I32): I32 = n + 100\n\n\ - pub fn deep_val(n: I32): I32 = helper(n)\n", - ); - write( - "app.eph", - "module app\n\nimport deep\n\nfn helper(n: I32): I32 = n + 7\n\n\ - fn main(): I32 = helper(1) + deep_val(0)\n", - ); - let refused = || { - let err = load_program(&dir.path().join("app.eph"), None).expect_err("refused"); + let load = || load_program(&dir.path().join("app.eph"), None); + let deep_fn = "fn helper(n: I32): I32 = n + 100"; + let deep_extern = "extern \"env\" {\n fn helper(n: I32): I32\n}"; + let deep = |helper: &str| { + format!("module deep\n\n{helper}\n\npub fn deep_val(n: I32): I32 = helper(n)\n") + }; + let app = |helper: &str| { + format!("module app\n\nimport deep\n\n{helper}\n\nfn main(): I32 = deep_val(0)\n") + }; + + 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 names: Vec<&str> = merged + .decls + .iter() + .filter_map(|d| match d { + Decl::Fn { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); + assert_eq!(names, ["helper", "main", "deep::helper", "deep_val"]); + ephapax_wasm::compile_module(&merged).expect("the merged module compiles"); + + // (deep, app): at least one `helper` is an extern function. + for (deep_helper, app_helper) in [ + (deep_extern, "fn helper(n: I32): I32 = n + 7"), + (deep_fn, deep_extern), + ( + deep_extern, + "extern \"other\" {\n fn helper(n: I32): I32\n}", + ), + ] { + write("deep.eph", &deep(deep_helper)); + write("app.eph", &app(app_helper)); + let err = load().expect_err("refused"); assert!( matches!(&err, DriverError::Unsupported { error, .. } if error.0.contains("`helper`")), "{err}" ); - }; - refused(); - // An extern function is in the same namespace: under another name - // kind, and under another ABI. - let app_calls_deep = "module app\n\nimport deep\n\nfn main(): I32 = deep_val(0)\n"; - for helper in [ - "fn helper(n: I32): I32 = n + 7", - "extern \"other\" {\n fn helper(n: I32): I32\n}", - ] { - write( - "deep.eph", - "module deep\n\nextern \"env\" {\n fn helper(n: I32): I32\n}\n\n\ - pub fn deep_val(n: I32): I32 = helper(n)\n", - ); - write("app.eph", &format!("{app_calls_deep}\n{helper}\n")); - refused(); } } diff --git a/src/ephapax-driver/src/qualify.rs b/src/ephapax-driver/src/qualify.rs new file mode 100644 index 00000000..d87d37a8 --- /dev/null +++ b/src/ephapax-driver/src/qualify.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +//! Module-qualified function names for the merge. +//! +//! A program becomes one wasm module with one flat function namespace +//! (`Program::into_merged`), but each module was type-checked in its own +//! scope. When two modules declare a function of the same name, one of +//! them gets the name `::` here, and every reference +//! follows the function that the type checker resolved it to. +//! +//! An extern function is never renamed: its name is the name of the host +//! import. `check_program` refuses a name collision that involves one. + +use std::collections::HashMap; + +use ephapax_syntax::{Decl, Expr, ExprKind, ExternItem, Module, Pattern, Var, Visibility}; + +use crate::LoadedModule; + +/// For one module: a name as written → its name in the merged program. +/// Only the names that change are in the map. +type Renames = HashMap; + +/// The callable names a module declares, as `(name, public, function)`. +/// `function` is false for an extern function. +fn callables(module: &Module) -> Vec<(&Var, bool, bool)> { + let mut out = Vec::new(); + for decl in &module.decls { + match decl { + Decl::Fn { + name, visibility, .. + } => out.push((name, *visibility == Visibility::Public, true)), + Decl::Extern { items, .. } => out.extend(items.iter().filter_map(|item| match item { + ExternItem::Fn { name, .. } => Some((name, true, false)), + ExternItem::Type { .. } => None, + })), + _ => {} + } + } + out +} + +/// The key the type checker's module registry uses for a module name. +fn module_key(name: &str) -> String { + name.replace('.', "/") +} + +/// Give each function a name that no other module of the program uses. +/// `modules` has the dependencies first and the root last. The root keeps +/// its names (they are the entry points of the program); after it, the +/// first module that declares a name keeps it. +pub(crate) fn qualify_functions(modules: &mut [LoadedModule]) { + let root = modules.len().saturating_sub(1); + + // The new name of each function that must change, by module. + let mut owner = HashMap::new(); + let mut qualified = vec![Renames::new(); modules.len()]; + for i in std::iter::once(root).chain(0..root) { + let Some(m) = modules.get(i) else { return }; + for (name, _, function) in callables(&m.core) { + if function && *owner.entry(name).or_insert(i) != i { + let new = format!("{}::{name}", m.logical_path); + qualified[i].insert(name.clone(), new.into()); + } + } + } + if qualified.iter().all(Renames::is_empty) { + return; + } + + // What each name means in each module, in the order the type checker + // brings names into scope: the imports in order (a later one shadows + // an earlier one), then the declarations of the module. + let index: HashMap = modules + .iter() + .enumerate() + .map(|(i, m)| (module_key(&m.core.name), i)) + .collect(); + let scopes: Vec = modules + .iter() + .enumerate() + .map(|(i, m)| { + let mut scope = Renames::new(); + let mut bind = |from: usize, name: &Var, function: bool| { + match qualified[from].get(name).filter(|_| function) { + Some(new) => scope.insert(name.clone(), new.clone()), + None => scope.remove(name), + }; + }; + for import in &m.core.imports { + let Some(&dep) = index.get(&module_key(&import.module)) else { + continue; + }; + for (name, public, function) in callables(&modules[dep].core) { + if public && (import.names.is_empty() || import.names.contains(name)) { + bind(dep, name, function); + } + } + } + for (name, _, function) in callables(&m.core) { + bind(i, name, function); + } + scope + }) + .collect(); + + for (m, scope) in modules.iter_mut().zip(&scopes) { + if scope.is_empty() { + continue; + } + for decl in &mut m.core.decls { + match decl { + Decl::Fn { + name, params, body, .. + } => { + rename(name, scope); + params.iter_mut().for_each(|(p, _)| rename(p, scope)); + rename_expr(body, scope); + } + Decl::Const { value, .. } => rename_expr(value, scope), + Decl::Type { .. } | Decl::Extern { .. } | Decl::Data { .. } => {} + } + } + } +} + +fn rename(name: &mut Var, scope: &Renames) { + if let Some(new) = scope.get(name) { + *name = new.clone(); + } +} + +/// Rename every occurrence of a name in `scope`, binders included. A +/// 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] + } + 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]; + 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]; + for clause in clauses { + clause.params.iter_mut().for_each(|p| rename(p, scope)); + children.push(&mut clause.body); + } + children + } + }; + for child in children { + rename_expr(child, scope); + } +} + +fn rename_pattern(pattern: &mut Pattern, scope: &Renames) { + match pattern { + Pattern::Wildcard | Pattern::Literal(_) | Pattern::Unit => {} + Pattern::Var(name) => rename(name, scope), + Pattern::Pair(left, right) => { + rename_pattern(left, scope); + rename_pattern(right, scope); + } + Pattern::Tuple(patterns) | Pattern::Constructor { args: patterns, .. } => { + patterns.iter_mut().for_each(|p| rename_pattern(p, scope)); + } + } +} From 71a4e2b00eccc6f4dc65e21de77d80b67cf7ee5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 12:52:16 +0000 Subject: [PATCH 11/13] feat(wasm): typed pair patterns (E5): each component at its own type A pair pattern read both components as an i32 at offsets 0 and 4, so `check`, `compile` and `run` refused every `match` on a value with an I64, F32 or F64 component. The pattern now loads each component at its own type and offset, as `compile_pair` stored it, and a variable pattern binds a local of that type. This covers the two forms the surface language lowers to a core `match`: a destructuring `let (a, b) = ...`, and a constructor with several fields (a right-nested pair). The refusal is now narrow: a tuple, constructor or literal pattern on a value with a wide component. Those patterns still read i32 values, and only core-syntax files reach them. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 2 +- src/ephapax-cli/tests/driver_agreement.rs | 6 +- src/ephapax-cli/tests/run_wasm.rs | 38 +++++ src/ephapax-wasm/src/lib.rs | 179 +++++++++++++++++----- 4 files changed, 179 insertions(+), 46 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index c53eb27d..e55aed24 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -23,7 +23,7 @@ performance, correctness, 13 tests) `+src/ephapax-vram-cache/benches/cache_bench.rs+` * Total: *590 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 591 +* Documented all-target tests: 593 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs index fdf5d9ce..dd734e7f 100644 --- a/src/ephapax-cli/tests/driver_agreement.rs +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -216,15 +216,13 @@ fn out_of_range_integer_literal_is_a_parse_error() { } /// A limit of the typed lowering is a load error too: `check` must not -/// accept a program that `compile` refuses. Not lowered yet: a pair or -/// tuple pattern on a value with a 64-bit component, and a lambda with a -/// parameter or result that is not 32 bits wide. +/// accept a program that `compile` refuses. Not lowered yet: a lambda +/// with a parameter or result that is not 32 bits wide. #[test] fn check_rejects_what_typed_codegen_cannot_lower() { let tmp = tempfile::tempdir().expect("tempdir"); // (result type, body, text the error names) for (ty, body, what) in [ - ("I64", "let (a, b) = (1, 5000000000)\n b", "`match`"), ( "F32", "let f = fn(x: F32) -> x + 1.0f32\n f(2.0f32)", diff --git a/src/ephapax-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs index 22fad56e..371e1d92 100644 --- a/src/ephapax-cli/tests/run_wasm.rs +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -210,3 +210,41 @@ fn each_module_calls_its_own_function_of_a_shared_name() { ); assert_eq!(stdout_of(tmp.path(), &["run", "param.eph"]), "1000"); } + +/// 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. +#[test] +fn pair_patterns_bind_each_component_at_its_type() { + let tmp = tempfile::tempdir().expect("tempdir"); + let record = "data S = S(F32, I64, I32, F64)\n\n"; + let matched = |ty: &str, field: &str| { + format!( + "{record}fn main(): {ty} =\n match S(1.5f32, 9000000000, 3, 0.25) of\n \ + | S(a, b, c, d) => {field}\n end\n" + ) + }; + for (program, want) in [ + ( + "fn main(): I64 =\n let (a, b) = (1, 5000000000)\n b\n".to_string(), + "5000000000i64", + ), + ( + "fn main(): F64 =\n let (a, b) = (2.5, 7)\n a\n".to_string(), + "2.5", + ), + (matched("F32", "a"), "1.5f32"), + (matched("I64", "b"), "9000000000i64"), + (matched("I32", "c"), "3"), + (matched("F64", "d"), "0.25"), + ] { + std::fs::write( + tmp.path().join("pat.eph"), + format!("module pat\n\n{program}"), + ) + .expect("write program"); + for args in [&["run", "pat.eph"][..], &["run", "--interp", "pat.eph"]] { + assert_eq!(stdout_of(tmp.path(), args), want, "{args:?}:\n{program}"); + } + } +} diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 161c3433..91f4e209 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -247,15 +247,18 @@ fn first_unsupported_in(expr: &Expr, types: Option<&ExprTypes>) -> Option types + ExprKind::Match { scrutinee, arms } => types .and_then(|t| t.get(&scrutinee.id)) - .filter(|ty| has_non_i32_component(ty)) + .filter(|ty| { + arms.iter() + .any(|arm| pattern_reads_wide_as_i32(&arm.pattern, ty)) + }) .map(|ty| { unsupported( &format!( - "`match` (or a destructuring `let`) on a value with an I64, F32 \ - or F64 component (type {ty:?}); read the component with `fst`, \ - `snd`, `.N` or `case`" + "`match` with a tuple, constructor or literal pattern on a value \ + with an I64, F32 or F64 component (type {ty:?}); use a pair \ + pattern, `fst`, `snd`, `.N` or `case`" ), expr.span, ) @@ -440,11 +443,6 @@ impl LocalTracker { } } - /// Bind a named variable to the next available local slot. - fn bind(&mut self, name: &str, is_linear: bool) -> u32 { - self.bind_typed(name, is_linear, ValType::I32) - } - /// Bind a named variable with its concrete WebAssembly local type. fn bind_typed(&mut self, name: &str, is_linear: bool, ty: ValType) -> u32 { let idx = self.next_idx; @@ -3014,10 +3012,13 @@ impl Codegen { /// through on inner refutation (needed once #66 admits patterns /// like `Some(Some(_))` + `Some(None)` + `None`). fn compile_match(&mut self, func: &mut Function, scrutinee: &Expr, arms: &[MatchArm]) { - // Patterns read every component as an i32 at the i32 layout. A - // scrutinee with an I64, F32 or F64 component never gets here: - // `first_unsupported_typed` refuses it before any code is emitted. + // A variable, a wildcard and a pair pattern are lowered at the type + // of the value. A tuple, constructor or literal pattern reads i32 + // values only; on a value with an I64, F32 or F64 component it + // never gets here: `first_unsupported_typed` refuses it. use wasm_encoder::BlockType; + let scrut_ty = self.ty_of(scrutinee).cloned(); + let scrut_valtype = self.valtype_of(scrutinee); // Recover data type info from the first constructor pattern (if any). // For non-data scrutinees (Bool, Pair, Tuple, Unit, Int) no tag @@ -3031,7 +3032,7 @@ impl Codegen { }); self.compile_expr(func, scrutinee); - let scrut_local = self.locals.temp(); + let scrut_local = self.locals.temp_typed(scrut_valtype); func.instruction(&Instruction::LocalSet(scrut_local)); let payload_local = self.locals.temp(); @@ -3044,11 +3045,12 @@ impl Codegen { func.instruction(&Instruction::LocalGet(scrut_local)); func.instruction(&Instruction::LocalSet(cur_local)); self.emit_tag_walk(func, total, cur_local, tag_local, payload_local); - } else { + } else if scrut_valtype == ValType::I32 { // Single-ctor data type OR non-data scrutinee: payload = scrut. // tag_local stays uninitialized; arms with constructor // patterns over a single-ctor data type don't need to read - // it (position is forced to 0). + // it (position is forced to 0). (A scrutinee that is not an + // i32 has no constructor pattern, so no payload.) func.instruction(&Instruction::LocalGet(scrut_local)); func.instruction(&Instruction::LocalSet(payload_local)); } @@ -3080,7 +3082,13 @@ impl Codegen { // Top-level pattern binding / refutable check. The arm body // produces an i32 on the stack; we br $done (depth 1) to // carry it past the remaining arms. - self.compile_top_pattern(func, &arm.pattern, scrut_local, payload_local); + self.compile_top_pattern( + func, + &arm.pattern, + scrut_local, + payload_local, + scrut_ty.as_ref(), + ); // Match guard (if any): evaluate under the arm's pattern // bindings; `br_if $arm_i_fail` (depth 0) when the guard @@ -3118,40 +3126,36 @@ impl Codegen { pat: &Pattern, scrut_local: u32, payload_local: u32, + scrut_ty: Option<&Ty>, ) { match pat { - Pattern::Wildcard => {} - Pattern::Var(name) => { - let idx = self.locals.bind(name.as_str(), false); - func.instruction(&Instruction::LocalGet(scrut_local)); - func.instruction(&Instruction::LocalSet(idx)); - } Pattern::Constructor { args, .. } => { self.destructure_payload(func, args, payload_local, 0); } - Pattern::Literal(_) | Pattern::Unit | Pattern::Pair(_, _) | Pattern::Tuple(_) => { - // Non-data top-level pattern: scrutinee IS the value - // to check / destructure. Sub-pattern handles - // refutability and binding uniformly. - self.compile_sub_pattern(func, pat, scrut_local, 0); - } + // Non-data top-level pattern: scrutinee IS the value to + // bind, check or destructure. Sub-pattern handles + // refutability and binding uniformly. + _ => self.compile_sub_pattern(func, pat, scrut_local, scrut_ty, 0), } } /// Recursively bind a sub-pattern against an already-extracted - /// value (held in `value_local`). Refutable patterns emit - /// `br_if fail_depth` on mismatch. + /// value (held in `value_local`, of type `ty`; `None` is a value the + /// type table has no entry for, which is an i32). Refutable patterns + /// emit `br_if fail_depth` on mismatch. fn compile_sub_pattern( &mut self, func: &mut Function, pat: &Pattern, value_local: u32, + ty: Option<&Ty>, fail_depth: u32, ) { match pat { Pattern::Wildcard => {} Pattern::Var(name) => { - let idx = self.locals.bind(name.as_str(), false); + let valtype = ty.map_or(ValType::I32, ty_to_valtype); + let idx = self.locals.bind_typed(name.as_str(), false, valtype); func.instruction(&Instruction::LocalGet(value_local)); func.instruction(&Instruction::LocalSet(idx)); } @@ -3182,18 +3186,31 @@ impl Codegen { } }, Pattern::Pair(left, right) => { - let left_local = self.locals.temp(); + // Each component at its own type and offset, as + // `compile_pair` stored it. + let (left_ty, right_ty) = match ty.map(peel_borrows) { + Some(Ty::Prod { left, right }) => (Some(&**left), Some(&**right)), + _ => (None, None), + }; + let left_valtype = left_ty.map_or(ValType::I32, ty_to_valtype); + let right_valtype = right_ty.map_or(ValType::I32, ty_to_valtype); + + let left_local = self.locals.temp_typed(left_valtype); func.instruction(&Instruction::LocalGet(value_local)); - func.instruction(&Instruction::I32Load(mem_arg(0))); + emit_typed_load(func, left_valtype, 0); func.instruction(&Instruction::LocalSet(left_local)); - let right_local = self.locals.temp(); + let right_local = self.locals.temp_typed(right_valtype); func.instruction(&Instruction::LocalGet(value_local)); - func.instruction(&Instruction::I32Load(mem_arg(4))); + emit_typed_load( + func, + right_valtype, + pair_layout(left_valtype, right_valtype).0, + ); func.instruction(&Instruction::LocalSet(right_local)); - self.compile_sub_pattern(func, left, left_local, fail_depth); - self.compile_sub_pattern(func, right, right_local, fail_depth); + self.compile_sub_pattern(func, left, left_local, left_ty, fail_depth); + self.compile_sub_pattern(func, right, right_local, right_ty, fail_depth); } Pattern::Tuple(parts) => { self.destructure_payload(func, parts, value_local, fail_depth); @@ -3297,18 +3314,18 @@ impl Codegen { ) { match args.len() { 0 => {} - 1 => self.compile_sub_pattern(func, &args[0], payload_local, fail_depth), + 1 => self.compile_sub_pattern(func, &args[0], payload_local, None, fail_depth), _ => { let mut cur = payload_local; for (i, arg) in args.iter().enumerate() { if i + 1 == args.len() { - self.compile_sub_pattern(func, arg, cur, fail_depth); + self.compile_sub_pattern(func, arg, cur, None, fail_depth); } else { let left = self.locals.temp(); func.instruction(&Instruction::LocalGet(cur)); func.instruction(&Instruction::I32Load(mem_arg(0))); func.instruction(&Instruction::LocalSet(left)); - self.compile_sub_pattern(func, arg, left, fail_depth); + self.compile_sub_pattern(func, arg, left, None, fail_depth); let next = self.locals.temp(); func.instruction(&Instruction::LocalGet(cur)); @@ -3922,6 +3939,28 @@ fn sum_payload_offset(payload: ValType) -> u64 { align_up(4, valtype_storage(payload).1) } +/// `ty` without its borrows and references (identity at run time). +fn peel_borrows(ty: &Ty) -> &Ty { + match ty { + Ty::Borrow { inner, .. } | Ty::Ref { inner, .. } => peel_borrows(inner), + _ => ty, + } +} + +/// True when the lowering of `pattern` against a value of type `ty` +/// would read an I64, F32 or F64 component as an i32. A variable, a +/// wildcard and a pair pattern are lowered at the type of the value; a +/// tuple, constructor or literal pattern reads i32 values only. +fn pattern_reads_wide_as_i32(pattern: &Pattern, ty: &Ty) -> bool { + match (pattern, peel_borrows(ty)) { + (Pattern::Wildcard | Pattern::Var(_) | Pattern::Unit, _) => false, + (Pattern::Pair(l, r), Ty::Prod { left, right }) => { + pattern_reads_wide_as_i32(l, left) || pattern_reads_wide_as_i32(r, right) + } + _ => has_non_i32_component(ty), + } +} + /// True when a value of type `ty` has a component that is not an i32 at /// run time (an I64, F32 or F64 inside a pair, sum, tuple or list, or /// the value itself). @@ -5726,6 +5765,64 @@ mod tests { validate_wasm(&wasm); } + /// A pair pattern is lowered at the type of each component. A literal + /// pattern still reads an i32, so on an I64 value it is refused; it + /// is not lowered to an arm that never matches. + #[test] + fn match_patterns_on_wide_values() { + let module = |ret_ty, body| AstModule { + name: "t".into(), + imports: vec![], + decls: vec![Decl::Fn { + name: "f".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty, + body, + }], + }; + let wide = || e(ExprKind::Lit(Literal::I64(5_000_000_000))); + let arm = |pattern, body| MatchArm { + pattern, + guard: None, + body, + }; + + // match (1, 5000000000) of | (a, b) => b end + let pair = e(ExprKind::Pair { + left: Box::new(e(ExprKind::Lit(Literal::I32(1)))), + right: Box::new(wide()), + }); + let typed = e(ExprKind::Match { + scrutinee: Box::new(pair), + arms: vec![arm( + P::Pair(Box::new(P::Var("a".into())), Box::new(P::Var("b".into()))), + e(ExprKind::Var("b".into())), + )], + }); + let wasm = compile_module(&module(Ty::Base(BaseTy::I64), typed)).expect("typed pair"); + validate_wasm(&wasm); + + // match 5000000000 of | 5000000000 => 1 | _ => 0 end + let literal = e(ExprKind::Match { + scrutinee: Box::new(wide()), + arms: vec![ + arm( + P::Literal(Literal::I64(5_000_000_000)), + e(ExprKind::Lit(Literal::I32(1))), + ), + arm(P::Wildcard, e(ExprKind::Lit(Literal::I32(0)))), + ], + }); + let err = compile_module(&module(Ty::Base(BaseTy::I32), literal)).expect_err("refused"); + assert!( + err.0.starts_with(UNSUPPORTED_PREFIX) && err.0.contains("`match`"), + "{}", + err.0 + ); + } + /// Three constructors → br_table over 3 labels, last constructor /// uses the bare-payload path. #[test] From 0841afe35e4845f01c3538e027480694a77ccf8d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 12:55:13 +0000 Subject: [PATCH 12/13] fix(query): the same load failure is not a change A program that fails to load is loaded again on each demand. Two failures never compared as equal, so each demand advanced the revision and the queries after the parse ran again. The results were correct; the recompute skip was lost for a failing program. Two errors with the same `Debug` form are now equivalent (CodeRabbit, #426). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 2 +- src/ephapax-query/src/lib.rs | 29 +++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index e55aed24..02b1eee1 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -23,7 +23,7 @@ performance, correctness, 13 tests) `+src/ephapax-vram-cache/benches/cache_bench.rs+` * Total: *590 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 593 +* Documented all-target tests: 594 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-query/src/lib.rs b/src/ephapax-query/src/lib.rs index ebffce2b..10491d11 100644 --- a/src/ephapax-query/src/lib.rs +++ b/src/ephapax-query/src/lib.rs @@ -91,11 +91,18 @@ pub struct QueryDb { } /// Two parse results are equivalent for backdating iff both are `Ok` with -/// equal core modules. `DriverError` is not `PartialEq`, and a differing -/// error is a real change anyway, so any `Err` is treated as changed. +/// equal core modules, or both are the same error. `DriverError` is not +/// `PartialEq`; its `Debug` form holds every field. fn parsed_equiv(a: &ParsedResult, b: &ParsedResult) -> bool { - matches!((a, b), (Ok(x), Ok(y)) - if x.modules.iter().map(|m| &m.core).eq(y.modules.iter().map(|m| &m.core))) + match (a, b) { + (Ok(x), Ok(y)) => x + .modules + .iter() + .map(|m| &m.core) + .eq(y.modules.iter().map(|m| &m.core)), + (Err(x), Err(y)) => format!("{x:?}") == format!("{y:?}"), + _ => false, + } } impl QueryDb { @@ -312,6 +319,20 @@ mod tests { ); } + /// A program that fails to load is loaded again on each demand (its + /// imports are not inputs). The same failure is not a change: the + /// queries after the parse do not run again. + #[test] + fn same_failure_does_not_recompute_downstream() { + let mut db = QueryDb::new(); + db.set_source_text("m", "fn add(a: I32): I32 = a +\n"); + assert!(db.parsed("m").is_err()); + let _ = db.typed("m"); + let n = db.recompute_count(); + let _ = db.typed("m"); + assert_eq!(db.recompute_count(), n + 1, "only the parse runs again"); + } + #[test] fn real_change_recomputes() { let mut db = QueryDb::new(); From 8ba1b5ebc4296ec8fac8fafe2766a6d30fc3e702 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 13:33:49 +0000 Subject: [PATCH 13/13] fix(wasm,query): check the fallback body; compile the program that was checked - `Codegen::compile_module_with_main`: the fallback compiled the main body with no type table, so a 64-bit `if` got an i32 block type and the module was invalid. The fallback is now `compile_program`, which checks the body first. The duplicate emission code is gone. - Query engine: `wasm` loaded a program with imports two times, once to compile and once (inside `typed`) to check. An imported file could change between the two loads, and the stale module was then kept for the new revision. `wasm` now compiles the program that `typed` checked. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 2 +- src/ephapax-cli/tests/driver_agreement.rs | 24 ++++++++ src/ephapax-query/src/lib.rs | 9 ++- src/ephapax-wasm/src/lib.rs | 71 ++++++++++++----------- 4 files changed, 71 insertions(+), 35 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 02b1eee1..168c5c35 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -23,7 +23,7 @@ performance, correctness, 13 tests) `+src/ephapax-vram-cache/benches/cache_bench.rs+` * Total: *590 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 594 +* Documented all-target tests: 596 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs index dd734e7f..40ce21f7 100644 --- a/src/ephapax-cli/tests/driver_agreement.rs +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -272,3 +272,27 @@ fn query_engine_sees_an_edit_to_an_imported_file() { std::fs::write(&lib, "module lib\n\npub fn inc(n: Bool): Bool = n\n").expect("rewrite lib"); assert!(db.typed(&id).is_err(), "the stale memo was served"); } + +/// `wasm` loads a program with imports one time for each demand, so the +/// program it compiles is the program it checked. It used to load it two +/// times, and an imported file could change between the two loads. +#[test] +fn query_engine_compiles_the_program_that_it_checked() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("lib.eph"), + "module lib\n\npub fn inc(n: I32): I32 = n + 1\n", + ) + .expect("write lib"); + let app = tmp.path().join("app.eph"); + let app_text = "module app\n\nimport lib\n\nfn main(): I32 = inc(41)\n"; + std::fs::write(&app, app_text).expect("write app"); + + let id = app.to_string_lossy(); + let mut db = ephapax_query::QueryDb::new(); + db.set_source_text(&id, app_text); + assert!(db.wasm(&id).is_ok(), "the program compiles"); + let n = db.recompute_count(); + assert!(db.wasm(&id).is_ok()); + assert_eq!(db.recompute_count(), n + 1, "one load, and no other work"); +} diff --git a/src/ephapax-query/src/lib.rs b/src/ephapax-query/src/lib.rs index 10491d11..b401dd44 100644 --- a/src/ephapax-query/src/lib.rs +++ b/src/ephapax-query/src/lib.rs @@ -239,8 +239,15 @@ impl QueryDb { /// Codegen query. Memoises `compile_module`, gated on a clean /// typecheck; depends on `parsed` and `typed`. pub fn wasm(&mut self, id: &str) -> WasmResult { - let parsed = self.parsed(id); let typed = self.typed(id); + // The program that `typed` checked. A second `parsed` demand + // loads a program with imports again, and an imported file may + // change between two loads: the compiled program must be the + // checked one. + let parsed = match self.parsed.get(id) { + Some(memo) => memo.value.clone(), + None => self.parsed(id), + }; let dep_parsed = self .parsed .get(id) diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 91f4e209..352ca938 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -963,7 +963,8 @@ impl Codegen { /// /// Unsupported constructs in either `module` or `main_body` return a /// [`CodegenError`] before compilation, even when `main_body` would not be - /// used. Other module-compilation errors are caught to select the fallback. + /// used. Other module-compilation errors are caught to select the + /// fallback, which is [`Self::compile_program`] on `main_body`. pub fn compile_module_with_main( &mut self, module: &AstModule, @@ -979,38 +980,9 @@ impl Codegen { return Ok(bytes); } - // Fallback: just compile the main body as a standalone expression. - self.emit_types(); - self.emit_imports(); - - let mut func_sec = FunctionSection::new(); - let mut code_sec = CodeSection::new(); - - self.append_runtime_funcs(&mut func_sec, &mut code_sec); - - // Main function (index self.first_user_fn()) - func_sec.function(TYPE_VOID_VOID); - - code_sec.function(&self.compile_standalone_body(main_body)); - - // Lambda functions discovered during expression compilation - let _ = self.append_lambda_funcs(&mut func_sec, &mut code_sec); - - // Emit in WASM order: Type, Import, Function, Table, Memory, Export, Element, Code, Data - self.module.section(&func_sec); - self.emit_table(); - self.emit_memory(); - - let mut exports = ExportSection::new(); - self.add_runtime_exports(&mut exports); - exports.export("main", ExportKind::Func, self.first_user_fn()); - self.module.section(&exports); - - self.emit_elements(); - self.module.section(&code_sec); - self.emit_data_section(); - self.emit_debug_sections(); - Ok(self.module.clone().finish()) + // Fallback: the main body as a standalone expression, checked + // and compiled as `compile_program` does it. + self.compile_program(main_body) } // ----------------------------------------------------------------------- @@ -4809,6 +4781,39 @@ mod tests { validate_wasm(&wasm); } + /// The fallback of `compile_module_with_main` checks the main body + /// before it compiles it. It used to compile it with no type table, + /// so a 64-bit `if` got an i32 block type. + #[test] + fn module_with_main_fallback_checks_the_body() { + // fn f(): I32 = true (not well typed: the fallback is taken) + let module = AstModule { + name: "test".into(), + imports: vec![], + decls: vec![Decl::Fn { + name: "f".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Base(BaseTy::I32), + body: e(ExprKind::Lit(Literal::Bool(true))), + }], + }; + // if true then fst((1i64, 2)) else 3i64 + let main_body = e(ExprKind::If { + cond: Box::new(e(ExprKind::Lit(Literal::Bool(true)))), + then_branch: Box::new(e(ExprKind::Fst(Box::new(e(ExprKind::Pair { + left: Box::new(e(ExprKind::Lit(Literal::I64(1)))), + right: Box::new(e(ExprKind::Lit(Literal::I32(2)))), + }))))), + else_branch: Box::new(e(ExprKind::Lit(Literal::I64(3)))), + }); + let wasm = Codegen::new() + .compile_module_with_main(&module, &main_body) + .expect("the main body compiles on its own"); + validate_wasm(&wasm); + } + #[test] fn compile_module_multiple_functions() { // fn double(x: I32) -> I32 = x + x