diff --git a/Cargo.lock b/Cargo.lock index 4c243a3e..97b1706f 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,20 @@ 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", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "ephapax-fuzz" version = "0.1.0" @@ -844,9 +858,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 +917,7 @@ dependencies = [ name = "ephapax-query" version = "0.1.0" dependencies = [ - "ephapax-parser", - "ephapax-syntax", - "ephapax-typing", + "ephapax-driver", "ephapax-wasm", ] @@ -915,6 +926,7 @@ name = "ephapax-repl" version = "0.1.0" dependencies = [ "colored", + "ephapax-driver", "ephapax-interp", "ephapax-lexer", "ephapax-parser", @@ -968,6 +980,7 @@ version = "0.1.0" dependencies = [ "ephapax-ir", "ephapax-syntax", + "ephapax-typing", "gimli 0.31.1", "object 0.36.7", "proptest", 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 f7a7f041..168c5c35 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: *568 tests* (`+cargo test --workspace --all-targets+`); +* Total: *590 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 568 +* Documented all-target tests: 596 === 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 7b336208..cf8195ed 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/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-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-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..14a1983f 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,123 @@ 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))?; +/// 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)) +} - let filename = path.to_str().unwrap_or("input"); +/// 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:?}") + } + _ => e.to_string(), + } +} - // 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()); +/// `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(), + ); + } - // Parse as module - let module = db.parsed(filename).map_err(|errors| { - for error in &errors { - report_parse_error(filename, &content, error); + let program = load_program(path, None)?; + if verbose { + println!("{} Type check passed", "✓".green()); + } + // 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, + ret_ty, + .. + } 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()); } - format!("{} parse error(s)", errors.len()) - })?; + return Ok(()); + }; - if verbose { - println!("{} Parsed {} declarations", "✓".green(), module.decls.len()); + if interp { + return run_interp(&module, ffi_libs, verbose); } - // Type check - db.typed(filename).map_err(|e| { - report_type_error(filename, &content, &e); - format!("Type error: {}", e) - })?; + if n_params != 0 { + return Err(format!( + "`main` takes {n_params} parameter(s); `ephapax run` calls `main` with none" + )); + } - if verbose { - println!("{} Type check passed", "✓".green()); + 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(()) +} - // Run with interpreter +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,23 +397,15 @@ 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") { - 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)), } } @@ -368,75 +439,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 +499,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 +527,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 +911,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..40ce21f7 --- /dev/null +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -0,0 +1,298 @@ +// 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 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"); + 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"], + ]; + + // (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(&format!("Integer literal `{literal}` is out of range")), + "`{body}` under {args:?}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } + } + + // 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:?}" + ); + } + } +} + +/// A limit of the typed lowering is a load error too: `check` must not +/// 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 [ + ( + "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() + ); + } + } +} + +/// 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"); +} + +/// `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-cli/tests/run_wasm.rs b/src/ephapax-cli/tests/run_wasm.rs new file mode 100644 index 00000000..371e1d92 --- /dev/null +++ b/src/ephapax-cli/tests/run_wasm.rs @@ -0,0 +1,250 @@ +// 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" + ); +} + +/// 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:?}"); + } +} + +/// 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"); +} + +/// 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-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-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..43735774 --- /dev/null +++ b/src/ephapax-driver/Cargo.toml @@ -0,0 +1,24 @@ +# 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 + +[dev-dependencies] +tempfile = "3" diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs new file mode 100644 index 00000000..ce9c768e --- /dev/null +++ b/src/ephapax-driver/src/lib.rs @@ -0,0 +1,497 @@ +#![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 qualify; +mod resolver; + +use std::path::{Path, PathBuf}; + +use ephapax_desugar::{DataRegistry, DesugarError, Desugarer}; +use ephapax_parser::ParseError; +use ephapax_syntax::{Decl, ExternItem, Module}; +use ephapax_typing::{module_expr_types, ModuleRegistry, SpannedTypeError}; +use ephapax_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. 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 + // 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; + qualify::qualify_functions(&mut modules); + let mut merged = modules + .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 { + 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 { + 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 + } +} + +/// 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(), + })?; + parse_source(&source, &root.to_string_lossy(), root.parent(), 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`; the empty path, +/// which is the parent of a bare file name: the current directory). +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(); + let mut types = Vec::with_capacity(program.modules.len()); + for m in &program.modules { + 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), + } + })?); + } + // A program is one wasm module, and codegen finds a callee by its + // bare name. Two functions of the same name get module-qualified names + // in the merge (`qualify`). An extern function cannot: its name is the + // 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 { + let mut callables = Vec::new(); + for decl in &m.core.decls { + 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; + }; + if first == &m.logical_path || first_ext == ext { + continue; + } + return Err(DriverError::Unsupported { + path: m.file_path.clone(), + error: CodegenError(format!( + "{}: `{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 + )), + }); + } + } + // 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(()) +} + +/// [`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) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn extern_fn_names(module: &Module) -> 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}" + ); + } + + /// 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() { + let dir = tempfile::tempdir().expect("tempdir"); + let write = |name: &str, text: &str| { + std::fs::write(dir.path().join(name), text).expect("write module") + }; + 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}" + ); + } + } + + /// `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"); + } + + /// 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\nfn f(x: I32): I32 =\n module_size(x)\n", + "app.eph", + None, + None, + ) + .expect("parses"); + assert_eq!(program.root().logical_path, "app"); + } +} 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)); + } + } +} diff --git a/src/ephapax-driver/src/resolver.rs b/src/ephapax-driver/src/resolver.rs new file mode 100644 index 00000000..16561a95 --- /dev/null +++ b/src/ephapax-driver/src/resolver.rs @@ -0,0 +1,283 @@ +// 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> { + // 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, + 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. 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(|| { + 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() + .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` + /// 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. 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(errors) => match parse_module(&source, logical) { + Ok(m) => Parsed::Core(m), + Err(_) => { + 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 `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 { + 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, + }; + 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 +} + +/// 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 { + ephapax_parser::declared_module_name(source).map(|name| normalise_path(&name)) +} + +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-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 9a6547a5..3dde9ca4 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), @@ -506,6 +510,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(), )), @@ -529,29 +536,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), @@ -575,20 +609,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 { @@ -645,8 +678,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-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..22e8f3be 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; @@ -71,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 { @@ -78,62 +81,43 @@ 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 { - 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, - }, - }, + /// 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()); + 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), - code: None, source: Some("ephapax".to_string()), - message: format!("Parse error: {:?}", err), - related_information: None, - tags: None, - code_description: None, - data: None, - }); - } - } + message: format!("analysis failed: {e}"), + ..Diagnostic::default() + }], + None, + ), + }; + if self.generations.lock().ok()?.get(uri) != Some(&generation) { + return None; } + 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() { @@ -147,7 +131,7 @@ impl Backend { ); } - diagnostics + Some(diagnostics) } /// Find the declaration at a given position (for go-to-definition). @@ -351,34 +335,45 @@ 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; + } } } 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); @@ -731,6 +726,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 +951,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/ephapax.pest b/src/ephapax-parser/src/ephapax.pest index 90b784d7..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 @@ -425,6 +428,7 @@ variable = { identifier } literal = { unit_literal | boolean + | float32 | float | integer | string @@ -435,6 +439,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/error.rs b/src/ephapax-parser/src/error.rs index 648bedba..9a0aaab9 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 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 }, } 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,19 @@ 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( + "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 f63e2679..332ca19a 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 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 //! @@ -98,10 +98,59 @@ 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 // ============================================================================ +/// 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 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)) +} + +/// 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>( + 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()) @@ -915,7 +964,7 @@ fn parse_pattern_core(pair: pest::iterators::Pair) -> Result { let name = parse_identifier( @@ -969,20 +1018,13 @@ 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::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(); let content = &s[1..s.len() - 1]; @@ -998,12 +1040,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 { @@ -1561,11 +1603,17 @@ fn parse_unary_expr(pair: pest::iterators::Pair) -> Result) -> Result { // Field access by name not currently supported @@ -2009,15 +2054,8 @@ 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::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(); // Remove quotes and process escapes @@ -2241,11 +2279,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 976e2f8f..f50ff931 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 { @@ -1363,21 +1356,16 @@ fn parse_postfix_expr(pair: pest::iterators::Pair) -> Result { // Named member access `base.member` — qualified @@ -1552,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 @@ -1736,13 +1744,8 @@ 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::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()), @@ -1967,6 +1970,53 @@ fn parse_base_ty(pair: pest::iterators::Pair) -> Result 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 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` //! 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 +56,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 +91,18 @@ 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 -/// 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 == y) + 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 { @@ -135,13 +145,20 @@ 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; 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; @@ -152,8 +169,18 @@ 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; + // 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, @@ -170,7 +197,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 +214,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 +222,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( @@ -212,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) @@ -230,9 +264,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; @@ -291,6 +326,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(); 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..06a7cbc9 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,10 +279,12 @@ 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_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 @@ -324,33 +325,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,13 +332,45 @@ 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)?; - self.interpreter.load_module(&module); + let stdlib = ephapax_driver::stdlib_dir(None); + self.load(ephapax_driver::load_source( + source, + name, + None, + stdlib.as_deref(), + )) + .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, + ) -> Result { + let module = program.map_err(ReplError::Load)?.into_merged(); + self.interpreter.load_module(&module); + Ok(module.decls.len()) + } } impl Default for Repl { @@ -386,6 +392,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 +408,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), } } } @@ -469,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"); 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 50452cf9..80b58672 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; @@ -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), @@ -856,8 +859,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 +884,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 +1074,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), @@ -1977,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( @@ -2356,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() @@ -2449,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() @@ -2488,6 +2541,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 +2584,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 afebebcc..352ca938 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; @@ -118,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 @@ -145,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) @@ -173,10 +192,26 @@ 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). 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 {}..{})", @@ -198,6 +233,36 @@ pub fn first_unsupported(expr: &Expr) -> Option { ), expr.span, )), + // Every closure has the wasm signature (env: i32, arg: i32) -> 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, arms } => types + .and_then(|t| t.get(&scrutinee.id)) + .filter(|ty| { + arms.iter() + .any(|arm| pattern_reads_wide_as_i32(&arm.pattern, ty)) + }) + .map(|ty| { + unsupported( + &format!( + "`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, + ) + }), _ => None, }; if here.is_some() { @@ -253,7 +318,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`]. @@ -261,13 +328,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}`"))); } } @@ -362,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; @@ -380,11 +456,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 } @@ -405,15 +486,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 @@ -449,6 +521,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)>, @@ -569,6 +645,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(), @@ -662,10 +739,13 @@ 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() + 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 { self.import_count() + NUM_RUNTIME_FNS @@ -729,6 +809,14 @@ 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(); + if let Some(err) = first_unsupported_typed(main_expr, &self.types) { + return Err(err); + } self.emit_types(); self.emit_imports(); @@ -752,12 +840,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); @@ -782,6 +865,27 @@ impl Codegen { 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())?; @@ -802,6 +906,14 @@ 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}")))?; + validate_supported_typed(ast, &self.types)?; + // ----- 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 @@ -851,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, @@ -867,43 +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); - - 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); - - // 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) } // ----------------------------------------------------------------------- @@ -1024,6 +1103,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 { @@ -1196,8 +1287,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); @@ -1224,13 +1315,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. @@ -1520,16 +1621,18 @@ impl Codegen { fn emit_data_section(&mut self) { let mut data = wasm_encoder::DataSection::new(); - // The bump pointer lives at `mem[0]`. Start it past the metadata - // area and the string data; left at 0, the first heap cell would - // be allocated over the bump pointer itself (nested pairs read back - // corrupted fields, e.g. `fst((1, (2, 3)))`). - let heap_start = self.data_offset.max(1024).next_multiple_of(8); - data.active( - 0, - &wasm_encoder::ConstExpr::i32_const(0), - heap_start.to_le_bytes(), - ); + + // 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, @@ -1864,9 +1967,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); @@ -1892,7 +1995,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![ @@ -1927,7 +2030,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 @@ -1935,8 +2038,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, @@ -1949,16 +2052,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 @@ -1973,25 +2076,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 // ----------------------------------------------------------------------- @@ -2165,10 +2346,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, @@ -2228,7 +2421,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); @@ -2613,77 +2806,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())); + /// 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); - let sum_local = self.locals.temp(); - func.instruction(&Instruction::LocalTee(sum_local)); - - // 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( @@ -2695,6 +2912,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(); @@ -2705,13 +2923,13 @@ 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) 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); @@ -2719,8 +2937,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); @@ -2766,7 +2984,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]) { + // 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 @@ -2780,7 +3004,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(); @@ -2793,22 +3017,21 @@ 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)); } - // Linear arm dispatch. The `$done` block carries the arms' result - // type: every arm has the same type, so the first arm decides it - // (an `I64` extern call in a pair destructuring, for example). - let result_ty = arms + // Linear arm dispatch. Every arm has the type of the match. + let result = arms .first() - .map_or(ValType::I32, |arm| self.infer_expr_valtype(&arm.body)); - func.instruction(&Instruction::Block(BlockType::Result(result_ty))); + .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, @@ -2831,7 +3054,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 @@ -2869,40 +3098,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)); } @@ -2933,18 +3158,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); @@ -3048,18 +3286,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)); @@ -3081,7 +3319,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); @@ -3095,30 +3333,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())); + func.instruction(&Instruction::LocalSet(value)); - let pair_local = self.locals.temp(); - func.instruction(&Instruction::LocalSet(pair_local)); // save pair ptr - - // 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)); } @@ -3137,34 +3365,65 @@ impl Codegen { } fn compile_binop(&mut self, func: &mut Function, op: BinOp, left: &Expr, right: &Expr) { - 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); + // The checker unifies the two operand types, so the left one + // selects the instruction. + let operand = self.valtype_of(left); + 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) { - 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); @@ -3173,112 +3432,71 @@ 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]) { - 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), + } } // ----------------------------------------------------------------------- @@ -3339,6 +3557,398 @@ 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, + // 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, + (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, + } +} + +/// 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) +} + +/// `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). +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, @@ -3547,6 +4157,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()) } @@ -4153,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 @@ -4334,18 +4995,37 @@ 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); } + /// 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(); @@ -4443,10 +5123,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); @@ -4455,7 +5135,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(), @@ -4464,7 +5144,7 @@ mod tests { region: "r".into(), value: "world".to_string(), })), - }); + })); let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); @@ -4473,12 +5153,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(); @@ -4494,27 +5179,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); @@ -5097,6 +5770,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] @@ -5319,8 +6050,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. @@ -5370,7 +6101,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"); @@ -5456,7 +6187,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(), @@ -5464,7 +6195,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(), }, ], }; @@ -5501,7 +6232,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(), }, ], }; 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..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 @@ -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 64c9b89e..b303600a 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] @@ -57,7 +59,7 @@ zig build-lib -dynamic -O ReleaseSafe \ # HISTORICAL (pre-E1, raw `__ffi` only). This no longer reaches the shim; # do not use it to test typed Coproc dispatch until rung E10: -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)`