From d125a8ef32ce2d98b9016166afcdb4c7f8c8979c Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 8 Oct 2026 13:23:04 +0700 Subject: [PATCH 1/9] feat(driver): run compiler work on a 128 MiB stack and remove the parser nesting limit Signed-off-by: microproofs --- .sampo/changesets/compiler-stack.md | 8 ++ crates/nash-cli/src/cmd/docs.rs | 92 ++++++++------- crates/nash-cli/src/cmd/format.rs | 34 +++--- crates/nash-cli/src/main.rs | 23 ++-- crates/nash-driver/src/compile.rs | 38 +++--- crates/nash-driver/src/lib.rs | 1 + crates/nash-driver/src/stack.rs | 50 ++++++++ crates/nash-driver/tests/stack.rs | 29 +++++ crates/nash-parse/src/error.rs | 1 - crates/nash-parse/src/expression/mod.rs | 8 -- crates/nash-parse/src/lib.rs | 111 +----------------- crates/nash-parse/src/pattern/mod.rs | 10 -- crates/nash-parse/src/type_.rs | 8 -- crates/nash-report/src/syntax/mod.rs | 7 -- ...__variants__variant_excessive_nesting.snap | 12 -- crates/nash-report/src/syntax/variants.rs | 11 -- docs/frontend-hardening-verification.md | 5 +- docs/overview.md | 11 +- docs/research/compiler-stack-audit.md | 14 +++ 19 files changed, 212 insertions(+), 261 deletions(-) create mode 100644 .sampo/changesets/compiler-stack.md create mode 100644 crates/nash-driver/src/stack.rs create mode 100644 crates/nash-driver/tests/stack.rs delete mode 100644 crates/nash-report/src/syntax/snapshots/nash_report__syntax__variants__variant_excessive_nesting.snap diff --git a/.sampo/changesets/compiler-stack.md b/.sampo/changesets/compiler-stack.md new file mode 100644 index 00000000..a33bb632 --- /dev/null +++ b/.sampo/changesets/compiler-stack.md @@ -0,0 +1,8 @@ +--- +cargo/nash-driver: minor +cargo/nash-parse: minor +cargo/nash-report: patch +cargo/nash-cli: patch +--- + +Run compiler work on threads with a 128 MiB stack (`nash_driver::stack`) and remove the parser nesting limit of 64 with its `Space::TooDeep` error. Input that is too deep now ends the process with a stack overflow. diff --git a/crates/nash-cli/src/cmd/docs.rs b/crates/nash-cli/src/cmd/docs.rs index 77d83dc1..3d043d3a 100644 --- a/crates/nash-cli/src/cmd/docs.rs +++ b/crates/nash-cli/src/cmd/docs.rs @@ -39,56 +39,58 @@ impl Args { let success = docs.compiler.is_success(); // Render diagnostics off the executor just like documentation pages. let (files, diagnostics) = spawn_blocking(move || { - let mut diagnostics = String::new(); - for module in docs.compiler.ordered_reports() { - let source = nash_report::Source::new(&module.source); - for report in &module.reports { + nash_driver::stack::run(move || { + let mut diagnostics = String::new(); + for module in docs.compiler.ordered_reports() { + let source = nash_report::Source::new(&module.source); + for report in &module.reports { + diagnostics.push_str(&format!( + "{:?}\n", + miette::Report::new(report.render(&source, &module.path, color)) + )); + } + } + for (uri, state) in &docs.compiler.modules { + if let nash_driver::ModuleResult::SourceUnavailable { message } = state { + diagnostics.push_str(&format!("Could not read {uri}: {message}\n")); + } + } + for warning in &docs.warnings { + let mut report = nash_report::Report::snippet( + "DOCUMENTATION WARNING", + nash_region::Region::zero(), + None, + nash_report::Doc::text(format!( + "{}.{}: {}", + warning.module, warning.name, warning.message + )), + nash_report::Doc::Empty, + ) + .warning(); + report.primary_label = None; + report.context = None; diagnostics.push_str(&format!( "{:?}\n", - miette::Report::new(report.render(&source, &module.path, color)) + miette::Report::new(report.render( + &nash_report::Source::new(""), + &warning.module, + color + )) )); } - } - for (uri, state) in &docs.compiler.modules { - if let nash_driver::ModuleResult::SourceUnavailable { message } = state { - diagnostics.push_str(&format!("Could not read {uri}: {message}\n")); - } - } - for warning in &docs.warnings { - let mut report = nash_report::Report::snippet( - "DOCUMENTATION WARNING", - nash_region::Region::zero(), - None, - nash_report::Doc::text(format!( - "{}.{}: {}", - warning.module, warning.name, warning.message - )), - nash_report::Doc::Empty, + let format = match self.format { + Format::Html => nash_docs::Format::Html, + Format::Markdown => nash_docs::Format::Markdown, + }; + ( + if success { + nash_docs::render(&docs.modules, format) + } else { + Default::default() + }, + diagnostics, ) - .warning(); - report.primary_label = None; - report.context = None; - diagnostics.push_str(&format!( - "{:?}\n", - miette::Report::new(report.render( - &nash_report::Source::new(""), - &warning.module, - color - )) - )); - } - let format = match self.format { - Format::Html => nash_docs::Format::Html, - Format::Markdown => nash_docs::Format::Markdown, - }; - ( - if success { - nash_docs::render(&docs.modules, format) - } else { - Default::default() - }, - diagnostics, - ) + }) }) .await .into_diagnostic()?; diff --git a/crates/nash-cli/src/cmd/format.rs b/crates/nash-cli/src/cmd/format.rs index f7bcc2e1..8498a8e6 100644 --- a/crates/nash-cli/src/cmd/format.rs +++ b/crates/nash-cli/src/cmd/format.rs @@ -25,9 +25,11 @@ impl Args { .read_to_string(&mut source) .await .into_diagnostic()?; - let formatted = spawn_blocking(move || format_source(&source, "", color)) - .await - .into_diagnostic()??; + let formatted = spawn_blocking(move || { + nash_driver::stack::run(move || format_source(&source, "", color)) + }) + .await + .into_diagnostic()??; let mut stdout = tokio::io::stdout(); stdout .write_all(formatted.as_bytes()) @@ -76,19 +78,21 @@ fn format_source(source: &str, name: &str, color: bool) -> Result { async fn format_file(path: PathBuf, name: String, check: bool, color: bool) -> Result<()> { let source = tokio::fs::read_to_string(&path).await.into_diagnostic()?; let formatted = spawn_blocking(move || { - let formatted = format_source(&source, &name, color)?; - if check { - if let Some(report) = nash_report::format::difference(&name, &source, &formatted) { - return Err(miette::Report::new(report.render( - &nash_report::Source::new(&source), - &name, - color, - ))); + nash_driver::stack::run(move || { + let formatted = format_source(&source, &name, color)?; + if check { + if let Some(report) = nash_report::format::difference(&name, &source, &formatted) { + return Err(miette::Report::new(report.render( + &nash_report::Source::new(&source), + &name, + color, + ))); + } + Ok(None) + } else { + Ok((source != formatted).then_some(formatted)) } - Ok(None) - } else { - Ok((source != formatted).then_some(formatted)) - } + }) }) .await .into_diagnostic()??; diff --git a/crates/nash-cli/src/main.rs b/crates/nash-cli/src/main.rs index 77a8e299..9cfb8e43 100644 --- a/crates/nash-cli/src/main.rs +++ b/crates/nash-cli/src/main.rs @@ -5,15 +5,18 @@ fn main() -> miette::Result<()> { // process is single-threaded — so it runs before the runtime starts. let proxied = nash_cli::proxy::proxy_guard()?; - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .into_diagnostic()? - .block_on(async { - if !proxied { - nash_cli::proxy::maybe_proxy().await?; - } + // Commands render and drop compiler results on this thread. + nash_driver::stack::run(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .into_diagnostic()? + .block_on(async { + if !proxied { + nash_cli::proxy::maybe_proxy().await?; + } - nash_cli::Cli::default().exec().await - }) + nash_cli::Cli::default().exec().await + }) + }) } diff --git a/crates/nash-driver/src/compile.rs b/crates/nash-driver/src/compile.rs index cc12fdfd..4bc1f616 100644 --- a/crates/nash-driver/src/compile.rs +++ b/crates/nash-driver/src/compile.rs @@ -118,9 +118,9 @@ struct CompileOutput { /// Compile all modules through the full pipeline, in dependency order. /// -/// The async part only fetches sources; the CPU-bound compilation runs on -/// tokio's blocking pool (`spawn_blocking`) so no executor worker is ever -/// stalled. +/// The async part only fetches sources; the CPU-bound compilation runs on a +/// compiler thread (`stack::run`) behind tokio's blocking pool, so no executor +/// worker is ever stalled. /// `origins` must contain every module in the graph, including applications. pub async fn build( db: Arc>, @@ -189,13 +189,15 @@ where let edges = graph.edges.clone(); let test_modules = graph.test_modules.clone(); tokio::task::spawn_blocking(move || { - build_sync_with_policy( - sources, - &edges, - exclude_tests, - test_modules.as_ref(), - finish, - ) + crate::stack::run(move || { + build_sync_with_policy( + sources, + &edges, + exclude_tests, + test_modules.as_ref(), + finish, + ) + }) }) .await .expect("compile task panicked") @@ -553,13 +555,15 @@ pub async fn build_graph_with_tests( let (uri, known) = (uri.clone(), known.clone()); let include_tests = test_modules.contains(&uri); tokio::task::spawn_blocking(move || { - // Retain unreadable nodes: the build reports their I/O failure and - // blocks dependents while continuing independent modules. - let imports = match source { - Ok(source) => scan_module(&source, &uri, &known, include_tests)?, - Err(_) => vec![], - }; - Ok::<_, DriverError>((uri, imports)) + crate::stack::run(move || { + // Retain unreadable nodes: the build reports their I/O failure and + // blocks dependents while continuing independent modules. + let imports = match source { + Ok(source) => scan_module(&source, &uri, &known, include_tests)?, + Err(_) => vec![], + }; + Ok::<_, DriverError>((uri, imports)) + }) }) }) .collect(); diff --git a/crates/nash-driver/src/lib.rs b/crates/nash-driver/src/lib.rs index 6bacf4fc..b83e1dca 100644 --- a/crates/nash-driver/src/lib.rs +++ b/crates/nash-driver/src/lib.rs @@ -50,6 +50,7 @@ pub mod graph; pub mod interface; pub mod project; pub mod source; +pub mod stack; // Re-export main types pub use compile::{ diff --git a/crates/nash-driver/src/stack.rs b/crates/nash-driver/src/stack.rs new file mode 100644 index 00000000..7626fa8d --- /dev/null +++ b/crates/nash-driver/src/stack.rs @@ -0,0 +1,50 @@ +//! The compiler stack. +//! +//! Compiler passes recurse over source structure, as Elm's do. Elm runs on a +//! stack that grows on the heap; Rust fixes the stack size when a thread +//! starts. Every thread that runs compiler work therefore reserves +//! `COMPILER_STACK` bytes. The reserve is address space: the system commits a +//! page only when the thread touches it. Input that needs more ends the process +//! with a stack overflow. + +pub const COMPILER_STACK: usize = 128 * 1024 * 1024; + +/// Run compiler work on a new thread with the compiler stack. +pub fn run(work: impl FnOnce() -> T + Send) -> T { + std::thread::scope(|scope| { + std::thread::Builder::new() + .name("nash-compiler".into()) + .stack_size(COMPILER_STACK) + .spawn_scoped(scope, work) + .expect("cannot start the compiler thread") + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Use at least 1 KiB of stack for each level. + fn descend(levels: usize) -> usize { + let frame = std::hint::black_box([1u8; 1024]); + if levels == 0 { + 0 + } else { + descend(levels - 1) + usize::from(frame[0]) + } + } + + #[test] + fn run_gives_the_compiler_stack() { + // At least 32 MiB of frames: more than a default thread or main stack. + assert_eq!(run(|| descend(32 * 1024)), 32 * 1024); + } + + #[test] + #[should_panic(expected = "from compiler work")] + fn run_resumes_a_panic_from_the_work() { + run(|| panic!("from compiler work")); + } +} diff --git a/crates/nash-driver/tests/stack.rs b/crates/nash-driver/tests/stack.rs new file mode 100644 index 00000000..7bf61415 --- /dev/null +++ b/crates/nash-driver/tests/stack.rs @@ -0,0 +1,29 @@ +//! Deep input compiles on the compiler stack. + +use nash_driver::{Database, InMemorySource, build, build_graph}; +use std::{collections::BTreeMap, sync::Arc}; +use tokio::sync::Mutex; +use url::Url; + +/// 4,000 levels overflow a 2 MiB thread in a debug build, in the import scan +/// and again in the compile. +#[tokio::test] +async fn nested_parentheses_have_no_limit() { + let depth = 4_000; + let source = format!( + "module Main exposing (..)\nvalue : int\nvalue = {}1{}\n", + "(".repeat(depth), + ")".repeat(depth) + ); + let files = InMemorySource::new(); + let uri = Url::parse("file:///project/src/Main.nash").unwrap(); + files.insert(uri.clone(), source); + let mut modules = BTreeMap::from([(uri, None)]); + modules.extend(nash_driver::bundled_base::modules()); + let db = Arc::new(Mutex::new(Database::new(files))); + let graph = build_graph(db.clone(), &modules.keys().cloned().collect::>()) + .await + .unwrap(); + let report = build(db, &graph, &modules).await; + assert!(report.is_success(), "{:?}", report.ordered_reports()); +} diff --git a/crates/nash-parse/src/error.rs b/crates/nash-parse/src/error.rs index 1a0b8725..10965b9b 100644 --- a/crates/nash-parse/src/error.rs +++ b/crates/nash-parse/src/error.rs @@ -571,7 +571,6 @@ pub enum Number { #[derive(Debug)] pub enum Space { - TooDeep, HasTab, EndlessMultiComment(Position), } diff --git a/crates/nash-parse/src/expression/mod.rs b/crates/nash-parse/src/expression/mod.rs index 21b323f2..3f07db2a 100644 --- a/crates/nash-parse/src/expression/mod.rs +++ b/crates/nash-parse/src/expression/mod.rs @@ -45,10 +45,6 @@ impl<'a> Parser<'a> { /// Currently implements: lambda, possiblyNegativeTerm + function application. /// TODO: let, if, case, operators pub fn expression(&mut self) -> Result<(&'a Located>, Position), error::Expr<'a>> { - self.with_depth(error::Expr::Space, Self::expression_inner) - } - - fn expression_inner(&mut self) -> Result<(&'a Located>, Position), error::Expr<'a>> { let start = self.get_position(); self.one_of( @@ -329,10 +325,6 @@ impl<'a> Parser<'a> { /// ] /// ``` pub fn term(&mut self) -> Result<&'a Located>, error::Expr<'a>> { - self.with_depth(error::Expr::Space, Self::term_inner) - } - - fn term_inner(&mut self) -> Result<&'a Located>, error::Expr<'a>> { let start = self.get_position(); self.one_of( diff --git a/crates/nash-parse/src/lib.rs b/crates/nash-parse/src/lib.rs index f19374da..34ebe579 100644 --- a/crates/nash-parse/src/lib.rs +++ b/crates/nash-parse/src/lib.rs @@ -53,8 +53,6 @@ pub struct Parser<'a> { row: Row, /// Current column (1-indexed) col: Col, - depth: usize, - depth_error: Option<(Row, Col)>, } impl<'a> Parser<'a> { @@ -78,39 +76,9 @@ impl<'a> Parser<'a> { indent: 1, row: 1, col: 1, - depth: 0, - depth_error: None, } } - /// Count recursive expression, pattern, and type entries together. This state - /// is deliberately outside ParserState: backtracking cannot undo exhaustion. - fn with_depth( - &mut self, - to_error: impl FnOnce(error::Space, Row, Col) -> E, - parse: impl FnOnce(&mut Self) -> Result, - ) -> Result { - const MAX_DEPTH: usize = 64; - if self.depth == MAX_DEPTH || self.depth_error.is_some() { - let position = self.position(); - let (row, col) = *self.depth_error.get_or_insert(position); - return Err(to_error(error::Space::TooDeep, row, col)); - } - self.depth += 1; - let result = parse(self); - self.depth -= 1; - // A speculative parser may swallow an error. Never turn exhaustion into - // a successful prefix parse, even when it restored the input position. - let result = match (result, self.depth_error) { - (Ok(_), Some((row, col))) => Err(to_error(error::Space::TooDeep, row, col)), - (result, _) => result, - }; - if self.depth == 0 { - self.depth_error = None; - } - result - } - // ------------------------------------------------------------------------- // Position & State // ------------------------------------------------------------------------- @@ -272,7 +240,7 @@ impl<'a> Parser<'a> { Ok(value) => return Ok(value), Err(e) => { // Did we consume any input? - if self.pos != before.pos || self.depth_error.is_some() { + if self.pos != before.pos { // Committed - propagate error return Err(e); } @@ -308,7 +276,7 @@ impl<'a> Parser<'a> { Ok(value) => return Ok(value), Err(e) => { // Did we consume any input? - if self.pos != before.pos || self.depth_error.is_some() { + if self.pos != before.pos { // Committed - propagate error return Err(e); } @@ -514,81 +482,6 @@ impl<'a> Parser<'a> { #[cfg(test)] mod tests { - #[test] - fn nesting_is_bounded_on_a_small_stack() { - std::thread::Builder::new() - .stack_size(2 * 1024 * 1024) - .spawn(|| { - for depth in [8, 512] { - for (open, leaf, close, kind) in [ - ("(", "1", ")", 0), - ("(-", "1", ")", 0), - ("[", "1", "]", 0), - ("\\x -> ", "1", "", 0), - ("Just (", "x", ")", 1), - ("(", "x", ")", 2), - ("Int -> ", "Int", "", 3), - ("(", "Int", ")", 4), - ] { - let bump = Bump::new(); - let source = - format!("{}{}{}", open.repeat(depth), leaf, close.repeat(depth)); - let mut parser = Parser::new(&bump, &source); - let result = match kind { - 0 => parser - .expression() - .map(|_| ()) - .map_err(|e| format!("{e:?}")), - 1 => parser - .pattern_expr() - .map(|_| ()) - .map_err(|e| format!("{e:?}")), - 2 => parser - .pattern_term() - .map(|_| ()) - .map_err(|e| format!("{e:?}")), - 3 => parser.type_expr().map(|_| ()).map_err(|e| format!("{e:?}")), - _ => parser.type_term().map(|_| ()).map_err(|e| format!("{e:?}")), - }; - if depth == 8 { - assert!(result.is_ok(), "{open}: {result:?}"); - assert!(parser.is_eof()); - } else { - assert!(result.unwrap_err().contains("TooDeep"), "{open}"); - } - assert_eq!(parser.depth, 0); - assert_eq!(parser.depth_error, None); - } - } - }) - .unwrap() - .join() - .unwrap(); - } - - #[test] - fn nesting_exhaustion_survives_backtracking_and_resets() { - let bump = Bump::new(); - let mut parser = Parser::new(&bump, "x"); - let result = parser.with_depth(error::Expr::Space, |p| { - let saved = p.save_state(); - p.depth_error = Some((1, 1)); - p.restore_state(saved); - p.one_of_with_fallback( - vec![Box::new(|_| { - Err(error::Expr::Space(error::Space::TooDeep, 1, 1)) - })], - (), - ) - }); - assert!(matches!( - result, - Err(error::Expr::Space(error::Space::TooDeep, 1, 1)) - )); - parser.expression().unwrap(); - assert!(parser.is_eof()); - } - #[test] fn flat_sequences_use_bounded_stack() { std::thread::Builder::new() diff --git a/crates/nash-parse/src/pattern/mod.rs b/crates/nash-parse/src/pattern/mod.rs index c8d9ed8a..a47b4c00 100644 --- a/crates/nash-parse/src/pattern/mod.rs +++ b/crates/nash-parse/src/pattern/mod.rs @@ -33,10 +33,6 @@ impl<'a> Parser<'a> { /// ] /// ``` pub fn pattern_term(&mut self) -> Result<&'a Located>, error::Pattern<'a>> { - self.with_depth(error::Pattern::Space, Self::pattern_term_inner) - } - - fn pattern_term_inner(&mut self) -> Result<&'a Located>, error::Pattern<'a>> { let start = self.get_position(); self.one_of( @@ -65,12 +61,6 @@ impl<'a> Parser<'a> { /// ``` pub fn pattern_expr( &mut self, - ) -> Result<(&'a Located>, Position), error::Pattern<'a>> { - self.with_depth(error::Pattern::Space, Self::pattern_expr_inner) - } - - fn pattern_expr_inner( - &mut self, ) -> Result<(&'a Located>, Position), error::Pattern<'a>> { let start = self.get_position(); let (first_pattern, first_end) = self.pattern_expr_part()?; diff --git a/crates/nash-parse/src/type_.rs b/crates/nash-parse/src/type_.rs index 5e00f419..19a98a86 100644 --- a/crates/nash-parse/src/type_.rs +++ b/crates/nash-parse/src/type_.rs @@ -42,10 +42,6 @@ impl<'a> Parser<'a> { /// oneOfWithFallback [ arrow... ] term1 /// ``` pub fn type_expr(&mut self) -> Result<(&'a Located>, Position), error::Type<'a>> { - self.with_depth(error::Type::Space, Self::type_expr_inner) - } - - fn type_expr_inner(&mut self) -> Result<(&'a Located>, Position), error::Type<'a>> { let start = self.get_position(); // Parse first term - either type application or simple term @@ -247,10 +243,6 @@ impl<'a> Parser<'a> { /// - Tuples: `()`, `(Int, String)` /// - Records: `{}`, `{ name : String }` pub fn type_term(&mut self) -> Result<&'a Located>, error::Type<'a>> { - self.with_depth(error::Type::Space, Self::type_term_inner) - } - - fn type_term_inner(&mut self) -> Result<&'a Located>, error::Type<'a>> { let start = self.get_position(); self.one_of( diff --git a/crates/nash-report/src/syntax/mod.rs b/crates/nash-report/src/syntax/mod.rs index c7cd59bc..8a190e52 100644 --- a/crates/nash-report/src/syntax/mod.rs +++ b/crates/nash-report/src/syntax/mod.rs @@ -172,13 +172,6 @@ fn unclosed_literal( pub(crate) fn to_space_report(_source: &Source<'_>, space: &Space, row: Row, col: Col) -> Report { match space { - Space::TooDeep => problem( - "EXCESSIVE NESTING", - row, - col, - "Nesting limit exceeded.", - "Split this expression, pattern, or type into smaller definitions.", - ), Space::HasTab => problem( "NO TABS", row, diff --git a/crates/nash-report/src/syntax/snapshots/nash_report__syntax__variants__variant_excessive_nesting.snap b/crates/nash-report/src/syntax/snapshots/nash_report__syntax__variants__variant_excessive_nesting.snap deleted file mode 100644 index eb73ca78..00000000 --- a/crates/nash-report/src/syntax/snapshots/nash_report__syntax__variants__variant_excessive_nesting.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: crates/nash-report/src/syntax/variants.rs -description: f = (((1))) ---- -nash::syntax - - × Nesting limit exceeded. - ╭─[src/Main.nash:1:7] - 1 │ f = (((1))) - · ─ - ╰──── - help: Split this expression, pattern, or type into smaller definitions. diff --git a/crates/nash-report/src/syntax/variants.rs b/crates/nash-report/src/syntax/variants.rs index f061a3b8..94d46b9b 100644 --- a/crates/nash-report/src/syntax/variants.rs +++ b/crates/nash-report/src/syntax/variants.rs @@ -4,17 +4,6 @@ use super::*; use crate::render_plain; use nash_parse::error::*; -#[test] -fn variant_excessive_nesting() { - let input = "f = (((1)))"; - let source = Source::new(input); - let error = Module::Space(Space::TooDeep, 1, 7); - let report = module::to_parse_error_report(&source, &error); - insta::with_settings!({ description => input, omit_expression => true }, { - insta::assert_snapshot!(render_plain(&report, &source, "src/Main.nash")); - }); -} - #[test] fn variant_module_space() { let input = "f =\t1"; diff --git a/docs/frontend-hardening-verification.md b/docs/frontend-hardening-verification.md index f2766170..b6a1bd41 100644 --- a/docs/frontend-hardening-verification.md +++ b/docs/frontend-hardening-verification.md @@ -15,7 +15,7 @@ rebases; use `jj log -r ` for the current commit hash. | `lrnzrllu` | Remove parser accumulator copies | | `tsqsspqw` | Require valid UTF-8 input | | `wplqvsku` | Widen source coordinates | -| `rnvunqnt` | Bound parser nesting and iterate flat sequences | +| `rnvunqnt` | Bound parser nesting and iterate flat sequences (the nesting bound was removed on 2026-10-08, see `docs/overview.md`) | | `otpvxozr` | Remove inactive interface-cache machinery | | `ovwmkvpo` | Borrow canonical local scopes | | `npksuwvn` | Bound trait candidate traversal | @@ -41,8 +41,7 @@ cargo test --release -p nash-parse ``` The parser tests cover Unicode and escapes, coordinates beyond 65,535, -arbitrary lookahead, oversized Unicode escapes, nesting on a 2 MiB stack, and -long flat sequences. Scope regressions cover shadowing and error recovery. +arbitrary lookahead, oversized Unicode escapes, and long flat sequences. Scope regressions cover shadowing and error recovery. Trait traversal compares candidate keys and payload identity with the original ordered full-map filter, including missing traits and empty heads. diff --git a/docs/overview.md b/docs/overview.md index 75497670..040a08e0 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -31,11 +31,12 @@ Name suggestions merge visible bindings in sorted order. The driver returns in-memory interface summaries with exports, kinds, and contract fingerprints. It has no persistent interface cache or cache metadata. -The parser permits at most 64 simultaneous recursive expression, pattern, and -type entries, counted together. Beyond this limit it reports excessive nesting -at the first exhausted position. Backtracking cannot clear that failure. This -limit is checked on a 2 MiB stack in debug and release tests. Flat sequences and -nested comments iterate instead of consuming stack per item. +Compiler passes recurse over source structure, as Elm's do. Every thread that +runs compiler work reserves a 128 MiB stack through `nash_driver::stack::run`: +the driver's compile and import-scan threads, and the CLI's command, format, and +docs threads. There is no nesting limit and no stack check: input that needs +more stack ends the process with a stack overflow. In the parser, flat sequences +and nested comments iterate instead of consuming stack per item. ## Status diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index 145dd9b8..a69dc23d 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -1,5 +1,19 @@ # Compiler stack audit — 2026-09-29 +## Stack model — 2026-10-08 + +This audit planned one explicit work stack for each recursive path. That +direction is superseded. Compiler work now runs on threads with one large +reserved stack (`nash_driver::stack::run`, 128 MiB), and a traversal recurses in +Elm's shape. rustc made the same change in rust-lang/rust#160535; the reasons +are in rust-lang/compiler-team#1011. The parser nesting limit of 64 is removed. +There is no stack check: input that is too deep ends the process with a stack +overflow. + +The sections below are the record of 2026-09-29. The candidates and the +follow-up order are not planned work. The work stacks under "Reproduced +failures" stay until a later change converts them back to recursion. + Default O1 exposed a stack overflow in the driver `ledger_contexts` fixture. This audit distinguishes reproduced failures from recursive paths that still need depth regressions. Passing the fixture does not establish a universal From 7687f06d3a80dee9491899bf96a2346eb3833d3a Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 8 Oct 2026 13:33:45 +0700 Subject: [PATCH 2/9] fix(plutus): encode and decode negative Data big integers as -1 - n Signed-off-by: microproofs --- .sampo/changesets/cbor-negative-bignum.md | 5 ++ crates/nash-plutus/src/flat/data.rs | 64 +++++++++++++++++------ 2 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 .sampo/changesets/cbor-negative-bignum.md diff --git a/.sampo/changesets/cbor-negative-bignum.md b/.sampo/changesets/cbor-negative-bignum.md new file mode 100644 index 00000000..e758f0fd --- /dev/null +++ b/.sampo/changesets/cbor-negative-bignum.md @@ -0,0 +1,5 @@ +--- +cargo/nash-plutus: patch +--- + +Encode and decode negative Data integers below `-(2^64 - 1)` as the Haskell node does: CBOR tag 3 holds `-1 - n`, and `-2^64` is a plain negative integer. diff --git a/crates/nash-plutus/src/flat/data.rs b/crates/nash-plutus/src/flat/data.rs index 0a96344a..27e35ad2 100644 --- a/crates/nash-plutus/src/flat/data.rs +++ b/crates/nash-plutus/src/flat/data.rs @@ -99,14 +99,13 @@ impl<'a, 'b> minicbor::decode::Decode<'b, Ctx<'a>> for &'a PlutusData<'a> { bytes.extend_from_slice(chunk); } - let integer = ctx.arena.alloc_integer(num::BigInt::from_bytes_be( - if x == IanaTag::PosBignum { - num_bigint::Sign::Plus - } else { - num_bigint::Sign::Minus - }, - &bytes, - )); + let n = num::BigInt::from_bytes_be(num_bigint::Sign::Plus, &bytes); + // RFC 8949 3.4.3: tag 3 holds -1 - n. + let integer = ctx.arena.alloc_integer(if x == IanaTag::PosBignum { + n + } else { + -n - 1 + }); Ok(PlutusData::integer(ctx.arena, integer)) } @@ -264,14 +263,20 @@ impl minicbor::encode::Encode for PlutusData<'_> { } } num_bigint::Sign::Minus => { - if digits.len() == 1 { - let integer = - minicbor::data::Int::try_from(-(digits[0] as i128)).unwrap(); - e.int(integer)?; - } else { - e.tag(Tag::new(3))?; - let (_sign, bytes) = n.to_bytes_be(); - encode_bytestring(e, &bytes)?; + // RFC 8949 3.4.3: a negative integer is written as -1 - n. + let argument: num::BigInt = -(**n).clone() - 1; + match u64::try_from(&argument) { + Ok(argument) => { + let integer = + minicbor::data::Int::try_from(-1 - i128::from(argument)) + .unwrap(); + e.int(integer)?; + } + Err(_) => { + e.tag(Tag::new(3))?; + let (_sign, bytes) = argument.to_bytes_be(); + encode_bytestring(e, &bytes)?; + } } } num_bigint::Sign::NoSign => { @@ -370,4 +375,31 @@ mod tests { minicbor::encode(d, &mut v).expect("invalid PlutusData"); assert_eq!(hex::encode(v), "d8799f9f0001ffff"); } + + #[test] + fn integers_at_the_64_bit_boundaries_match_the_haskell_encoding() { + let arena = crate::arena::Arena::new(); + for (value, cbor) in [ + ("-1", "20"), + ("-18446744073709551616", "3bffffffffffffffff"), + ("-18446744073709551617", "c349010000000000000000"), + ("18446744073709551615", "1bffffffffffffffff"), + ("18446744073709551616", "c249010000000000000000"), + ] { + let value: num::BigInt = value.parse().unwrap(); + let mut encoded = vec![]; + minicbor::encode(PlutusData::Integer(&value), &mut encoded) + .expect("invalid PlutusData"); + assert_eq!(hex::encode(&encoded), cbor); + assert_eq!( + PlutusData::from_cbor(&arena, &encoded).unwrap(), + &PlutusData::Integer(&value) + ); + } + // Tag 3 holds -1 - n, also for an empty payload. + assert_eq!( + PlutusData::from_cbor(&arena, &hex::decode("c340").unwrap()).unwrap(), + &PlutusData::Integer(&num::BigInt::from(-1)) + ); + } } From 5ee70cc4da311a8f237db47350f8b9418c7b3729 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 8 Oct 2026 14:12:23 +0700 Subject: [PATCH 3/9] refactor(plutus): restore the recursive Flat term encoder and decoder Signed-off-by: microproofs --- .sampo/changesets/flat-term-recursion.md | 7 + crates/nash-cli/src/cmd/test.rs | 1 + crates/nash-codegen/src/program/tests.rs | 29 ---- crates/nash-plutus/src/flat/decode/mod.rs | 171 +++++++++------------- crates/nash-plutus/src/flat/encode/mod.rs | 134 +++++++---------- crates/nash-test/src/run.rs | 1 + crates/nash-test/src/types.rs | 4 + crates/nash-test/tests/runtime.rs | 1 + docs/research/compiler-stack-audit.md | 4 + 9 files changed, 143 insertions(+), 209 deletions(-) create mode 100644 .sampo/changesets/flat-term-recursion.md diff --git a/.sampo/changesets/flat-term-recursion.md b/.sampo/changesets/flat-term-recursion.md new file mode 100644 index 00000000..a94c6ebf --- /dev/null +++ b/.sampo/changesets/flat-term-recursion.md @@ -0,0 +1,7 @@ +--- +cargo/nash-plutus: patch +cargo/nash-test: minor +cargo/nash-cli: patch +--- + +Restore the recursive Flat term encoder and decoder. `nash_test::Config` gains `worker_stack`, and `nash test` runs its workers on the compiler stack. diff --git a/crates/nash-cli/src/cmd/test.rs b/crates/nash-cli/src/cmd/test.rs index 74db9ce8..6e5d5fc6 100644 --- a/crates/nash-cli/src/cmd/test.rs +++ b/crates/nash-cli/src/cmd/test.rs @@ -173,6 +173,7 @@ impl Args { seed, max_success: self.max_success, jobs: self.jobs, + worker_stack: nash_driver::stack::COMPILER_STACK, }; let outcomes = spawn_blocking(move || nash_test::run_all(programs, &config)) .await diff --git a/crates/nash-codegen/src/program/tests.rs b/crates/nash-codegen/src/program/tests.rs index c0e74a30..c8f50216 100644 --- a/crates/nash-codegen/src/program/tests.rs +++ b/crates/nash-codegen/src/program/tests.rs @@ -543,32 +543,3 @@ fn o1_assembly_matches_snapshot_pipeline_and_preserves_traces() { assert_eq!(before.info.logs, after.info.logs); } } - -#[test] -fn deep_assembly_uses_heap_work_lists() { - std::thread::Builder::new() - .stack_size(256 * 1024) - .spawn(|| { - let arena = Arena::new(); - let b = Builder::new(&arena); - let message = b.lit(Constant::string(&arena, "tick")); - let mut core = b.int(1); - for _ in 0..4096 { - core = b.trace(message, core); - } - let mut outcomes = Vec::new(); - for level in [ - nash_config::OptimizationLevel::O0, - nash_config::OptimizationLevel::O1, - ] { - let compiled = assemble_core_with_options(&arena, core, level).unwrap(); - nash_plutus::flat::encode(compiled.program).unwrap(); - let result = compiled.program.eval(&arena); - outcomes.push((pretty::term(result.term.unwrap()), result.info.logs)); - } - assert_eq!(outcomes[0], outcomes[1]); - }) - .unwrap() - .join() - .unwrap(); -} diff --git a/crates/nash-plutus/src/flat/decode/mod.rs b/crates/nash-plutus/src/flat/decode/mod.rs index 07af2d3a..29b967d4 100644 --- a/crates/nash-plutus/src/flat/decode/mod.rs +++ b/crates/nash-plutus/src/flat/decode/mod.rs @@ -51,98 +51,77 @@ fn decode_term<'a, V>( where V: Binder<'a>, { - // Flat terms can be much deeper than the host call stack. Keep unfinished - // parents explicitly, just as the encoder does. - enum Frame<'a, V> { - Delay, - Force, - Lambda(&'a V), - Function, - Argument(&'a Term<'a, V>), - Subject, - Fields(usize, BumpVec<'a, &'a Term<'a, V>>), - Branches(&'a Term<'a, V>, BumpVec<'a, &'a Term<'a, V>>), - } + let tag = decoder.bits8(TERM_TAG_WIDTH)?; - let mut frames = Vec::new(); - 'next: loop { - let mut term = match decoder.bits8(TERM_TAG_WIDTH)? { - tag::VAR => Term::var(ctx.arena, V::var_decode(ctx.arena, decoder)?), - tag::DELAY => { - frames.push(Frame::Delay); - continue; - } - tag::LAMBDA => { - frames.push(Frame::Lambda(V::parameter_decode(ctx.arena, decoder)?)); - continue; - } - tag::APPLY => { - frames.push(Frame::Function); - continue; - } - tag::CONSTANT => Term::constant(ctx.arena, decode_constant(ctx, decoder)?), - tag::FORCE => { - frames.push(Frame::Force); - continue; - } - tag::ERROR => Term::error(ctx.arena), - tag::BUILTIN => { - let builtin_tag = decoder.bits8(BUILTIN_TAG_WIDTH)?; - Term::builtin(ctx.arena, builtin::try_from_tag(ctx.arena, builtin_tag)?) - } - tag::CONSTR => { - let tag = decoder.word()?; - let fields = BumpVec::new_in(ctx.arena.as_bump()); - if decoder.bit()? { - frames.push(Frame::Fields(tag, fields)); - continue; - } - Term::constr(ctx.arena, tag, fields.into_bump_slice()) - } - tag::CASE => { - frames.push(Frame::Subject); - continue; - } - tag => return Err(FlatDecodeError::UnknownTermConstructor(tag)), - }; - - loop { - term = match frames.pop() { - None => return Ok(term), - Some(Frame::Delay) => term.delay(ctx.arena), - Some(Frame::Force) => term.force(ctx.arena), - Some(Frame::Lambda(param)) => term.lambda(ctx.arena, param), - Some(Frame::Function) => { - frames.push(Frame::Argument(term)); - continue 'next; - } - Some(Frame::Argument(function)) => function.apply(ctx.arena, term), - Some(Frame::Subject) => { - let branches = BumpVec::new_in(ctx.arena.as_bump()); - if decoder.bit()? { - frames.push(Frame::Branches(term, branches)); - continue 'next; - } - Term::case(ctx.arena, term, branches.into_bump_slice()) - } - Some(Frame::Fields(tag, mut fields)) => { - fields.push(term); - if decoder.bit()? { - frames.push(Frame::Fields(tag, fields)); - continue 'next; - } - Term::constr(ctx.arena, tag, fields.into_bump_slice()) - } - Some(Frame::Branches(subject, mut branches)) => { - branches.push(term); - if decoder.bit()? { - frames.push(Frame::Branches(subject, branches)); - continue 'next; - } - Term::case(ctx.arena, subject, branches.into_bump_slice()) - } - }; + match tag { + // Var + tag::VAR => Ok(Term::var(ctx.arena, V::var_decode(ctx.arena, decoder)?)), + // Delay + tag::DELAY => { + let term = decode_term(ctx, decoder)?; + + Ok(term.delay(ctx.arena)) + } + // Lambda + tag::LAMBDA => { + let param = V::parameter_decode(ctx.arena, decoder)?; + + let term = decode_term(ctx, decoder)?; + + Ok(term.lambda(ctx.arena, param)) + } + // Apply + tag::APPLY => { + let function = decode_term(ctx, decoder)?; + let argument = decode_term(ctx, decoder)?; + + let term = function.apply(ctx.arena, argument); + + Ok(term) + } + // Constant + tag::CONSTANT => { + let constant = decode_constant(ctx, decoder)?; + + Ok(Term::constant(ctx.arena, constant)) } + // Force + tag::FORCE => { + let term = decode_term(ctx, decoder)?; + + Ok(term.force(ctx.arena)) + } + // Error + tag::ERROR => Ok(Term::error(ctx.arena)), + // Builtin + tag::BUILTIN => { + let builtin_tag = decoder.bits8(BUILTIN_TAG_WIDTH)?; + + let function = builtin::try_from_tag(ctx.arena, builtin_tag)?; + + let term = Term::builtin(ctx.arena, function); + + Ok(term) + } + // Constr + tag::CONSTR => { + let tag = decoder.word()?; + let fields = decoder.list_with(ctx, decode_term)?; + let fields = ctx.arena.alloc(fields); + + let term = Term::constr(ctx.arena, tag, fields); + + Ok(term) + } + // Case + tag::CASE => { + let constr = decode_term(ctx, decoder)?; + let branches = decoder.list_with(ctx, decode_term)?; + let branches = ctx.arena.alloc(branches); + + Ok(Term::case(ctx.arena, constr, branches)) + } + _ => Err(FlatDecodeError::UnknownTermConstructor(tag)), } } @@ -403,22 +382,12 @@ mod tests { use hex; use num::BigInt; - #[test] - fn deeply_nested_terms_decode_without_call_stack_growth() { - let arena = Arena::new(); - let mut bytes = vec![1, 1, 0]; - bytes.extend(std::iter::repeat_n(0x11, 10_000)); - bytes.push(0x61); - let program: &Program = decode(&arena, &bytes).unwrap(); - assert_eq!(crate::flat::encode(program).unwrap(), bytes); - } - #[test] fn nested_application_and_case_lists_roundtrip() { let arena = Arena::new(); let leaf = Term::::error(&arena); let mut term = leaf; - for _ in 0..5_000 { + for _ in 0..16 { let fields = arena.alloc([term, leaf]); let subject = Term::constr(&arena, 3, fields); let branches = arena.alloc([leaf, leaf]); diff --git a/crates/nash-plutus/src/flat/encode/mod.rs b/crates/nash-plutus/src/flat/encode/mod.rs index dc2c2558..8110ff0c 100644 --- a/crates/nash-plutus/src/flat/encode/mod.rs +++ b/crates/nash-plutus/src/flat/encode/mod.rs @@ -44,70 +44,62 @@ fn encode_term<'a, V>(encoder: &mut Encoder, term: &'a Term<'a, V>) -> Result<() where V: Binder<'a>, { - enum Work<'a, V> { - Term(&'a Term<'a, V>), - List(&'a [&'a Term<'a, V>]), - } + match term { + Term::Var(name) => { + encode_term_tag(encoder, tag::VAR)?; - let mut pending = vec![Work::Term(term)]; - while let Some(work) = pending.pop() { - let term = match work { - Work::Term(term) => term, - Work::List(items) => { - if let Some((first, rest)) = items.split_first() { - encoder.one(); - pending.push(Work::List(rest)); - pending.push(Work::Term(first)); - } else { - encoder.zero(); - } - continue; - } - }; - match term { - Term::Var(name) => { - encode_term_tag(encoder, tag::VAR)?; - name.var_encode(encoder)?; - } - Term::Lambda { parameter, body } => { - encode_term_tag(encoder, tag::LAMBDA)?; - parameter.parameter_encode(encoder)?; - pending.push(Work::Term(body)); - } - Term::Apply { function, argument } => { - encode_term_tag(encoder, tag::APPLY)?; - pending.push(Work::Term(argument)); - pending.push(Work::Term(function)); - } - Term::Delay(body) => { - encode_term_tag(encoder, tag::DELAY)?; - pending.push(Work::Term(body)); - } - Term::Force(body) => { - encode_term_tag(encoder, tag::FORCE)?; - pending.push(Work::Term(body)); - } - Term::Case { constr, branches } => { - encode_term_tag(encoder, tag::CASE)?; - pending.push(Work::List(branches)); - pending.push(Work::Term(constr)); - } - Term::Constr { tag, fields } => { - encode_term_tag(encoder, tag::CONSTR)?; - encoder.word(*tag); - pending.push(Work::List(fields)); - } - Term::Constant(c) => { - encode_term_tag(encoder, tag::CONSTANT)?; - encode_constant(encoder, c)?; - } - Term::Builtin(b) => { - encode_term_tag(encoder, tag::BUILTIN)?; - encoder.bits(tag::BUILTIN_TAG_WIDTH as i64, **b as u8); - } - Term::Error => { - encode_term_tag(encoder, tag::ERROR)?; - } + name.var_encode(encoder)?; + } + Term::Lambda { parameter, body } => { + encode_term_tag(encoder, tag::LAMBDA)?; + + parameter.parameter_encode(encoder)?; + + encode_term(encoder, body)?; + } + Term::Apply { function, argument } => { + encode_term_tag(encoder, tag::APPLY)?; + + encode_term(encoder, function)?; + + encode_term(encoder, argument)?; + } + Term::Delay(body) => { + encode_term_tag(encoder, tag::DELAY)?; + + encode_term(encoder, body)?; + } + Term::Force(body) => { + encode_term_tag(encoder, tag::FORCE)?; + + encode_term(encoder, body)?; + } + Term::Case { constr, branches } => { + encode_term_tag(encoder, tag::CASE)?; + + encode_term(encoder, constr)?; + + encoder.list_with(branches, |e, t| encode_term(e, t))?; + } + Term::Constr { tag, fields } => { + encode_term_tag(encoder, tag::CONSTR)?; + + encoder.word(*tag); + + encoder.list_with(fields, |e, t| encode_term(e, t))?; + } + Term::Constant(c) => { + encode_term_tag(encoder, tag::CONSTANT)?; + + encode_constant(encoder, c)?; + } + Term::Builtin(b) => { + encode_term_tag(encoder, tag::BUILTIN)?; + + encoder.bits(tag::BUILTIN_TAG_WIDTH as i64, **b as u8); + } + Term::Error => { + encode_term_tag(encoder, tag::ERROR)?; } } @@ -318,22 +310,6 @@ mod tests { use crate::binder::DeBruijn; use crate::flat::decode; - #[test] - fn deeply_nested_delays_encode_without_call_stack_growth() { - let arena = Arena::new(); - let mut term = Term::::error(&arena); - for _ in 0..20_000 { - term = term.delay(&arena); - } - let program = Program::new(&arena, crate::program::Version::plutus_v3(&arena), term); - let bytes = encode(program).unwrap(); - // Version 1.1.0, 20,000 four-bit delay tags, then error and filler. - let mut expected = vec![1, 1, 0]; - expected.extend(std::iter::repeat_n(0x11, 10_000)); - expected.push(0x61); - assert_eq!(bytes, expected); - } - #[test] fn cbor_wraps_flat_bytes_once() { let arena = Arena::new(); diff --git a/crates/nash-test/src/run.rs b/crates/nash-test/src/run.rs index 433a4f1c..13e96be1 100644 --- a/crates/nash-test/src/run.rs +++ b/crates/nash-test/src/run.rs @@ -15,6 +15,7 @@ pub fn run_all(tests: Vec, config: &Config) -> Vec { }; match rayon::ThreadPoolBuilder::new() .num_threads(config.jobs.max(1)) + .stack_size(config.worker_stack) .build() { Ok(pool) => pool.install(run), diff --git a/crates/nash-test/src/types.rs b/crates/nash-test/src/types.rs index e8250d29..1355e0b4 100644 --- a/crates/nash-test/src/types.rs +++ b/crates/nash-test/src/types.rs @@ -38,6 +38,8 @@ pub struct Config { pub seed: u32, pub max_success: usize, pub jobs: usize, + /// Stack size of each worker thread; the workers decode programs. + pub worker_stack: usize, } impl Default for Config { fn default() -> Self { @@ -45,6 +47,8 @@ impl Default for Config { seed: 0, max_success: 100, jobs: 1, + // Rust's default thread stack. + worker_stack: 2 * 1024 * 1024, } } } diff --git a/crates/nash-test/tests/runtime.rs b/crates/nash-test/tests/runtime.rs index a1b79516..96baa752 100644 --- a/crates/nash-test/tests/runtime.rs +++ b/crates/nash-test/tests/runtime.rs @@ -101,6 +101,7 @@ fn run(test: TestProgram) -> Outcome { seed: 42, max_success: 5, jobs: 1, + ..Config::default() }, ) .remove(0) diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index a69dc23d..878d24e7 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -14,6 +14,10 @@ The sections below are the record of 2026-09-29. The candidates and the follow-up order are not planned work. The work stacks under "Reproduced failures" stay until a later change converts them back to recursion. +Converted back so far: Flat term encoding and decoding (`encode_term`, +`decode_term`) recurse again, as in the imported code. `nash test` decodes +programs on its worker pool, so the CLI gives those workers the compiler stack. + Default O1 exposed a stack overflow in the driver `ledger_contexts` fixture. This audit distinguishes reproduced failures from recursive paths that still need depth regressions. Passing the fixture does not establish a universal From f4c2c37e46430ad7c830a884bb8b0179a18c7bb1 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 8 Oct 2026 14:28:20 +0700 Subject: [PATCH 4/9] refactor(plutus): restore the recursive UPLC printer, De Bruijn conversion, and target validation Signed-off-by: microproofs --- .sampo/changesets/uplc-support-recursion.md | 5 + crates/nash-plutus/src/debruijn.rs | 121 +++++-------- crates/nash-plutus/src/pretty.rs | 183 ++++++-------------- crates/nash-plutus/src/script.rs | 119 +++++++------ docs/research/compiler-stack-audit.md | 2 + 5 files changed, 163 insertions(+), 267 deletions(-) create mode 100644 .sampo/changesets/uplc-support-recursion.md diff --git a/.sampo/changesets/uplc-support-recursion.md b/.sampo/changesets/uplc-support-recursion.md new file mode 100644 index 00000000..85e7955e --- /dev/null +++ b/.sampo/changesets/uplc-support-recursion.md @@ -0,0 +1,5 @@ +--- +cargo/nash-plutus: patch +--- + +Restore the recursive UPLC term printer, De Bruijn conversion, and target validation. diff --git a/crates/nash-plutus/src/debruijn.rs b/crates/nash-plutus/src/debruijn.rs index a951fa28..eea24b3d 100644 --- a/crates/nash-plutus/src/debruijn.rs +++ b/crates/nash-plutus/src/debruijn.rs @@ -13,81 +13,50 @@ pub fn to_debruijn<'a>( arena: &'a Arena, term: &'a Term<'a, Name<'a>>, ) -> Result<&'a Term<'a, DeBruijn>, FreeVariable<'a>> { - enum Task<'a> { - Visit(&'a Term<'a, Name<'a>>), - Finish(&'a Term<'a, Name<'a>>, usize), - } - let mut pending = vec![Task::Visit(term)]; - let mut scope = Vec::new(); - let mut results: Vec<&'a Term<'a, DeBruijn>> = Vec::new(); - while let Some(task) = pending.pop() { - match task { - Task::Visit(term) => { - pending.push(Task::Finish(term, results.len())); - match term { - Term::Lambda { parameter, body } => { - scope.push(parameter.unique()); - pending.push(Task::Visit(body)); - } - Term::Apply { function, argument } => { - pending.push(Task::Visit(argument)); - pending.push(Task::Visit(function)); - } - Term::Delay(body) | Term::Force(body) => pending.push(Task::Visit(body)), - Term::Constr { fields, .. } => { - pending.extend(fields.iter().rev().map(|t| Task::Visit(t))) - } - Term::Case { constr, branches } => { - pending.extend(branches.iter().rev().map(|t| Task::Visit(t))); - pending.push(Task::Visit(constr)); - } - _ => {} - } - } - Task::Finish(term, start) => { - let mut children = results.drain(start..); - let result = match term { - Term::Var(name) => { - let position = scope - .iter() - .rposition(|id| *id == name.unique()) - .ok_or(FreeVariable(name))?; - Term::var(arena, DeBruijn::new(arena, scope.len() - position)) - } - Term::Lambda { .. } => { - scope.pop(); - children - .next() - .unwrap() - .lambda(arena, DeBruijn::zero(arena)) - } - Term::Apply { .. } => children - .next() - .unwrap() - .apply(arena, children.next().unwrap()), - Term::Delay(_) => children.next().unwrap().delay(arena), - Term::Force(_) => children.next().unwrap().force(arena), - Term::Constant(c) => Term::constant(arena, c), - Term::Builtin(f) => Term::builtin(arena, f), - Term::Error => Term::error(arena), - Term::Constr { tag, .. } => Term::constr( - arena, - *tag, - arena.alloc_slice_copy(&children.by_ref().collect::>()), - ), - Term::Case { .. } => { - let constr = children.next().unwrap(); - Term::case( - arena, - constr, - arena.alloc_slice_copy(&children.by_ref().collect::>()), - ) - } - }; - drop(children); - results.push(result); - } + convert(arena, &mut Vec::new(), term) +} + +fn convert<'a>( + arena: &'a Arena, + scope: &mut Vec, + term: &'a Term<'a, Name<'a>>, +) -> Result<&'a Term<'a, DeBruijn>, FreeVariable<'a>> { + Ok(match term { + Term::Var(name) => { + let position = scope + .iter() + .rposition(|unique| *unique == name.unique()) + .ok_or(FreeVariable(name))?; + Term::var(arena, DeBruijn::new(arena, scope.len() - position)) + } + Term::Lambda { parameter, body } => { + scope.push(parameter.unique()); + let result = convert(arena, scope, body); + scope.pop(); + result?.lambda(arena, DeBruijn::zero(arena)) + } + Term::Apply { function, argument } => { + convert(arena, scope, function)?.apply(arena, convert(arena, scope, argument)?) + } + Term::Delay(t) => convert(arena, scope, t)?.delay(arena), + Term::Force(t) => convert(arena, scope, t)?.force(arena), + Term::Constant(c) => Term::constant(arena, c), + Term::Builtin(f) => Term::builtin(arena, f), + Term::Error => Term::error(arena), + Term::Constr { tag, fields } => { + let fields = fields + .iter() + .map(|f| convert(arena, scope, f)) + .collect::, _>>()?; + Term::constr(arena, *tag, arena.alloc_slice_copy(&fields)) + } + Term::Case { constr, branches } => { + let constr = convert(arena, scope, constr)?; + let branches = branches + .iter() + .map(|b| convert(arena, scope, b)) + .collect::, _>>()?; + Term::case(arena, constr, arena.alloc_slice_copy(&branches)) } - } - Ok(results.pop().unwrap()) + }) } diff --git a/crates/nash-plutus/src/pretty.rs b/crates/nash-plutus/src/pretty.rs index 9b1522a1..2fa98bc7 100644 --- a/crates/nash-plutus/src/pretty.rs +++ b/crates/nash-plutus/src/pretty.rs @@ -70,112 +70,63 @@ pub fn program(program: &Program<'_, V>) -> String { ) } pub fn term(term: &Term<'_, V>) -> String { - enum Frame<'a, 't, V> { - Visit(&'t Term<'a, V>), - Form { - head: String, - start: usize, - apply: bool, - }, - EndLambda { - name: String, - start: usize, - }, - } - let mut pending = vec![Frame::Visit(term)]; - let mut scope: Vec<(Option, String)> = Vec::new(); - let mut rendered = Vec::new(); - while let Some(frame) = pending.pop() { - match frame { - Frame::Form { head, start, apply } => { - let children = rendered.split_off(start); - rendered.push(form(&head, children, apply)); + print_term(term, &mut Vec::new()) +} +fn print_term( + term: &Term<'_, V>, + scope: &mut Vec<(Option, String)>, +) -> String { + match term { + Term::Var(v) => { + if let Some(index) = v.index() { + return if index > 0 && index <= scope.len() { + scope[scope.len() - index].1.clone() + } else { + format!("free_i{index}") + }; + } + if let Some(unique) = v.unique() + && let Some((_, label)) = scope.iter().rev().find(|(id, _)| *id == Some(unique)) + { + return label.clone(); } - Frame::EndLambda { name, start } => { - scope.pop(); - let children = rendered.split_off(start); - rendered.push(form(&format!("lam {name}"), children, false)); + let mut name = String::new(); + v.write(&mut name); + name + } + Term::Lambda { parameter, body } => { + let mut name = String::new(); + if parameter.index().is_some() { + write!(name, "i{}", scope.len()).unwrap(); + } else { + parameter.write(&mut name); } - Frame::Visit(term) => match term { - Term::Var(v) => { - let name = if let Some(index) = v.index() { - if index > 0 && index <= scope.len() { - scope[scope.len() - index].1.clone() - } else { - format!("free_i{index}") - } - } else if let Some(unique) = v.unique() - && let Some((_, label)) = - scope.iter().rev().find(|(id, _)| *id == Some(unique)) - { - label.clone() - } else { - let mut name = String::new(); - v.write(&mut name); - name - }; - rendered.push(name); - } - Term::Lambda { parameter, body } => { - let mut name = String::new(); - if parameter.index().is_some() { - write!(name, "i{}", scope.len()).unwrap(); - } else { - parameter.write(&mut name); - } - scope.push((parameter.unique(), name.clone())); - pending.push(Frame::EndLambda { - name, - start: rendered.len(), - }); - pending.push(Frame::Visit(body)); - } - Term::Apply { function, argument } => { - pending.push(Frame::Form { - head: String::new(), - start: rendered.len(), - apply: true, - }); - pending.push(Frame::Visit(argument)); - pending.push(Frame::Visit(function)); - } - Term::Delay(t) | Term::Force(t) => { - let head = if matches!(term, Term::Delay(_)) { - "delay" - } else { - "force" - }; - pending.push(Frame::Form { - head: head.into(), - start: rendered.len(), - apply: false, - }); - pending.push(Frame::Visit(t)); - } - Term::Constant(c) => rendered.push(constant(c)), - Term::Builtin(f) => rendered.push(format!("(builtin {})", builtin(**f))), - Term::Error => rendered.push("(error)".into()), - Term::Constr { tag, fields } => { - pending.push(Frame::Form { - head: format!("constr {tag}"), - start: rendered.len(), - apply: false, - }); - pending.extend(fields.iter().rev().map(|t| Frame::Visit(t))); - } - Term::Case { constr, branches } => { - pending.push(Frame::Form { - head: "case".into(), - start: rendered.len(), - apply: false, - }); - pending.extend(branches.iter().rev().map(|t| Frame::Visit(t))); - pending.push(Frame::Visit(constr)); - } - }, + scope.push((parameter.unique(), name.clone())); + let body = print_term(body, scope); + scope.pop(); + form(&format!("lam {name}"), vec![body], false) + } + Term::Apply { function, argument } => form( + "", + vec![print_term(function, scope), print_term(argument, scope)], + true, + ), + Term::Delay(t) => form("delay", vec![print_term(t, scope)], false), + Term::Force(t) => form("force", vec![print_term(t, scope)], false), + Term::Constant(c) => constant(c), + Term::Builtin(f) => format!("(builtin {})", builtin(**f)), + Term::Error => "(error)".into(), + Term::Constr { tag, fields } => form( + &format!("constr {tag}"), + fields.iter().map(|t| print_term(t, scope)).collect(), + false, + ), + Term::Case { constr, branches } => { + let mut children = vec![print_term(constr, scope)]; + children.extend(branches.iter().map(|t| print_term(t, scope))); + form("case", children, false) } } - rendered.pop().unwrap() } fn form(head: &str, children: Vec, apply: bool) -> String { let (open, close) = if apply { ('[', ']') } else { ('(', ')') }; @@ -458,31 +409,3 @@ pub fn builtin(function: DefaultFunction) -> &'static str { DefaultFunction::ScaleValue => "scaleValue", } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deep_terms_print_on_a_small_stack() { - std::thread::Builder::new() - .stack_size(128 * 1024) - .spawn(|| { - let arena = Arena::new(); - let depth = 1024; - let mut root = Term::::error(&arena); - for _ in 0..depth { - root = root.delay(&arena); - } - let printed = term(root); - // Check the traversal preserves every node without parsing the - // deep output through another recursive consumer. - assert_eq!(printed.matches("(delay").count(), depth); - assert_eq!(printed.matches("(error)").count(), 1); - assert_eq!(printed.matches(')').count(), depth + 1); - }) - .unwrap() - .join() - .unwrap(); - } -} diff --git a/crates/nash-plutus/src/script.rs b/crates/nash-plutus/src/script.rs index e80b65a0..189a0aab 100644 --- a/crates/nash-plutus/src/script.rs +++ b/crates/nash-plutus/src/script.rs @@ -56,84 +56,81 @@ fn validate_term( version: PlutusVersion, uplc_110: bool, ) -> Result<(), TargetError> { - let mut pending = vec![term]; - while let Some(term) = pending.pop() { - match term { - Term::Lambda { body, .. } | Term::Delay(body) | Term::Force(body) => { - pending.push(*body) + match term { + Term::Lambda { body, .. } | Term::Delay(body) | Term::Force(body) => { + validate_term(body, version, uplc_110)? + } + Term::Apply { function, argument } => { + validate_term(function, version, uplc_110)?; + validate_term(argument, version, uplc_110)?; + } + Term::Constr { fields, .. } => { + if !uplc_110 { + return Err(error(version, "constr (requires UPLC 1.1.0)")); } - Term::Apply { function, argument } => { - pending.push(*argument); - pending.push(*function); + for term in *fields { + validate_term(term, version, uplc_110)?; } - Term::Constr { fields, .. } => { - if !uplc_110 { - return Err(error(version, "constr (requires UPLC 1.1.0)")); - } - pending.extend(fields.iter().rev().copied()); + } + Term::Case { constr, branches } => { + if !uplc_110 { + return Err(error(version, "case (requires UPLC 1.1.0)")); } - Term::Case { constr, branches } => { - if !uplc_110 { - return Err(error(version, "case (requires UPLC 1.1.0)")); - } - pending.extend(branches.iter().rev().copied()); - pending.push(*constr); + validate_term(constr, version, uplc_110)?; + for term in *branches { + validate_term(term, version, uplc_110)?; } - Term::Builtin(fun) => { - let tag = **fun as u8; - // Flat tags 0..=100 are exactly upstream batches 1–6. Keep this - // bound fixed: future runtime builtins must not become ledger-valid - // merely because they were added to DefaultFunction. - let allowed = tag <= 100; - if !allowed { - return Err(error(version, format!("builtin {fun:?}"))); - } + } + Term::Builtin(fun) => { + let tag = **fun as u8; + // Flat tags 0..=100 are exactly upstream batches 1–6. Keep this + // bound fixed: future runtime builtins must not become ledger-valid + // merely because they were added to DefaultFunction. + let allowed = tag <= 100; + if !allowed { + return Err(error(version, format!("builtin {fun:?}"))); } - Term::Constant(constant) => validate_constant(constant, version)?, - Term::Var(_) | Term::Error => {} } + Term::Constant(constant) => validate_constant(constant, version)?, + Term::Var(_) | Term::Error => {} } Ok(()) } fn validate_type(typ: &Type<'_>, version: PlutusVersion) -> Result<(), TargetError> { - let mut pending = vec![typ]; - while let Some(typ) = pending.pop() { - match typ { - Type::List(t) | Type::Array(t) => pending.push(*t), - Type::Pair(a, b) => { - pending.push(*b); - pending.push(*a); - } - // Nash cannot encode BLS constant types (including empty containers). - Type::Bls12_381G1Element | Type::Bls12_381G2Element | Type::Bls12_381MlResult => { - return Err(error(version, format!("constant type {typ:?}"))); - } - _ => {} + match typ { + Type::List(t) | Type::Array(t) => validate_type(t, version)?, + Type::Pair(a, b) => { + validate_type(a, version)?; + validate_type(b, version)?; } + // Nash cannot encode BLS constant types (including empty containers). + Type::Bls12_381G1Element | Type::Bls12_381G2Element | Type::Bls12_381MlResult => { + return Err(error(version, format!("constant type {typ:?}"))); + } + _ => {} } Ok(()) } fn validate_constant(constant: &Constant<'_>, version: PlutusVersion) -> Result<(), TargetError> { - let mut pending = vec![constant]; - while let Some(constant) = pending.pop() { - match constant { - Constant::ProtoList(t, values) | Constant::ProtoArray(t, values) => { - validate_type(t, version)?; - pending.extend(values.iter().rev().copied()); - } - Constant::ProtoPair(a, b, x, y) => { - validate_type(a, version)?; - validate_type(b, version)?; - pending.push(*y); - pending.push(*x); - } - Constant::Bls12_381G1Element(_) - | Constant::Bls12_381G2Element(_) - | Constant::Bls12_381MlResult(_) => { - return Err(error(version, "runtime-only BLS constant")); + match constant { + Constant::ProtoList(t, values) | Constant::ProtoArray(t, values) => { + validate_type(t, version)?; + for value in *values { + validate_constant(value, version)?; } - _ => {} } + Constant::ProtoPair(a, b, x, y) => { + validate_type(a, version)?; + validate_type(b, version)?; + validate_constant(x, version)?; + validate_constant(y, version)?; + } + Constant::Bls12_381G1Element(_) + | Constant::Bls12_381G2Element(_) + | Constant::Bls12_381MlResult(_) => { + return Err(error(version, "runtime-only BLS constant")); + } + _ => {} } Ok(()) } diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index 878d24e7..2a3963a1 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -17,6 +17,8 @@ failures" stay until a later change converts them back to recursion. Converted back so far: Flat term encoding and decoding (`encode_term`, `decode_term`) recurse again, as in the imported code. `nash test` decodes programs on its worker pool, so the CLI gives those workers the compiler stack. +The UPLC term printer, De Bruijn conversion, and target validation +(`pretty::term`, `to_debruijn`, `validate_*`) recurse again. Default O1 exposed a stack overflow in the driver `ledger_contexts` fixture. This audit distinguishes reproduced failures from recursive paths that still From 056ee2551fc4e5518b1c2a0e8bda0944db7cc50b Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 8 Oct 2026 14:37:39 +0700 Subject: [PATCH 5/9] refactor(ir): restore the recursive Core traversals Signed-off-by: microproofs --- .sampo/changesets/core-traversal-recursion.md | 5 + crates/nash-ir/src/analysis.rs | 370 ++++++++-------- crates/nash-ir/src/anf.rs | 204 ++++----- crates/nash-ir/src/beta.rs | 24 +- crates/nash-ir/src/core.rs | 55 ++- crates/nash-ir/src/hygiene.rs | 399 ++++++------------ crates/nash-ir/src/pretty.rs | 320 ++++++-------- crates/nash-ir/src/traverse.rs | 58 +-- crates/nash-ir/src/traverse/tests.rs | 32 -- docs/research/compiler-stack-audit.md | 4 +- 10 files changed, 636 insertions(+), 835 deletions(-) create mode 100644 .sampo/changesets/core-traversal-recursion.md diff --git a/.sampo/changesets/core-traversal-recursion.md b/.sampo/changesets/core-traversal-recursion.md new file mode 100644 index 00000000..ded242d4 --- /dev/null +++ b/.sampo/changesets/core-traversal-recursion.md @@ -0,0 +1,5 @@ +--- +cargo/nash-ir: patch +--- + +Restore the recursive Core traversals: `walk`, `map`, occurrence and free-variable analysis, discard checks, hygiene, ANF, beta splicing, and Core printing. diff --git a/crates/nash-ir/src/analysis.rs b/crates/nash-ir/src/analysis.rs index fc8b482f..de556fb1 100644 --- a/crates/nash-ir/src/analysis.rs +++ b/crates/nash-ir/src/analysis.rs @@ -71,117 +71,114 @@ impl<'a> Collector<'a> { self.scope.push(id); } + fn within(&mut self, boundary: Boundary, body: &Core<'a>) { + self.boundaries.push(boundary); + self.visit(body); + self.boundaries.pop(); + } + fn visit(&mut self, core: &Core<'a>) { - enum Task<'t, 'a> { - Visit(&'t Core<'a>), - Bind(Binder<'a>), - Boundary(Boundary), - Restore(usize, usize), - } - let mut pending = vec![Task::Visit(core)]; - let mut children = Vec::new(); - while let Some(task) = pending.pop() { - let core = match task { - Task::Visit(core) => core, - Task::Bind(binder) => { - self.bind(binder); - continue; + let node = self.next_node; + self.next_node += 1; + let depth = self.scope.len(); + match &core.kind { + CoreKind::Var(name) => { + let binding = self + .scope + .iter() + .rev() + .copied() + .find(|&id| self.report.bindings[id].name.unique == name.unique); + self.report.uses.push(Occurrence { + name: *name, + binding, + node, + scope: self.scope.clone(), + execution_scope: self.boundaries.clone(), + }); + } + CoreKind::Lam { params, body } => { + if params.is_empty() { + self.visit(body); + } else { + self.boundaries.push(Boundary::Lambda(node)); + for p in *params { + self.bind(*p); + } + self.visit(body); + self.boundaries.pop(); } - Task::Boundary(boundary) => { - self.boundaries.push(boundary); - continue; + } + CoreKind::Let { + binder, + value, + body, + } => { + self.visit(value); + self.bind(*binder); + self.visit(body); + } + CoreKind::LetRec { binders, body } => { + for rb in *binders { + self.bind(rb.binder); } - Task::Restore(scope, boundaries) => { - self.scope.truncate(scope); - self.boundaries.truncate(boundaries); - continue; + let group_depth = self.scope.len(); + for (index, rb) in binders.iter().enumerate() { + self.boundaries + .push(Boundary::RecursiveBody { node, index }); + for p in rb.params { + self.bind(*p); + } + self.visit(rb.body); + self.boundaries.pop(); + self.scope.truncate(group_depth); } - }; - let node = self.next_node; - self.next_node += 1; - let depth = self.scope.len(); - let boundaries = self.boundaries.len(); - pending.push(Task::Restore(depth, boundaries)); - match &core.kind { - CoreKind::Var(name) => { - let binding = self - .scope - .iter() - .rev() - .copied() - .find(|&id| self.report.bindings[id].name.unique == name.unique); - self.report.uses.push(Occurrence { - name: *name, - binding, + self.visit(body); + } + CoreKind::Case { + scrutinee, + branches, + default, + .. + } => { + self.visit(scrutinee); + for (index, branch) in branches.iter().enumerate() { + self.boundaries.push(Boundary::Branch { node, - scope: self.scope.clone(), - execution_scope: self.boundaries.clone(), + index: Some(index), }); - } - CoreKind::Lam { params, body } => { - if !params.is_empty() { - self.boundaries.push(Boundary::Lambda(node)); - } - for param in *params { - self.bind(*param); - } - pending.push(Task::Visit(body)); - } - CoreKind::Let { - binder, - value, - body, - } => { - pending.push(Task::Visit(body)); - pending.push(Task::Bind(*binder)); - pending.push(Task::Visit(value)); - } - CoreKind::LetRec { binders, body } => { - for rb in *binders { - self.bind(rb.binder); - } - let group_depth = self.scope.len(); - pending.push(Task::Visit(body)); - for (index, rb) in binders.iter().enumerate().rev() { - pending.push(Task::Restore(group_depth, boundaries)); - pending.push(Task::Visit(rb.body)); - pending.extend(rb.params.iter().rev().map(|p| Task::Bind(*p))); - pending.push(Task::Boundary(Boundary::RecursiveBody { node, index })); + for p in branch.binders { + self.bind(*p); } + self.visit(branch.body); + self.boundaries.pop(); + self.scope.truncate(depth); } - CoreKind::Case { - scrutinee, - branches, - default, - .. - } => { - if let Some(body) = default { - pending.push(Task::Restore(depth, boundaries)); - pending.push(Task::Visit(body)); - pending.push(Task::Boundary(Boundary::Branch { node, index: None })); - } - for (index, branch) in branches.iter().enumerate().rev() { - pending.push(Task::Restore(depth, boundaries)); - pending.push(Task::Visit(branch.body)); - pending.extend(branch.binders.iter().rev().map(|p| Task::Bind(*p))); - pending.push(Task::Boundary(Boundary::Branch { - node, - index: Some(index), - })); - } - pending.push(Task::Visit(scrutinee)); + if let Some(body) = default { + self.within(Boundary::Branch { node, index: None }, body); } - CoreKind::Delay(body) => { - self.boundaries.push(Boundary::Delay(node)); - pending.push(Task::Visit(body)); + } + CoreKind::App { func, args } => { + self.visit(func); + for arg in *args { + self.visit(arg); } - _ => { - children.clear(); - core.push_children_reversed(&mut children); - pending.extend(children.iter().map(|child| Task::Visit(child))); + } + CoreKind::Constr { fields: args, .. } | CoreKind::Builtin { args, .. } => { + for arg in *args { + self.visit(arg); } } + CoreKind::Field { record, .. } => self.visit(record), + CoreKind::Trace { message, body } => { + self.visit(message); + self.visit(body); + } + CoreKind::Delay(body) => self.within(Boundary::Delay(node), body), + CoreKind::Force(body) => self.visit(body), + CoreKind::Lit(_) | CoreKind::Error => {} } + self.scope.truncate(depth); } } @@ -190,27 +187,22 @@ impl<'a> Collector<'a> { /// in the surrounding environment. Does not prove duplication or motion safe. /// Saturated builtins and arbitrary calls are deliberately not evaluated here. pub fn safe_to_discard(core: &Core<'_>) -> bool { - let mut pending = vec![core]; - while let Some(core) = pending.pop() { - match &core.kind { - CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Delay(_) => {} - CoreKind::Lam { params, body } => { - if params.is_empty() { - pending.push(body); - } - } - CoreKind::Builtin { func, args } if args.len() < func.arity() => { - pending.extend(args.iter().rev().copied()) - } - CoreKind::Constr { fields, .. } => pending.extend(fields.iter().rev().copied()), - CoreKind::Let { value, body, .. } => { - pending.push(body); - pending.push(value); - } - _ => return false, + match &core.kind { + CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Delay(_) => true, + CoreKind::Lam { params, body } => !params.is_empty() || safe_to_discard(body), + CoreKind::Builtin { func, args } => { + args.len() < func.arity() && args.iter().all(|a| safe_to_discard(a)) } + CoreKind::Constr { fields, .. } => fields.iter().all(|a| safe_to_discard(a)), + CoreKind::Let { value, body, .. } => safe_to_discard(value) && safe_to_discard(body), + CoreKind::App { .. } + | CoreKind::LetRec { .. } + | CoreKind::Case { .. } + | CoreKind::Field { .. } + | CoreKind::Trace { .. } + | CoreKind::Error + | CoreKind::Force(_) => false, } - true } /// Structural size only. Counts repeated subtree occurrences, includes LetRec, @@ -240,89 +232,87 @@ pub fn size_estimate(core: &Core<'_>) -> CoreSize { /// Free runtime names in deterministic source traversal order. Name identity /// follows the unique number, exactly as UPLC De Bruijn conversion does. pub fn free_variables<'a>(core: &Core<'a>) -> Vec> { - enum Task<'t, 'a> { - Visit(&'t Core<'a>), - Bind(u32), - Restore(usize), - } - let mut pending = vec![Task::Visit(core)]; - let mut scope = Vec::new(); - let mut seen = HashSet::new(); let mut result = Vec::new(); - let mut children = Vec::new(); - while let Some(task) = pending.pop() { - let core = match task { - Task::Visit(core) => core, - Task::Bind(id) => { - scope.push(id); - continue; - } - Task::Restore(depth) => { - scope.truncate(depth); - continue; + free(core, &mut Vec::new(), &mut HashSet::new(), &mut result); + result +} +fn free<'a>( + core: &Core<'a>, + scope: &mut Vec, + seen: &mut HashSet, + out: &mut Vec>, +) { + let depth = scope.len(); + match &core.kind { + CoreKind::Var(name) => { + if !scope.contains(&name.unique) && seen.insert(name.unique) { + out.push(*name); } - }; - let depth = scope.len(); - pending.push(Task::Restore(depth)); - match &core.kind { - CoreKind::Var(name) => { - if !scope.contains(&name.unique) && seen.insert(name.unique) { - result.push(*name); - } + } + CoreKind::Lam { params, body } => { + scope.extend(params.iter().map(|p| p.name.unique)); + free(body, scope, seen, out); + } + CoreKind::App { func, args } => { + free(func, scope, seen, out); + for arg in *args { + free(arg, scope, seen, out); } - CoreKind::Lam { params, body } => { - scope.extend(params.iter().map(|p| p.name.unique)); - pending.push(Task::Visit(body)); + } + CoreKind::Let { + binder, + value, + body, + } => { + free(value, scope, seen, out); + scope.push(binder.name.unique); + free(body, scope, seen, out); + } + CoreKind::LetRec { binders, body } => { + scope.extend(binders.iter().map(|rb| rb.binder.name.unique)); + let group_depth = scope.len(); + for rb in *binders { + scope.extend(rb.params.iter().map(|p| p.name.unique)); + free(rb.body, scope, seen, out); + scope.truncate(group_depth); } - CoreKind::Let { - binder, - value, - body, - } => { - pending.push(Task::Visit(body)); - pending.push(Task::Bind(binder.name.unique)); - pending.push(Task::Visit(value)); + free(body, scope, seen, out); + } + CoreKind::Case { + scrutinee, + branches, + default, + .. + } => { + free(scrutinee, scope, seen, out); + for branch in *branches { + scope.extend(branch.binders.iter().map(|p| p.name.unique)); + free(branch.body, scope, seen, out); + scope.truncate(depth); } - CoreKind::LetRec { binders, body } => { - scope.extend(binders.iter().map(|b| b.binder.name.unique)); - let group_depth = scope.len(); - pending.push(Task::Visit(body)); - for rb in binders.iter().rev() { - pending.push(Task::Restore(group_depth)); - pending.push(Task::Visit(rb.body)); - pending.extend(rb.params.iter().rev().map(|p| Task::Bind(p.name.unique))); - } + if let Some(body) = default { + free(body, scope, seen, out); } - CoreKind::Case { - scrutinee, - branches, - default, - .. - } => { - if let Some(body) = default { - pending.push(Task::Visit(body)); - } - for branch in branches.iter().rev() { - pending.push(Task::Restore(depth)); - pending.push(Task::Visit(branch.body)); - pending.extend( - branch - .binders - .iter() - .rev() - .map(|p| Task::Bind(p.name.unique)), - ); - } - pending.push(Task::Visit(scrutinee)); + } + CoreKind::Constr { fields, .. } => { + for field in *fields { + free(field, scope, seen, out); } - _ => { - children.clear(); - core.push_children_reversed(&mut children); - pending.extend(children.iter().map(|child| Task::Visit(child))); + } + CoreKind::Builtin { args, .. } => { + for arg in *args { + free(arg, scope, seen, out); } } + CoreKind::Field { record, .. } => free(record, scope, seen, out), + CoreKind::Trace { message, body } => { + free(message, scope, seen, out); + free(body, scope, seen, out); + } + CoreKind::Delay(body) | CoreKind::Force(body) => free(body, scope, seen, out), + CoreKind::Lit(_) | CoreKind::Error => {} } - result + scope.truncate(depth); } #[cfg(test)] diff --git a/crates/nash-ir/src/anf.rs b/crates/nash-ir/src/anf.rs index 2b5d9d6b..6a4507fb 100644 --- a/crates/nash-ir/src/anf.rs +++ b/crates/nash-ir/src/anf.rs @@ -131,6 +131,7 @@ impl<'a> Normalizer<'_, 'a> { } } fn atom(&mut self, value: &'a Core<'a>, prefix: &mut Vec>) -> &'a Core<'a> { + let value = self.term(value); let value = self.peel(value, prefix); if is_atom(value) { return value; @@ -165,109 +166,120 @@ impl<'a> Normalizer<'_, 'a> { } } fn term(&mut self, core: &'a Core<'a>) -> &'a Core<'a> { - struct State<'a> { - core: &'a Core<'a>, - inputs: Vec<&'a Core<'a>>, - index: usize, - values: Vec<&'a Core<'a>>, - prefix: Vec>, - } - enum Task<'a> { - Visit(&'a Core<'a>), - Step(State<'a>), - Resume(State<'a>, bool), - } let b = self.build; - let mut pending = vec![Task::Visit(core)]; - let mut results = Vec::new(); - while let Some(task) = pending.pop() { - match task { - Task::Visit(core) => { - if matches!( - core.kind, - CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error - ) { - results.push(core); - continue; - } - let mut inputs = Vec::new(); - core.push_children_reversed(&mut inputs); - inputs.reverse(); - pending.push(Task::Step(State { - core, - inputs, - index: 0, - values: Vec::new(), - prefix: Vec::new(), - })); + let mut prefix = Vec::new(); + let kind = match core.kind { + CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error => return core, + CoreKind::Lam { params, body } => { + let body = self.term(body); + if params.is_empty() { + return b.with_type(body, core.ty); } - Task::Resume(mut state, atom) => { - let mut value = results.pop().unwrap(); - if atom { - value = self.atom(value, &mut state.prefix); - } - if let CoreKind::Let { binder, .. } = state.core.kind - && state.index == 0 - { - value = self.peel(value, &mut state.prefix); - state.prefix.push(Prefix::Let(binder, value)); - } - state.values.push(value); - state.index += 1; - pending.push(Task::Step(state)); + CoreKind::Lam { params, body } + } + CoreKind::Delay(body) => CoreKind::Delay(self.term(body)), + CoreKind::Force(body) => CoreKind::Force(self.atom(body, &mut prefix)), + CoreKind::App { func, args } => { + if args.is_empty() { + return b.with_type(self.term(func), core.ty); } - Task::Step(mut state) => { - if let Some(&input) = state.inputs.get(state.index) { - let atom = match state.core.kind { - CoreKind::App { args, .. } => !args.is_empty(), - CoreKind::Builtin { .. } - | CoreKind::Constr { .. } - | CoreKind::Field { .. } - | CoreKind::Force(_) => true, - CoreKind::Case { .. } | CoreKind::Trace { .. } => state.index == 0, - _ => false, - }; - if matches!(state.core.kind, CoreKind::App { .. }) - && state.index > 1 - && !is_atom(input) - && state.values.len() > 1 - { - let mut ty = state.values[0].ty; - for arg in &state.values[1..] { - ty = self.applied_type(ty, arg); - } - let func = self.atom( - b.app(state.values[0], &state.values[1..], ty), - &mut state.prefix, - ); - state.values.clear(); - state.values.push(func); + let mut func = self.atom(func, &mut prefix); + let mut pending: Vec<&'a Core<'a>> = Vec::new(); + for arg in args { + // Applying the prefix may fail, trace or diverge. It must + // run before evaluating the next non-value argument. + if !is_atom(arg) && !pending.is_empty() { + let mut ty = func.ty; + for previous in &pending { + ty = self.applied_type(ty, previous); } - pending.push(Task::Resume(state, atom)); - pending.push(Task::Visit(input)); - } else { - let core = state.core; - let result = match core.kind { - CoreKind::Lam { params: [], .. } | CoreKind::App { args: [], .. } => { - b.with_type(state.values[0], core.ty) - } - CoreKind::App { .. } => { - b.app(state.values[0], &state.values[1..], core.ty) - } - CoreKind::Let { .. } => b.with_type(state.values[1], core.ty), - _ => crate::traverse::rebuild( - b, - core, - &mut state.values.into_iter(), - &mut |_| None, - ), - }; - results.push(self.finish(state.prefix, result)); + func = self.atom(b.app(func, &pending, ty), &mut prefix); + pending.clear(); } + pending.push(self.atom(arg, &mut prefix)); } + return self.finish(prefix, b.app(func, &pending, core.ty)); } - } - results.pop().unwrap() + CoreKind::Let { + binder, + value, + body, + } => { + let value = self.term(value); + let value = self.peel(value, &mut prefix); + prefix.push(Prefix::Let(binder, value)); + let body = b.with_type(self.term(body), core.ty); + return self.finish(prefix, body); + } + CoreKind::LetRec { binders, body } => { + let binders: Vec<_> = binders + .iter() + .map(|rec| RecBinder { + body: self.term(rec.body), + ..*rec + }) + .collect(); + CoreKind::LetRec { + binders: b.arena.alloc_slice_copy(&binders), + body: self.term(body), + } + } + CoreKind::Builtin { func, args } => { + // A known builtin cannot execute until saturation. Earlier + // applications only collect arguments, so operands can be named + // left-to-right without introducing partial builtin bindings. + let args: Vec<_> = args.iter().map(|arg| self.atom(arg, &mut prefix)).collect(); + CoreKind::Builtin { + func, + args: b.arena.alloc_slice_copy(&args), + } + } + CoreKind::Constr { tag, fields } => { + let fields: Vec<_> = fields + .iter() + .map(|field| self.atom(field, &mut prefix)) + .collect(); + CoreKind::Constr { + tag, + fields: b.arena.alloc_slice_copy(&fields), + } + } + CoreKind::Field { + record, + index, + arity, + } => CoreKind::Field { + record: self.atom(record, &mut prefix), + index, + arity, + }, + CoreKind::Case { + kind, + scrutinee, + branches, + default, + } => { + let scrutinee = self.atom(scrutinee, &mut prefix); + let branches: Vec<_> = branches + .iter() + .map(|branch| Branch { + body: self.term(branch.body), + ..*branch + }) + .collect(); + CoreKind::Case { + kind, + scrutinee, + branches: b.arena.alloc_slice_copy(&branches), + default: default.map(|body| self.term(body)), + } + } + CoreKind::Trace { message, body } => CoreKind::Trace { + message: self.atom(message, &mut prefix), + body: self.term(body), + }, + }; + self.finish(prefix, b.alloc(core.ty, kind)) } } diff --git a/crates/nash-ir/src/beta.rs b/crates/nash-ir/src/beta.rs index bec43893..64caf87e 100644 --- a/crates/nash-ir/src/beta.rs +++ b/crates/nash-ir/src/beta.rs @@ -86,21 +86,17 @@ fn splice<'a>( value: &'a Core<'a>, finish: &mut impl FnMut(&'a Core<'a>) -> &'a Core<'a>, ) -> &'a Core<'a> { - let mut frames = Vec::new(); - let mut value = value; - while let CoreKind::Let { body, .. } | CoreKind::LetRec { body, .. } = value.kind { - frames.push(value); - value = b.with_type(body, value.ty); - } - let mut result = finish(value); - for frame in frames.into_iter().rev() { - result = match frame.kind { - CoreKind::Let { binder, value, .. } => b.let_(binder, value, result), - CoreKind::LetRec { binders, .. } => b.let_rec(binders, result), - _ => unreachable!(), - }; + match value.kind { + CoreKind::Let { + binder, + value: rhs, + body, + } => b.let_(binder, rhs, splice(b, b.with_type(body, value.ty), finish)), + CoreKind::LetRec { binders, body } => { + b.let_rec(binders, splice(b, b.with_type(body, value.ty), finish)) + } + _ => finish(value), } - result } #[cfg(test)] diff --git a/crates/nash-ir/src/core.rs b/crates/nash-ir/src/core.rs index 7e0d1595..0601b170 100644 --- a/crates/nash-ir/src/core.rs +++ b/crates/nash-ir/src/core.rs @@ -137,10 +137,57 @@ impl<'a> Core<'a> { /// arguments, binding values then continuation, scrutinee then branches /// then default. Shared subtrees are visited once per occurrence. pub fn walk<'tree>(&'tree self, f: &mut impl FnMut(&'tree Core<'a>)) { - let mut pending = vec![self]; - while let Some(node) = pending.pop() { - f(node); - node.push_children_reversed(&mut pending); + f(self); + match &self.kind { + CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error => {} + CoreKind::Lam { body, .. } | CoreKind::Delay(body) | CoreKind::Force(body) => { + body.walk(f) + } + CoreKind::App { func, args } => { + func.walk(f); + for arg in *args { + arg.walk(f); + } + } + CoreKind::Let { value, body, .. } => { + value.walk(f); + body.walk(f); + } + CoreKind::LetRec { binders, body } => { + for binder in *binders { + binder.body.walk(f); + } + body.walk(f); + } + CoreKind::Case { + scrutinee, + branches, + default, + .. + } => { + scrutinee.walk(f); + for branch in *branches { + branch.body.walk(f); + } + if let Some(body) = default { + body.walk(f); + } + } + CoreKind::Constr { fields, .. } => { + for field in *fields { + field.walk(f); + } + } + CoreKind::Builtin { args, .. } => { + for arg in *args { + arg.walk(f); + } + } + CoreKind::Field { record, .. } => record.walk(f), + CoreKind::Trace { message, body } => { + message.walk(f); + body.walk(f); + } } } diff --git a/crates/nash-ir/src/hygiene.rs b/crates/nash-ir/src/hygiene.rs index bc247003..541eecbc 100644 --- a/crates/nash-ir/src/hygiene.rs +++ b/crates/nash-ir/src/hygiene.rs @@ -49,7 +49,7 @@ pub fn validate<'a>(core: &Core<'a>, external: &[Name<'a>]) -> Result<(), Vec(build: &Builder<'a>, core: &'a Core<'a>) -> &'a Core<'a> { - Rewriter::new(build, &[core]).term(core, None) + Rewriter::new(build, &[core]).term(core, &HashMap::new(), None) } /// Replace free occurrences of `target` with `replacement`. Binders in both the @@ -69,7 +69,11 @@ pub fn substitute<'a>( target: u32, replacement: &'a Core<'a>, ) -> &'a Core<'a> { - Rewriter::new(build, &[core, replacement]).term(core, Some((target, replacement))) + Rewriter::new(build, &[core, replacement]).term( + core, + &HashMap::new(), + Some((target, replacement)), + ) } struct Rewriter<'b, 'a> { @@ -126,288 +130,139 @@ impl<'b, 'a> Rewriter<'b, 'a> { self.build.arena.alloc_slice_copy(&binders) } + fn terms( + &mut self, + terms: &[&'a Core<'a>], + scope: &HashMap>, + substitution: Option<(u32, &'a Core<'a>)>, + ) -> Vec<&'a Core<'a>> { + terms + .iter() + .map(|t| self.term(t, scope, substitution)) + .collect() + } + fn term( &mut self, - root: &'a Core<'a>, + core: &'a Core<'a>, + scope: &HashMap>, substitution: Option<(u32, &'a Core<'a>)>, ) -> &'a Core<'a> { - enum Task<'a> { - Visit(&'a Core<'a>, Option<(u32, &'a Core<'a>)>), - Finish(&'a Core<'a>, usize), - LetBody(&'a Core<'a>, usize, Option<(u32, &'a Core<'a>)>), - Restore(HashMap>), - Retype(crate::ty::Ty<'a>), - RecNext { - core: &'a Core<'a>, - names: Vec>, - recs: Vec>, - sub: Option<(u32, &'a Core<'a>)>, - }, - RecBody { - core: &'a Core<'a>, - names: Vec>, - recs: Vec>, - params: &'a [Binder<'a>], - sub: Option<(u32, &'a Core<'a>)>, - }, - RecFinish { - core: &'a Core<'a>, - recs: Vec>, - }, - CaseNext { - core: &'a Core<'a>, - scrutinee: Option<&'a Core<'a>>, - branches: Vec>, - sub: Option<(u32, &'a Core<'a>)>, - }, - CaseBody { - core: &'a Core<'a>, - scrutinee: &'a Core<'a>, - branches: Vec>, - binders: &'a [Binder<'a>], - sub: Option<(u32, &'a Core<'a>)>, - }, - CaseFinish { - core: &'a Core<'a>, - scrutinee: &'a Core<'a>, - branches: Vec>, - }, - } let b = self.build; - let mut scope = HashMap::new(); - let mut pending = vec![Task::Visit(root, substitution)]; - let mut results = Vec::new(); - let mut children = Vec::new(); - while let Some(task) = pending.pop() { - match task { - Task::Restore(previous) => scope = previous, - Task::Retype(ty) => { - let value = results.pop().unwrap(); - results.push(b.with_type(value, ty)); + let rewritten = match &core.kind { + CoreKind::Var(name) => { + if let Some(renamed) = scope.get(&name.unique) { + return b.var(*renamed, core.ty); } - Task::Finish(core, start) => { - let rebuilt = - crate::traverse::rebuild(b, core, &mut results.drain(start..), &mut |_| { - None - }); - results.push(rebuilt); + if let Some((target, replacement)) = substitution + && name.unique == target + { + let replacement = self.term(replacement, &HashMap::new(), None); + return b.with_type(replacement, core.ty); } - Task::LetBody(core, start, sub) => { - let CoreKind::Let { - binder, - value, - body, - } = core.kind - else { - unreachable!() - }; - let previous = scope.clone(); - let binder = self.binder(binder, &mut scope); - pending.push(Task::Restore(previous)); - pending.push(Task::Finish( - b.alloc( - core.ty, - CoreKind::Let { - binder, - value, - body, - }, - ), - start, - )); - pending.push(Task::Visit(body, sub)); - } - Task::RecNext { - core, - names, - recs, - sub, - } => { - let CoreKind::LetRec { binders, body } = core.kind else { - unreachable!() - }; - if recs.len() == binders.len() { - pending.push(Task::RecFinish { core, recs }); - pending.push(Task::Visit(body, sub)); - } else { - let rec = binders[recs.len()]; - let previous = scope.clone(); - let params = self.binders(rec.params, &mut scope); - pending.push(Task::RecBody { - core, - names, - recs, + core + } + CoreKind::Lit(_) | CoreKind::Error => core, + CoreKind::Lam { params, body } => { + let mut inner = scope.clone(); + let params = self.binders(params, &mut inner); + let body = self.term(body, &inner, substitution); + b.lam(params, body) + } + CoreKind::Let { + binder, + value, + body, + } => { + let value = self.term(value, scope, substitution); + let mut inner = scope.clone(); + let binder = self.binder(*binder, &mut inner); + let body = self.term(body, &inner, substitution); + b.let_(binder, value, body) + } + CoreKind::LetRec { binders, body } => { + let mut group = scope.clone(); + let names: Vec<_> = binders + .iter() + .map(|r| self.binder(r.binder, &mut group)) + .collect(); + let recs: Vec<_> = binders + .iter() + .zip(names) + .map(|(rec, binder)| { + let mut inner = group.clone(); + let params = self.binders(rec.params, &mut inner); + let body = self.term(rec.body, &inner, substitution); + RecBinder { + binder, params, - sub, - }); - pending.push(Task::Restore(previous)); - pending.push(Task::Visit(rec.body, sub)); - } - } - Task::RecBody { - core, - names, - mut recs, - params, - sub, - } => { - let CoreKind::LetRec { binders, .. } = core.kind else { - unreachable!() - }; - recs.push(RecBinder { - binder: names[recs.len()], - params, - body: results.pop().unwrap(), - ..binders[recs.len()] - }); - pending.push(Task::RecNext { - core, - names, - recs, - sub, - }); - } - Task::RecFinish { core, recs } => { - let body = results.pop().unwrap(); - results.push(b.with_type(b.let_rec(&recs, body), core.ty)); - } - Task::CaseNext { - core, - scrutinee, - branches, - sub, - } => { - let scrutinee = scrutinee.unwrap_or_else(|| results.pop().unwrap()); - let CoreKind::Case { - branches: original, - default, - .. - } = core.kind - else { - unreachable!() - }; - if branches.len() == original.len() { - pending.push(Task::CaseFinish { - core, - scrutinee, - branches, - }); - if let Some(body) = default { - pending.push(Task::Visit(body, sub)); + body, + ..*rec } - } else { - let branch = original[branches.len()]; - let previous = scope.clone(); - let binders = self.binders(branch.binders, &mut scope); - pending.push(Task::CaseBody { - core, - scrutinee, - branches, + }) + .collect(); + let body = self.term(body, &group, substitution); + b.let_rec(&recs, body) + } + CoreKind::Case { + kind, + scrutinee, + branches, + default, + } => { + let scrutinee = self.term(scrutinee, scope, substitution); + let branches: Vec<_> = branches + .iter() + .map(|branch| { + let mut inner = scope.clone(); + let binders = self.binders(branch.binders, &mut inner); + let body = self.term(branch.body, &inner, substitution); + Branch { binders, - sub, - }); - pending.push(Task::Restore(previous)); - pending.push(Task::Visit(branch.body, sub)); - } - } - Task::CaseBody { - core, - scrutinee, - mut branches, - binders, - sub, - } => { - let CoreKind::Case { - branches: original, .. - } = core.kind - else { - unreachable!() - }; - branches.push(Branch { - binders, - body: results.pop().unwrap(), - ..original[branches.len()] - }); - pending.push(Task::CaseNext { - core, - scrutinee: Some(scrutinee), - branches, - sub, - }); - } - Task::CaseFinish { - core, - scrutinee, - branches, - } => { - let CoreKind::Case { kind, default, .. } = core.kind else { - unreachable!() - }; - let default = default.map(|_| results.pop().unwrap()); - results.push(b.case(kind, scrutinee, &branches, default, core.ty)); - } - Task::Visit(core, sub) => match core.kind { - CoreKind::Var(name) => { - if let Some(renamed) = scope.get(&name.unique) { - results.push(b.var(*renamed, core.ty)); - } else if let Some((target, replacement)) = sub - && name.unique == target - { - pending.push(Task::Restore(std::mem::take(&mut scope))); - pending.push(Task::Retype(core.ty)); - pending.push(Task::Visit(replacement, None)); - } else { - results.push(core); + body, + ..*branch } - } - CoreKind::Lit(_) | CoreKind::Error => results.push(core), - CoreKind::Lam { params, body } => { - let previous = scope.clone(); - let params = self.binders(params, &mut scope); - pending.push(Task::Restore(previous)); - pending.push(Task::Finish( - b.alloc(core.ty, CoreKind::Lam { params, body }), - results.len(), - )); - pending.push(Task::Visit(body, sub)); - } - CoreKind::Let { value, .. } => { - pending.push(Task::LetBody(core, results.len(), sub)); - pending.push(Task::Visit(value, sub)); - } - CoreKind::LetRec { binders, .. } => { - let previous = scope.clone(); - let names = binders - .iter() - .map(|rec| self.binder(rec.binder, &mut scope)) - .collect(); - pending.push(Task::Restore(previous)); - pending.push(Task::RecNext { - core, - names, - recs: Vec::new(), - sub, - }); - } - CoreKind::Case { scrutinee, .. } => { - pending.push(Task::CaseNext { - core, - scrutinee: None, - branches: Vec::new(), - sub, - }); - pending.push(Task::Visit(scrutinee, sub)); - } - _ => { - pending.push(Task::Finish(core, results.len())); - children.clear(); - core.push_children_reversed(&mut children); - pending.extend(children.iter().map(|child| Task::Visit(child, sub))); - } - }, + }) + .collect(); + let default = default.map(|d| self.term(d, scope, substitution)); + b.case(*kind, scrutinee, &branches, default, core.ty) } - } - results.pop().unwrap() + CoreKind::App { func, args } => { + let func = self.term(func, scope, substitution); + let args = self.terms(args, scope, substitution); + b.app(func, &args, core.ty) + } + CoreKind::Constr { tag, fields } => { + let fields = self.terms(fields, scope, substitution); + b.constr(*tag, &fields, core.ty) + } + CoreKind::Builtin { func, args } => { + let args = self.terms(args, scope, substitution); + b.builtin(*func, &args, core.ty) + } + CoreKind::Field { + record, + index, + arity, + } => { + let record = self.term(record, scope, substitution); + b.field(record, *index, *arity, core.ty) + } + CoreKind::Trace { message, body } => { + let message = self.term(message, scope, substitution); + let body = self.term(body, scope, substitution); + b.trace(message, body) + } + CoreKind::Delay(body) => { + let body = self.term(body, scope, substitution); + b.delay(body) + } + CoreKind::Force(body) => { + let body = self.term(body, scope, substitution); + b.force(body, core.ty) + } + }; + b.with_type(rewritten, core.ty) } } diff --git a/crates/nash-ir/src/pretty.rs b/crates/nash-ir/src/pretty.rs index 1a823a71..d29839eb 100644 --- a/crates/nash-ir/src/pretty.rs +++ b/crates/nash-ir/src/pretty.rs @@ -21,179 +21,156 @@ fn newline(out: &mut String, indent: usize) { out.push_str(" "); } } -fn lambda(out: &mut String, params: &[Binder<'_>]) { - out.push('\\'); - for (index, p) in params.iter().enumerate() { - if index > 0 { - out.push(' '); +fn write_core(out: &mut String, core: &Core<'_>, indent: usize, context: u8) { + let precedence = match &core.kind { + CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error => 2, + CoreKind::App { args, .. } | CoreKind::Builtin { args, .. } if args.is_empty() => 2, + CoreKind::App { .. } + | CoreKind::Builtin { .. } + | CoreKind::Constr { .. } + | CoreKind::Field { .. } + | CoreKind::Delay(_) + | CoreKind::Force(_) => 1, + _ => 0, + }; + let parens = precedence < context; + if parens { + out.push('('); + } + match &core.kind { + CoreKind::Var(n) => name(out, *n), + CoreKind::Lit(c) => match c { + nash_plutus::constant::Constant::Integer(i) => write!(out, "{i}").unwrap(), + nash_plutus::constant::Constant::String(s) => write!(out, "{s:?}").unwrap(), + nash_plutus::constant::Constant::Boolean(b) => write!(out, "{b}").unwrap(), + nash_plutus::constant::Constant::Unit => out.push_str("()"), + _ => out.push_str(&nash_plutus::pretty::constant(c)), + }, + CoreKind::Lam { params, body } => { + out.push('\\'); + for (index, p) in params.iter().enumerate() { + if index > 0 { + out.push(' '); + } + // Parentheses separate function-valued parameters from the body arrow. + let parens = matches!(p.ty, crate::ty::Ty::Term(crate::ty::TermTy::Fun(_, _))); + if parens { + out.push('('); + } + binder(out, *p); + if parens { + out.push(')'); + } + } + out.push_str(" -> "); + write_core(out, body, indent, 0); } - // Separate function-valued parameters from the body arrow. - let parens = matches!(p.ty, crate::ty::Ty::Term(crate::ty::TermTy::Fun(_, _))); - if parens { - out.push('('); + CoreKind::App { func, args } => { + write_core(out, func, indent, 1); + arguments(out, args, indent); } - binder(out, *p); - if parens { - out.push(')'); + CoreKind::Let { + binder: b, + value, + body, + } => { + out.push_str("let "); + binder(out, *b); + out.push_str(" = "); + write_core(out, value, indent, 0); + out.push_str(" in"); + newline(out, indent); + write_core(out, body, indent, 0); } - } - out.push_str(" -> "); -} - -enum Task<'a> { - Core(&'a Core<'a>, usize, u8), - Text(&'static str), - Newline(usize), - Rec(&'a RecBinder<'a>, usize), - Branch(&'a Branch<'a>, usize), -} - -fn arguments<'a>(pending: &mut Vec>, args: &[&'a Core<'a>], indent: usize) { - for arg in args.iter().rev() { - pending.push(Task::Core(arg, indent, 2)); - pending.push(Task::Text(" ")); - } -} - -fn write_core<'a>(out: &mut String, core: &'a Core<'a>, indent: usize, context: u8) { - let mut pending = vec![Task::Core(core, indent, context)]; - while let Some(task) = pending.pop() { - let (core, indent, context) = match task { - Task::Text(text) => { - out.push_str(text); - continue; - } - Task::Newline(indent) => { - newline(out, indent); - continue; - } - Task::Rec(rec, indent) => { - newline(out, indent); + CoreKind::LetRec { binders, body } => { + out.push_str("letrec"); + for rec in *binders { + newline(out, indent + 1); binder(out, rec.binder); write!(out, " [static {:?}] = ", rec.static_params).unwrap(); - lambda(out, rec.params); - pending.push(Task::Core(rec.body, indent, 0)); - continue; + write_core( + out, + &Core { + ty: rec.binder.ty, + kind: CoreKind::Lam { + params: rec.params, + body: rec.body, + }, + }, + indent + 1, + 0, + ); } - Task::Branch(branch, indent) => { - newline(out, indent); + newline(out, indent); + out.push_str("in"); + newline(out, indent); + write_core(out, body, indent, 0); + } + CoreKind::Case { + kind, + scrutinee, + branches, + default, + } => { + write!(out, "case@{kind:?} ").unwrap(); + write_core(out, scrutinee, indent, 1); + out.push_str(" of"); + for branch in *branches { + newline(out, indent + 1); test(out, branch.test); for b in branch.binders { out.push(' '); name(out, b.name); } out.push_str(" -> "); - pending.push(Task::Core(branch.body, indent, 0)); - continue; - } - Task::Core(core, indent, context) => (core, indent, context), - }; - let precedence = match &core.kind { - CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error => 2, - CoreKind::App { args, .. } | CoreKind::Builtin { args, .. } if args.is_empty() => 2, - CoreKind::App { .. } - | CoreKind::Builtin { .. } - | CoreKind::Constr { .. } - | CoreKind::Field { .. } - | CoreKind::Delay(_) - | CoreKind::Force(_) => 1, - _ => 0, - }; - if precedence < context { - out.push('('); - pending.push(Task::Text(")")); - } - match core.kind { - CoreKind::Var(n) => name(out, n), - CoreKind::Lit(c) => match c { - nash_plutus::constant::Constant::Integer(i) => write!(out, "{i}").unwrap(), - nash_plutus::constant::Constant::String(s) => write!(out, "{s:?}").unwrap(), - nash_plutus::constant::Constant::Boolean(b) => write!(out, "{b}").unwrap(), - nash_plutus::constant::Constant::Unit => out.push_str("()"), - _ => out.push_str(&nash_plutus::pretty::constant(c)), - }, - CoreKind::Lam { params, body } => { - lambda(out, params); - pending.push(Task::Core(body, indent, 0)); + write_core(out, branch.body, indent + 1, 0); } - CoreKind::App { func, args } => { - arguments(&mut pending, args, indent); - pending.push(Task::Core(func, indent, 1)); - } - CoreKind::Let { - binder: b, - value, - body, - } => { - out.push_str("let "); - binder(out, b); - out.push_str(" = "); - pending.push(Task::Core(body, indent, 0)); - pending.push(Task::Newline(indent)); - pending.push(Task::Text(" in")); - pending.push(Task::Core(value, indent, 0)); - } - CoreKind::LetRec { binders, body } => { - out.push_str("letrec"); - pending.push(Task::Core(body, indent, 0)); - pending.push(Task::Newline(indent)); - pending.push(Task::Text("in")); - pending.push(Task::Newline(indent)); - pending.extend(binders.iter().rev().map(|rec| Task::Rec(rec, indent + 1))); - } - CoreKind::Case { - kind, - scrutinee, - branches, - default, - } => { - write!(out, "case@{kind:?} ").unwrap(); - if let Some(body) = default { - pending.push(Task::Core(body, indent + 1, 0)); - pending.push(Task::Text("_ -> ")); - pending.push(Task::Newline(indent + 1)); - } - pending.extend( - branches - .iter() - .rev() - .map(|branch| Task::Branch(branch, indent + 1)), - ); - pending.push(Task::Text(" of")); - pending.push(Task::Core(scrutinee, indent, 1)); - } - CoreKind::Constr { tag, fields } => { - write!(out, "constr {tag}").unwrap(); - arguments(&mut pending, fields, indent); - } - CoreKind::Field { - record, - index, - arity, - } => { - write!(out, "field@{index}/{arity} ").unwrap(); - pending.push(Task::Core(record, indent, 2)); - } - CoreKind::Builtin { func, args } => { - out.push_str(nash_plutus::pretty::builtin(func)); - arguments(&mut pending, args, indent); - } - CoreKind::Trace { message, body } => { - out.push_str("trace "); - pending.push(Task::Core(body, indent, 0)); - pending.push(Task::Text(" in ")); - pending.push(Task::Core(message, indent, 2)); - } - CoreKind::Error => out.push_str("error"), - CoreKind::Delay(body) | CoreKind::Force(body) => { - out.push_str(if matches!(core.kind, CoreKind::Delay(_)) { - "delay " - } else { - "force " - }); - pending.push(Task::Core(body, indent, 2)); + if let Some(body) = default { + newline(out, indent + 1); + out.push_str("_ -> "); + write_core(out, body, indent + 1, 0); } } + CoreKind::Constr { tag, fields } => { + write!(out, "constr {tag}").unwrap(); + arguments(out, fields, indent); + } + CoreKind::Field { + record, + index, + arity, + } => { + write!(out, "field@{index}/{arity} ").unwrap(); + write_core(out, record, indent, 2); + } + CoreKind::Builtin { func, args } => { + out.push_str(nash_plutus::pretty::builtin(*func)); + arguments(out, args, indent); + } + CoreKind::Trace { message, body } => { + out.push_str("trace "); + write_core(out, message, indent, 2); + out.push_str(" in "); + write_core(out, body, indent, 0); + } + CoreKind::Error => out.push_str("error"), + CoreKind::Delay(body) => { + out.push_str("delay "); + write_core(out, body, indent, 2); + } + CoreKind::Force(body) => { + out.push_str("force "); + write_core(out, body, indent, 2); + } + } + if parens { + out.push(')'); + } +} +fn arguments(out: &mut String, args: &[&Core<'_>], indent: usize) { + for arg in args { + out.push(' '); + write_core(out, arg, indent, 2); } } fn test(out: &mut String, test: Test<'_>) { @@ -235,33 +212,6 @@ mod tests { } } - #[test] - fn deep_core_prints_on_a_small_stack() { - std::thread::Builder::new() - .stack_size(128 * 1024) - .spawn(|| { - let arena = Arena::new(); - let b = Builder::new(&arena); - let depth = 20_000; - let mut core = b.int(1); - for _ in 0..depth { - core = b.alloc(core.ty, CoreKind::Force(core)); - } - let rendered = pretty(core); - // Structural depth/parenthesis check; ordinary formatting is covered - // by the snapshots without storing a huge depth-test snapshot. - let expected = format!( - "{}force 1{}", - "force (".repeat(depth - 1), - ")".repeat(depth - 1) - ); - assert_eq!(rendered, expected); - }) - .unwrap() - .join() - .unwrap(); - } - #[test] fn pretty_let_app() { let arena = Arena::new(); diff --git a/crates/nash-ir/src/traverse.rs b/crates/nash-ir/src/traverse.rs index fd2a3d10..f5f4ec0e 100644 --- a/crates/nash-ir/src/traverse.rs +++ b/crates/nash-ir/src/traverse.rs @@ -12,44 +12,20 @@ pub fn map<'a>( build: &Builder<'a>, core: &'a Core<'a>, f: &mut impl FnMut(&'a Core<'a>) -> Option<&'a Core<'a>>, -) -> &'a Core<'a> { - let mut pending = vec![(core, false)]; - let mut results = Vec::new(); - let mut children = Vec::new(); - while let Some((node, finish)) = pending.pop() { - children.clear(); - node.push_children_reversed(&mut children); - if !finish { - pending.push((node, true)); - pending.extend(children.iter().map(|child| (*child, false))); - } else { - let start = results.len() - children.len(); - let mapped = rebuild(build, node, &mut results.drain(start..), f); - results.push(mapped); - } - } - results.pop().unwrap() -} - -pub(crate) fn rebuild<'a>( - build: &Builder<'a>, - core: &'a Core<'a>, - children: &mut impl Iterator>, - f: &mut impl FnMut(&'a Core<'a>) -> Option<&'a Core<'a>>, ) -> &'a Core<'a> { let changed = match &core.kind { CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error => None, CoreKind::Lam { params, body } => { - let mapped = children.next().unwrap(); + let mapped = map(build, body, f); (!ptr::eq(*body, mapped)).then_some(CoreKind::Lam { params, body: mapped, }) } CoreKind::App { func, args } => { - let mapped = children.next().unwrap(); + let mapped = map(build, func, f); let mapped_args = map_slice(build, args, |arg| { - let value = children.next().unwrap(); + let value = map(build, arg, f); (value, !ptr::eq(arg, value)) }); (!ptr::eq(*func, mapped) || !ptr::eq(*args, mapped_args)).then_some(CoreKind::App { @@ -62,8 +38,8 @@ pub(crate) fn rebuild<'a>( value, body, } => { - let v = children.next().unwrap(); - let t = children.next().unwrap(); + let v = map(build, value, f); + let t = map(build, body, f); (!ptr::eq(*value, v) || !ptr::eq(*body, t)).then_some(CoreKind::Let { binder: *binder, value: v, @@ -72,10 +48,10 @@ pub(crate) fn rebuild<'a>( } CoreKind::LetRec { binders, body } => { let mapped = map_slice(build, binders, |rb| { - let body = children.next().unwrap(); + let body = map(build, rb.body, f); (RecBinder { body, ..rb }, !ptr::eq(rb.body, body)) }); - let t = children.next().unwrap(); + let t = map(build, body, f); (!ptr::eq(*binders, mapped) || !ptr::eq(*body, t)).then_some(CoreKind::LetRec { binders: mapped, body: t, @@ -87,12 +63,12 @@ pub(crate) fn rebuild<'a>( branches, default, } => { - let s = children.next().unwrap(); + let s = map(build, scrutinee, f); let bs = map_slice(build, branches, |branch| { - let body = children.next().unwrap(); + let body = map(build, branch.body, f); (Branch { body, ..branch }, !ptr::eq(branch.body, body)) }); - let d = default.map(|_| children.next().unwrap()); + let d = default.map(|body| map(build, body, f)); let same_default = match (default, d) { (Some(a), Some(b)) => ptr::eq(*a, b), (None, None) => true, @@ -109,7 +85,7 @@ pub(crate) fn rebuild<'a>( } CoreKind::Constr { tag, fields } => { let mapped = map_slice(build, fields, |arg| { - let value = children.next().unwrap(); + let value = map(build, arg, f); (value, !ptr::eq(arg, value)) }); (!ptr::eq(*fields, mapped)).then_some(CoreKind::Constr { @@ -122,7 +98,7 @@ pub(crate) fn rebuild<'a>( index, arity, } => { - let mapped = children.next().unwrap(); + let mapped = map(build, record, f); (!ptr::eq(*record, mapped)).then_some(CoreKind::Field { record: mapped, index: *index, @@ -131,7 +107,7 @@ pub(crate) fn rebuild<'a>( } CoreKind::Builtin { func, args } => { let mapped = map_slice(build, args, |arg| { - let value = children.next().unwrap(); + let value = map(build, arg, f); (value, !ptr::eq(arg, value)) }); (!ptr::eq(*args, mapped)).then_some(CoreKind::Builtin { @@ -140,19 +116,19 @@ pub(crate) fn rebuild<'a>( }) } CoreKind::Trace { message, body } => { - let m = children.next().unwrap(); - let t = children.next().unwrap(); + let m = map(build, message, f); + let t = map(build, body, f); (!ptr::eq(*message, m) || !ptr::eq(*body, t)).then_some(CoreKind::Trace { message: m, body: t, }) } CoreKind::Delay(body) => { - let mapped = children.next().unwrap(); + let mapped = map(build, body, f); (!ptr::eq(*body, mapped)).then_some(CoreKind::Delay(mapped)) } CoreKind::Force(body) => { - let mapped = children.next().unwrap(); + let mapped = map(build, body, f); (!ptr::eq(*body, mapped)).then_some(CoreKind::Force(mapped)) } }; diff --git a/crates/nash-ir/src/traverse/tests.rs b/crates/nash-ir/src/traverse/tests.rs index 7b19230e..91eb1f4f 100644 --- a/crates/nash-ir/src/traverse/tests.rs +++ b/crates/nash-ir/src/traverse/tests.rs @@ -174,35 +174,3 @@ fn visitor_observes_mapped_children_and_identity_replacement_is_a_noop() { }); assert!(std::ptr::eq(mapped, result)); } - -#[test] -fn deep_walk_and_map_use_heap_work_lists() { - std::thread::Builder::new() - .stack_size(128 * 1024) - .spawn(|| { - let arena = Arena::new(); - let b = Builder::new(&arena); - let mut core = b.int(1); - for _ in 0..20_000 { - core = b.alloc(core.ty, CoreKind::Force(core)); - } - let mut nodes = 0; - core.walk(&mut |_| nodes += 1); - assert_eq!(nodes, 20_001); - assert!(std::ptr::eq(core, core.map(&b, &mut |_| None))); - let mapped = core.map(&b, &mut |node| { - matches!(node.kind, CoreKind::Lit(_)).then(|| b.int(2)) - }); - let mut cursor = mapped; - for _ in 0..20_000 { - let CoreKind::Force(body) = cursor.kind else { - panic!("missing force") - }; - cursor = body; - } - assert!(matches!(cursor.kind, CoreKind::Lit(_))); - }) - .unwrap() - .join() - .unwrap(); -} diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index 2a3963a1..f728ecc1 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -18,7 +18,9 @@ Converted back so far: Flat term encoding and decoding (`encode_term`, `decode_term`) recurse again, as in the imported code. `nash test` decodes programs on its worker pool, so the CLI gives those workers the compiler stack. The UPLC term printer, De Bruijn conversion, and target validation -(`pretty::term`, `to_debruijn`, `validate_*`) recurse again. +(`pretty::term`, `to_debruijn`, `validate_*`) recurse again. So do the Core +traversals of `nash-ir`: `walk`, `map`, occurrence and free-variable analysis, +discard checks, hygiene, ANF, beta splicing, and Core printing. Default O1 exposed a stack overflow in the driver `ledger_contexts` fixture. This audit distinguishes reproduced failures from recursive paths that still From df857f60d2789a73df17ea97f8d70d0cdd3eb0ef Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 8 Oct 2026 14:47:28 +0700 Subject: [PATCH 6/9] refactor(codegen): restore the recursive lowering, constant-sharing rewrite, and recursion rewrite Signed-off-by: microproofs --- .../changesets/codegen-traversal-recursion.md | 5 + crates/nash-codegen/src/lower.rs | 611 +++++++---------- .../src/lower/constant_sharing.rs | 83 +-- crates/nash-codegen/src/recursion.rs | 617 +++++++----------- docs/codegen.md | 7 +- docs/research/compiler-stack-audit.md | 3 +- plans/08-optimizer.md | 6 +- 7 files changed, 534 insertions(+), 798 deletions(-) create mode 100644 .sampo/changesets/codegen-traversal-recursion.md diff --git a/.sampo/changesets/codegen-traversal-recursion.md b/.sampo/changesets/codegen-traversal-recursion.md new file mode 100644 index 00000000..c1e66503 --- /dev/null +++ b/.sampo/changesets/codegen-traversal-recursion.md @@ -0,0 +1,5 @@ +--- +cargo/nash-codegen: patch +--- + +Restore the recursive lowering, constant-sharing rewrite, and recursion rewrite. diff --git a/crates/nash-codegen/src/lower.rs b/crates/nash-codegen/src/lower.rs index 812b5fda..6b74e084 100644 --- a/crates/nash-codegen/src/lower.rs +++ b/crates/nash-codegen/src/lower.rs @@ -172,382 +172,271 @@ impl<'a> Lower<'a> { } fn term(&mut self, core: &'a Core<'a>) -> Result, Error> { - struct CaseState<'a> { - kind: CaseKind, - scrutinee: Uplc<'a>, - fallback: Uplc<'a>, - branches: Vec<&'a Branch<'a>>, - index: usize, - name: Option<&'a Name<'a>>, - arms: Vec>>, - seen: Vec>, - } - enum Task<'a> { - Visit(&'a Core<'a>), - Finish(&'a Core<'a>, usize, bool), - Field(Uplc<'a>), - CaseStart(CaseKind, &'a [Branch<'a>], bool), - CaseNext(CaseState<'a>), - CaseBody( - CaseState<'a>, - usize, - Option, - Option>, - ), - } - let mut pending = vec![Task::Visit(core)]; - let mut results: Vec> = Vec::new(); - while let Some(task) = pending.pop() { - match task { - Task::Visit(core) => { - let start = results.len(); - match core.kind { - CoreKind::Var(n) => results.push(Term::var(self.arena, self.name(n))), - CoreKind::Lit(c) => results.push(Term::constant(self.arena, c)), - CoreKind::Error => results.push(Term::error(self.arena)), - CoreKind::LetRec { .. } => return Err(Error::Unlowered("recursion")), - CoreKind::Field { - record, - index, - arity, - } => { - if index >= arity { - return Err(Error::InvalidField { index, arity }); - } - let params = (0..arity) - .map(|_| self.fresh()) - .collect::, _>>()?; - let mut selector = Term::var(self.arena, params[index as usize]); - for param in params.into_iter().rev() { - selector = selector.lambda(self.arena, param); - } - pending.push(Task::Field(selector)); - pending.push(Task::Visit(record)); - } - CoreKind::Case { - kind, - scrutinee, - branches, - default, - } => { - pending.push(Task::CaseStart(kind, branches, default.is_some())); - if let Some(body) = default { - pending.push(Task::Visit(body)); - } - pending.push(Task::Visit(scrutinee)); - } - CoreKind::Let { - binder, - value, - body, - } => { - let unit = binder.ty - == nash_ir::ty::Ty::Const(&nash_ir::ty::ConstTy::Unit) - && !crate::build::names(body).contains(&binder.name.unique); - pending.push(Task::Finish(core, start, unit)); - pending.push(Task::Visit(value)); - pending.push(Task::Visit(body)); - } - _ => { - if let CoreKind::Builtin { func, args } = core.kind - && args.len() > func.arity() - { - return Err(Error::BuiltinArity); - } - pending.push(Task::Finish(core, start, false)); - let mut children = Vec::new(); - core.push_children_reversed(&mut children); - pending.extend(children.into_iter().map(Task::Visit)); - } - } + Ok(match &core.kind { + CoreKind::Var(n) => Term::var(self.arena, self.name(*n)), + CoreKind::Lit(c) => Term::constant(self.arena, c), + CoreKind::Lam { params, body } => { + let body = self.term(body)?; + self.lambda(params, body) + } + CoreKind::App { func, args } => { + let mut term = self.term(func)?; + for arg in *args { + term = term.apply(self.arena, self.term(arg)?); } - Task::Field(selector) => { - let record = results.pop().unwrap(); - results.push(Term::case( - self.arena, - record, - self.arena.alloc_slice_copy(&[selector]), - )); + term + } + CoreKind::Let { + binder, + value, + body, + } => { + let unit_sequence = binder.ty + == nash_ir::ty::Ty::Const(&nash_ir::ty::ConstTy::Unit) + && !crate::build::names(body).contains(&binder.name.unique); + let body = self.term(body)?; + let value = self.term(value)?; + if unit_sequence { + Term::case(self.arena, value, self.arena.alloc_slice_copy(&[body])) + } else { + body.lambda(self.arena, self.name(binder.name)) + .apply(self.arena, value) } - Task::Finish(core, start, unit) => { - let mut children = results.drain(start..); - let result = match core.kind { - CoreKind::Lam { params, .. } => { - self.lambda(params, children.next().unwrap()) - } - CoreKind::App { .. } => { - let mut func = children.next().unwrap(); - for arg in children.by_ref() { - func = func.apply(self.arena, arg); - } - func - } - CoreKind::Let { binder, .. } => { - let body = children.next().unwrap(); - let value = children.next().unwrap(); - if unit { - Term::case(self.arena, value, self.arena.alloc_slice_copy(&[body])) - } else { - body.lambda(self.arena, self.name(binder.name)) - .apply(self.arena, value) - } - } - CoreKind::Builtin { func, .. } => { - self.builtin(func, &children.by_ref().collect::>())? - } - CoreKind::Constr { tag, .. } => Term::constr( - self.arena, - tag as usize, - self.arena - .alloc_slice_copy(&children.by_ref().collect::>()), - ), - CoreKind::Trace { .. } => { - let message = children.next().unwrap(); - let body = children.next().unwrap(); - self.builtin( - DefaultFunction::Trace, - &[message, body.delay(self.arena)], - )? - .force(self.arena) - } - CoreKind::Delay(_) => children.next().unwrap().delay(self.arena), - CoreKind::Force(_) => children.next().unwrap().force(self.arena), - _ => unreachable!(), - }; - drop(children); - results.push(result); + } + CoreKind::Builtin { func, args } => { + if args.len() > func.arity() { + return Err(Error::BuiltinArity); } - Task::CaseStart(kind, branches, has_default) => { - let fallback = if has_default { - results.pop().unwrap() - } else { - Term::error(self.arena) + let args = args + .iter() + .map(|arg| self.term(arg)) + .collect::, _>>()?; + self.builtin(*func, &args)? + } + CoreKind::Case { + kind, + scrutinee, + branches, + default, + } => self.case(*kind, scrutinee, branches, *default)?, + CoreKind::Constr { tag, fields } => { + let fields = fields + .iter() + .map(|f| self.term(f)) + .collect::, _>>()?; + Term::constr( + self.arena, + *tag as usize, + self.arena.alloc_slice_copy(&fields), + ) + } + CoreKind::Field { + record, + index, + arity, + } => { + if index >= arity { + return Err(Error::InvalidField { + index: *index, + arity: *arity, + }); + } + let params = (0..*arity) + .map(|_| self.fresh()) + .collect::, _>>()?; + let mut selector = Term::var(self.arena, params[*index as usize]); + for param in params.into_iter().rev() { + selector = selector.lambda(self.arena, param); + } + Term::case( + self.arena, + self.term(record)?, + self.arena.alloc_slice_copy(&[selector]), + ) + } + CoreKind::Trace { message, body } => { + let message = self.term(message)?; + let body = self.term(body)?; + self.builtin(DefaultFunction::Trace, &[message, body.delay(self.arena)])? + .force(self.arena) + } + CoreKind::Error => Term::error(self.arena), + CoreKind::Delay(t) => self.term(t)?.delay(self.arena), + CoreKind::Force(t) => self.term(t)?.force(self.arena), + CoreKind::LetRec { .. } => return Err(Error::Unlowered("recursion")), + }) + } + + fn case( + &mut self, + kind: CaseKind, + scrutinee: &'a Core<'a>, + branches: &'a [Branch<'a>], + default: Option<&'a Core<'a>>, + ) -> Result, Error> { + let scrutinee = self.term(scrutinee)?; + let fallback = match default { + Some(c) => self.term(c)?, + None => Term::error(self.arena), + }; + Ok(match kind { + CaseKind::Pair => { + let [branch] = branches else { + return Err(Error::InvalidCase("pair case requires one branch")); + }; + if branch.test != Test::Pair || branch.binders.len() != 2 || default.is_some() { + return Err(Error::InvalidCase("pair branch must bind its two fields")); + } + let body = self.term(branch.body)?; + Term::case( + self.arena, + scrutinee, + self.arena + .alloc_slice_copy(&[self.lambda(branch.binders, body)]), + ) + } + CaseKind::Bool => { + let mut yes = None; + let mut no = None; + for b in branches { + if !b.binders.is_empty() { + return Err(Error::InvalidCase("boolean branches bind no fields")); + } + let slot = match b.test { + Test::True => &mut yes, + Test::False => &mut no, + _ => return Err(Error::InvalidCase("non-boolean test")), }; - let scrutinee = results.pop().unwrap(); - if kind == CaseKind::Pair { - let [branch] = branches else { - return Err(Error::InvalidCase("pair case requires one branch")); - }; - if branch.test != Test::Pair || branch.binders.len() != 2 || has_default { - return Err(Error::InvalidCase("pair branch must bind its two fields")); - } + if slot.is_some() { + return Err(Error::InvalidCase("duplicate boolean branch")); } - if kind == CaseKind::Tag && has_default { - return Err(Error::InvalidCase( - "tag defaults must be expanded using constructor arities", - )); + *slot = Some(self.term(b.body)?); + } + self.branch(scrutinee, yes.unwrap_or(fallback), no.unwrap_or(fallback)) + } + CaseKind::Int | CaseKind::Bytes => { + let name = self.fresh()?; + let value = Term::var(self.arena, name); + let mut rest = fallback; + let mut seen = Vec::new(); + for b in branches.iter().rev() { + if !b.binders.is_empty() || seen.contains(&b.test) { + return Err(Error::InvalidCase("invalid literal branch")); } - let mut branches: Vec<_> = branches.iter().collect(); - let name = match kind { - CaseKind::Int | CaseKind::Bytes => { - branches.reverse(); - Some(self.fresh()?) + seen.push(b.test); + let (func, literal) = match (kind, b.test) { + (CaseKind::Int, Test::Int(i)) => { + (DefaultFunction::EqualsInteger, Term::integer(self.arena, i)) } - CaseKind::Data => Some(self.fresh()?), - CaseKind::Tag => { - branches.sort_by_key(|b| { - if let Test::Tag(tag) = b.test { - tag - } else { - u16::MAX - } - }); - None - } - _ => None, - }; - let count = match kind { - CaseKind::Bool | CaseKind::List => 2, - CaseKind::Data => 5, - _ => branches.len(), + (CaseKind::Bytes, Test::Bytes(bytes)) => ( + DefaultFunction::EqualsByteString, + Term::byte_string(self.arena, bytes), + ), + _ => return Err(Error::InvalidCase("literal test has wrong kind")), }; - pending.push(Task::CaseNext(CaseState { - kind, - scrutinee, - fallback, - branches, - index: 0, - name, - arms: vec![None; count], - seen: Vec::new(), - })); + let condition = self.builtin(func, &[value, literal])?; + let body = self.term(b.body)?; + rest = self.branch(condition, body, rest); } - Task::CaseNext(state) => { - if let Some(branch) = state.branches.get(state.index) { - let mut unwrap = None; - let mut condition = None; - let slot = match state.kind { - CaseKind::Pair => 0, - CaseKind::Bool => { - if !branch.binders.is_empty() { - return Err(Error::InvalidCase( - "boolean branches bind no fields", - )); - } - let slot = match branch.test { - Test::False => 0, - Test::True => 1, - _ => return Err(Error::InvalidCase("non-boolean test")), - }; - if state.arms[slot].is_some() { - return Err(Error::InvalidCase("duplicate boolean branch")); - } - slot - } - CaseKind::Int | CaseKind::Bytes => { - if !branch.binders.is_empty() || state.seen.contains(&branch.test) { - return Err(Error::InvalidCase("invalid literal branch")); - } - let (func, literal) = match (state.kind, branch.test) { - (CaseKind::Int, Test::Int(i)) => ( - DefaultFunction::EqualsInteger, - Term::integer(self.arena, i), - ), - (CaseKind::Bytes, Test::Bytes(bytes)) => ( - DefaultFunction::EqualsByteString, - Term::byte_string(self.arena, bytes), - ), - _ => { - return Err(Error::InvalidCase( - "literal test has wrong kind", - )); - } - }; - condition = Some(self.builtin( - func, - &[Term::var(self.arena, state.name.unwrap()), literal], - )?); - state.index - } - CaseKind::List => match branch.test { - Test::Nil - if state.arms[1].is_none() && branch.binders.is_empty() => - { - 1 - } - Test::Cons - if state.arms[0].is_none() && branch.binders.len() == 2 => - { - 0 - } - _ => return Err(Error::InvalidCase("invalid list branch")), - }, - CaseKind::Data => { - let (slot, func) = match branch.test { - Test::DataConstr => (0, DefaultFunction::UnConstrData), - Test::DataMap => (1, DefaultFunction::UnMapData), - Test::DataList => (2, DefaultFunction::UnListData), - Test::DataI => (3, DefaultFunction::UnIData), - Test::DataB => (4, DefaultFunction::UnBData), - _ => return Err(Error::InvalidCase("non-Data test")), - }; - if state.arms[slot].is_some() || branch.binders.len() != 1 { - return Err(Error::InvalidCase("invalid Data branch")); - } - unwrap = Some(func); - slot - } - CaseKind::Tag => { - if branch.test - != Test::Tag( - u16::try_from(state.index).map_err(|_| { - Error::InvalidCase("too many constructors") - })?, - ) - { - return Err(Error::InvalidCase( - "tag branches must cover consecutive unique tags", - )); - } - state.index - } - }; - let body = branch.body; - pending.push(Task::CaseBody(state, slot, unwrap, condition)); - pending.push(Task::Visit(body)); - } else { - let result = - match state.kind { - CaseKind::Pair | CaseKind::Tag => Term::case( - self.arena, - state.scrutinee, - self.arena.alloc_slice_copy( - &state - .arms - .into_iter() - .map(Option::unwrap) - .collect::>(), - ), - ), - CaseKind::Bool => self.branch( - state.scrutinee, - state.arms[1].unwrap_or(state.fallback), - state.arms[0].unwrap_or(state.fallback), - ), - CaseKind::Int | CaseKind::Bytes => state - .fallback - .lambda(self.arena, state.name.unwrap()) - .apply(self.arena, state.scrutinee), - CaseKind::List => { - let cons = match state.arms[0] { - Some(cons) => cons, - None => state - .fallback - .lambda(self.arena, self.fresh()?) - .lambda(self.arena, self.fresh()?), - }; - Term::case( - self.arena, - state.scrutinee, - self.arena.alloc_slice_copy(&[ - cons, - state.arms[1].unwrap_or(state.fallback), - ]), - ) - } - CaseKind::Data => { - let value = Term::var(self.arena, state.name.unwrap()); - let mut args = vec![value]; - args.extend(state.arms.into_iter().map(|arm| { - arm.unwrap_or(state.fallback).delay(self.arena) - })); - self.builtin(DefaultFunction::ChooseData, &args)? - .force(self.arena) - .lambda(self.arena, state.name.unwrap()) - .apply(self.arena, state.scrutinee) - } - }; - results.push(result); + rest.lambda(self.arena, name).apply(self.arena, scrutinee) + } + CaseKind::List => { + let mut nil = None; + let mut cons = None; + for b in branches { + match b.test { + Test::Nil if nil.is_none() && b.binders.is_empty() => { + nil = Some(self.term(b.body)?) + } + Test::Cons if cons.is_none() && b.binders.len() == 2 => { + let body = self.term(b.body)?; + cons = Some(self.lambda(b.binders, body)); + } + _ => return Err(Error::InvalidCase("invalid list branch")), } } - Task::CaseBody(mut state, slot, unwrap, condition) => { - let body = results.pop().unwrap(); - let branch = state.branches[state.index]; - if let Some(condition) = condition { - state.fallback = self.branch(condition, body, state.fallback); - state.seen.push(branch.test); - } else { - state.arms[slot] = Some(if let Some(unwrap) = unwrap { - if crate::build::names(branch.body) - .contains(&branch.binders[0].name.unique) - { - let value = Term::var(self.arena, state.name.unwrap()); - let unwrapped = self.builtin(unwrap, &[value])?; - self.lambda(branch.binders, body) - .apply(self.arena, unwrapped) - } else { - body - } + let cons = match cons { + Some(cons) => cons, + None => fallback + .lambda(self.arena, self.fresh()?) + .lambda(self.arena, self.fresh()?), + }; + let nil = nil.unwrap_or(fallback); + Term::case( + self.arena, + scrutinee, + self.arena.alloc_slice_copy(&[cons, nil]), + ) + } + CaseKind::Data => { + let name = self.fresh()?; + let value = Term::var(self.arena, name); + let mut arms = [None; 5]; + for b in branches { + let (index, unwrap) = match b.test { + Test::DataConstr => (0, DefaultFunction::UnConstrData), + Test::DataMap => (1, DefaultFunction::UnMapData), + Test::DataList => (2, DefaultFunction::UnListData), + Test::DataI => (3, DefaultFunction::UnIData), + Test::DataB => (4, DefaultFunction::UnBData), + _ => return Err(Error::InvalidCase("non-Data test")), + }; + if arms[index].is_some() || b.binders.len() != 1 { + return Err(Error::InvalidCase("invalid Data branch")); + } + let body = self.term(b.body)?; + arms[index] = Some( + if crate::build::names(b.body).contains(&b.binders[0].name.unique) { + let function = self.lambda(b.binders, body); + let unwrapped = self.builtin(unwrap, &[value])?; + function.apply(self.arena, unwrapped) } else { - self.lambda(branch.binders, body) - }); + body + }, + ); + } + let [constr, map, list, int, bytes] = + arms.map(|arm| arm.unwrap_or(fallback).delay(self.arena)); + self.builtin( + DefaultFunction::ChooseData, + &[value, constr, map, list, int, bytes], + )? + .force(self.arena) + .lambda(self.arena, name) + .apply(self.arena, scrutinee) + } + CaseKind::Tag => { + if default.is_some() { + return Err(Error::InvalidCase( + "tag defaults must be expanded using constructor arities", + )); + } + let mut ordered = branches.iter().collect::>(); + ordered.sort_by_key(|b| { + if let Test::Tag(tag) = b.test { + tag + } else { + u16::MAX } - state.index += 1; - pending.push(Task::CaseNext(state)); + }); + let mut arms = Vec::new(); + for (i, b) in ordered.into_iter().enumerate() { + if b.test + != Test::Tag( + u16::try_from(i) + .map_err(|_| Error::InvalidCase("too many constructors"))?, + ) + { + return Err(Error::InvalidCase( + "tag branches must cover consecutive unique tags", + )); + } + let body = self.term(b.body)?; + arms.push(self.lambda(b.binders, body)); } + Term::case(self.arena, scrutinee, self.arena.alloc_slice_copy(&arms)) } - } - Ok(results.pop().unwrap()) + }) } } diff --git a/crates/nash-codegen/src/lower/constant_sharing.rs b/crates/nash-codegen/src/lower/constant_sharing.rs index 859a07c5..1f6cdae4 100644 --- a/crates/nash-codegen/src/lower/constant_sharing.rs +++ b/crates/nash-codegen/src/lower/constant_sharing.rs @@ -81,60 +81,41 @@ pub(super) fn share<'a>(lower: &mut Lower<'a>, root: Uplc<'a>) -> Result(lower: &Lower<'a>, term: Uplc<'a>, entries: &[Entry<'a>]) -> Uplc<'a> { - let mut pending = vec![(term, false, 0)]; - let mut results: Vec> = Vec::new(); + if let Some((func, literal)) = prefix(lower, term) + && let Some(entry) = entries + .iter() + .find(|e| e.func == func && e.literal == literal) + { + return Term::var(lower.arena, entry.name.unwrap()); + } let a = lower.arena; - while let Some((term, finish, start)) = pending.pop() { - if !finish { - if let Some((func, literal)) = prefix(lower, term) - && let Some(entry) = entries - .iter() - .find(|e| e.func == func && e.literal == literal) - { - results.push(Term::var(a, entry.name.unwrap())); - continue; - } - pending.push((term, true, results.len())); - match term { - Term::Apply { function, argument } => { - pending.push((argument, false, 0)); - pending.push((function, false, 0)); - } - Term::Lambda { body, .. } | Term::Force(body) | Term::Delay(body) => { - pending.push((body, false, 0)) - } - Term::Case { constr, branches } => { - pending.push((constr, false, 0)); - pending.extend(branches.iter().rev().map(|t| (*t, false, 0))); - } - Term::Constr { fields, .. } => { - pending.extend(fields.iter().rev().map(|t| (*t, false, 0))) - } - _ => {} - } - } else { - let mut children = results.drain(start..); - let result = match term { - Term::Apply { .. } => children.next().unwrap().apply(a, children.next().unwrap()), - Term::Lambda { parameter, .. } => children.next().unwrap().lambda(a, parameter), - Term::Force(_) => children.next().unwrap().force(a), - Term::Delay(_) => children.next().unwrap().delay(a), - Term::Case { branches, .. } => { - let branches: Vec<_> = children.by_ref().take(branches.len()).collect(); - Term::case(a, children.next().unwrap(), a.alloc_slice_copy(&branches)) - } - Term::Constr { tag, .. } => Term::constr( - a, - *tag, - a.alloc_slice_copy(&children.by_ref().collect::>()), - ), - _ => term, - }; - drop(children); - results.push(result); + match term { + Term::Apply { function, argument } => { + rewrite(lower, function, entries).apply(a, rewrite(lower, argument, entries)) + } + Term::Lambda { parameter, body } => rewrite(lower, body, entries).lambda(a, parameter), + Term::Force(body) => rewrite(lower, body, entries).force(a), + Term::Delay(body) => rewrite(lower, body, entries).delay(a), + Term::Case { constr, branches } => { + let branches: Vec<_> = branches + .iter() + .map(|branch| rewrite(lower, branch, entries)) + .collect(); + Term::case( + a, + rewrite(lower, constr, entries), + a.alloc_slice_copy(&branches), + ) + } + Term::Constr { tag, fields } => { + let fields: Vec<_> = fields + .iter() + .map(|field| rewrite(lower, field, entries)) + .collect(); + Term::constr(a, *tag, a.alloc_slice_copy(&fields)) } + _ => term, } - results.pop().unwrap() } #[cfg(test)] diff --git a/crates/nash-codegen/src/recursion.rs b/crates/nash-codegen/src/recursion.rs index 704e1b54..31ec2eea 100644 --- a/crates/nash-codegen/src/recursion.rs +++ b/crates/nash-codegen/src/recursion.rs @@ -78,29 +78,6 @@ struct Rewriter<'b, 'a> { used: HashSet, } -// Continuations retain unfinished work on the heap, including nested groups. -enum Task<'a> { - Visit(&'a Core<'a>, Environment<'a>), - Finish(Continuation<'a>), -} -type Continuation<'a> = Box< - dyn FnOnce( - &mut Rewriter<'_, 'a>, - &mut Vec>, - &mut Vec<&'a Core<'a>>, - ) -> Result>, Error> - + 'a, ->; -struct Mutual<'a> { - binders: &'a [RecBinder<'a>], - outer: Environment<'a>, - continuation: &'a Core<'a>, - arms: &'a [DispatchArm<'a>], - dispatcher: Binder<'a>, - request: Binder<'a>, - branches: Vec>, -} - impl<'a> Rewriter<'_, 'a> { fn fresh(&mut self, text: &'a str, ty: Ty<'a>) -> Binder<'a> { loop { @@ -125,58 +102,23 @@ impl<'a> Rewriter<'_, 'a> { self.build.lam(¶ms, self.build.app(func, &args, result)) } fn term(&mut self, core: &'a Core<'a>, env: &Environment<'a>) -> Result<&'a Core<'a>, Error> { - let mut tasks = vec![Task::Visit(core, env.clone())]; - let mut results = Vec::new(); - while let Some(task) = tasks.pop() { - match task { - Task::Visit(core, env) => self.schedule(core, env, &mut tasks, &mut results)?, - Task::Finish(finish) => { - if let Some(result) = finish(self, &mut tasks, &mut results)? { - results.push(result); - } - } - } - } - Ok(results.pop().unwrap()) - } - - fn schedule( - &mut self, - core: &'a Core<'a>, - env: Environment<'a>, - tasks: &mut Vec>, - results: &mut Vec<&'a Core<'a>>, - ) -> Result<(), Error> { let b = self.build; - match core.kind { - CoreKind::Var(n) => { - results.push(b.with_type(env.get(&n).map_or(core, |r| r.value), core.ty)); - return Ok(()); - } - CoreKind::Lit(_) | CoreKind::Error => { - results.push(core); - return Ok(()); - } - CoreKind::LetRec { binders, body } => { - return self.group(core.ty, binders, body, env, tasks); - } - _ => {} - } - let start = results.len(); - if let CoreKind::App { - func: Core { - kind: CoreKind::Var(n), - .. - }, - args, - } = core.kind - { - if let Some((dispatcher, tag, arms)) = env.get(n).and_then(|r| r.packet) - && args.len() >= arms[usize::from(tag)].params.len() - { - tasks.push(Task::Finish(Box::new(move |this, _, results| { - let b = this.build; - let args: Vec<_> = results.drain(start..).collect(); + let result = match &core.kind { + CoreKind::Var(n) => env.get(n).map_or(core, |r| r.value), + CoreKind::Lit(_) | CoreKind::Error => core, + CoreKind::Lam { params, body } => b.lam( + params, + self.term(body, &without(env, params.iter().map(|p| p.name)))?, + ), + CoreKind::App { func, args } => { + if let CoreKind::Var(n) = &func.kind + && let Some((dispatcher, tag, arms)) = env.get(n).and_then(|r| r.packet) + && args.len() >= arms[usize::from(tag)].params.len() + { + let args = args + .iter() + .map(|arg| self.term(arg, env)) + .collect::, _>>()?; let arm = &arms[usize::from(tag)]; let arity = arm.params.len(); let mut fields = vec![b.var(dispatcher.name, dispatcher.ty)]; @@ -187,154 +129,117 @@ impl<'a> Rewriter<'_, 'a> { &[b.constr(tag, &fields, packet_ty)], arm.result, ); - let result = if args.len() == arity { + if args.len() == arity { call } else { b.app(call, &args[arity..], core.ty) - }; - Ok(Some(b.with_type(result, core.ty))) - }))); - tasks.extend(args.iter().rev().map(|arg| Task::Visit(arg, env.clone()))); - return Ok(()); - } - if let Some(Replacement { - direct: Some((target, excluded)), - .. - }) = env.get(n) - { - let target = *target; - tasks.push(Task::Finish(Box::new(move |this, _, results| { - let args: Vec<_> = results.drain(start..).collect(); - Ok(Some(this.build.app(target, &args, core.ty))) - }))); - tasks.extend( - args.iter() + } + } else if let CoreKind::Var(n) = &func.kind + && let Some(Replacement { + direct: Some((target, excluded)), + .. + }) = env.get(n) + { + let args = args + .iter() .enumerate() - .rev() .filter(|(i, _)| !excluded.iter().any(|x| usize::from(*x) == *i)) - .map(|(_, arg)| Task::Visit(arg, env.clone())), - ); - return Ok(()); - } - } - tasks.push(Task::Finish(Box::new(move |this, _, results| { - let b = this.build; - let mut children = results.drain(start..); - let result = match core.kind { - CoreKind::Lam { params, .. } => b.lam(params, children.next().unwrap()), - CoreKind::App { .. } => { - let func = children.next().unwrap(); - b.app(func, &children.by_ref().collect::>(), core.ty) - } - CoreKind::Let { binder, .. } => { - b.let_(binder, children.next().unwrap(), children.next().unwrap()) - } - CoreKind::Case { - kind, - branches, - default, - .. - } => { - let scrutinee = children.next().unwrap(); - let branches: Vec<_> = branches + .map(|(_, arg)| self.term(arg, env)) + .collect::, _>>()?; + b.app(target, &args, core.ty) + } else { + let func = self.term(func, env)?; + let args = args .iter() - .map(|branch| Branch { - body: children.next().unwrap(), - ..*branch - }) - .collect(); - let default = default.map(|_| children.next().unwrap()); - b.case(kind, scrutinee, &branches, default, core.ty) - } - CoreKind::Constr { tag, .. } => { - b.constr(tag, &children.by_ref().collect::>(), core.ty) - } - CoreKind::Field { index, arity, .. } => b.alloc( - core.ty, - CoreKind::Field { - record: children.next().unwrap(), - index, - arity, - }, - ), - CoreKind::Builtin { func, .. } => b.alloc( - core.ty, - CoreKind::Builtin { - func, - args: b - .arena - .alloc_slice_copy(&children.by_ref().collect::>()), - }, - ), - CoreKind::Trace { .. } => { - b.trace(children.next().unwrap(), children.next().unwrap()) + .map(|arg| self.term(arg, env)) + .collect::, _>>()?; + b.app(func, &args, core.ty) } - CoreKind::Delay(_) => b.delay(children.next().unwrap()), - CoreKind::Force(_) => b.force(children.next().unwrap(), core.ty), - _ => unreachable!(), - }; - Ok(Some(b.with_type(result, core.ty))) - }))); - match core.kind { - CoreKind::Lam { params, body } => tasks.push(Task::Visit( - body, - without(&env, params.iter().map(|p| p.name)), - )), + } CoreKind::Let { binder, value, body, - } => { - tasks.push(Task::Visit(body, without(&env, [binder.name]))); - tasks.push(Task::Visit(value, env)); - } + } => b.let_( + *binder, + self.term(value, env)?, + self.term(body, &without(env, [binder.name]))?, + ), + CoreKind::LetRec { binders, body } => self.group(binders, body, env)?, CoreKind::Case { + kind, scrutinee, branches, default, - .. } => { - if let Some(body) = default { - tasks.push(Task::Visit(body, env.clone())); - } - tasks.extend(branches.iter().rev().map(|branch| { - Task::Visit( - branch.body, - without(&env, branch.binders.iter().map(|p| p.name)), - ) - })); - tasks.push(Task::Visit(scrutinee, env)); + let scrutinee = self.term(scrutinee, env)?; + let branches = branches + .iter() + .map(|branch| { + Ok(Branch { + body: self.term( + branch.body, + &without(env, branch.binders.iter().map(|p| p.name)), + )?, + ..*branch + }) + }) + .collect::, Error>>()?; + let default = default.map(|d| self.term(d, env)).transpose()?; + b.case(*kind, scrutinee, &branches, default, core.ty) } - _ => { - let mut children = Vec::new(); - core.push_children_reversed(&mut children); - tasks.extend( - children - .into_iter() - .map(|child| Task::Visit(child, env.clone())), - ); + CoreKind::Constr { tag, fields } => { + let fields = fields + .iter() + .map(|f| self.term(f, env)) + .collect::, _>>()?; + b.constr(*tag, &fields, core.ty) } - } - Ok(()) + CoreKind::Field { + record, + index, + arity, + } => b.alloc( + core.ty, + CoreKind::Field { + record: self.term(record, env)?, + index: *index, + arity: *arity, + }, + ), + CoreKind::Builtin { func, args } => { + let args = args + .iter() + .map(|a| self.term(a, env)) + .collect::, _>>()?; + b.alloc( + core.ty, + CoreKind::Builtin { + func: *func, + args: b.arena.alloc_slice_copy(&args), + }, + ) + } + CoreKind::Trace { message, body } => { + b.trace(self.term(message, env)?, self.term(body, env)?) + } + CoreKind::Delay(body) => b.delay(self.term(body, env)?), + CoreKind::Force(body) => b.force(self.term(body, env)?, core.ty), + }; + Ok(b.with_type(result, core.ty)) } - fn group( &mut self, - ty: Ty<'a>, binders: &'a [RecBinder<'a>], body: &'a Core<'a>, - env: Environment<'a>, - tasks: &mut Vec>, - ) -> Result<(), Error> { + env: &Environment<'a>, + ) -> Result<&'a Core<'a>, Error> { let b = self.build; - // Retain the original type view even for empty groups. - tasks.push(Task::Finish(Box::new(move |this, _, results| { - Ok(Some(this.build.with_type(results.pop().unwrap(), ty))) - }))); if binders.is_empty() { - tasks.push(Task::Visit(body, env)); - return Ok(()); + return self.term(body, env); } + // A fully static worker is an explicit delayed recursive value. Knot + // creation returns the delay; each original call still forces its body. if let [single] = binders && single.params.is_empty() && matches!(single.body.kind, CoreKind::Delay(_)) @@ -355,23 +260,11 @@ impl<'a> Rewriter<'_, 'a> { direct: None, }, ); - tasks.push(Task::Finish(Box::new(move |this, tasks, results| { - let b = this.build; - let inner_body = results.pop().unwrap(); - let inner = b.with_type(b.lam(&[self_arg], inner_body), self_ty); - let knot = b.app(b.lam(&[self_arg], raw), &[inner], single.binder.ty); - tasks.push(Task::Finish(Box::new(move |this, _, results| { - Ok(Some(this.build.let_( - single.binder, - knot, - results.pop().unwrap(), - ))) - }))); - tasks.push(Task::Visit(body, without(&env, [single.binder.name]))); - Ok(None) - }))); - tasks.push(Task::Visit(single.body, inner_env)); - return Ok(()); + let inner_body = self.term(single.body, &inner_env)?; + let inner = b.with_type(b.lam(&[self_arg], inner_body), self_ty); + let knot = b.app(b.lam(&[self_arg], raw), &[inner], single.binder.ty); + let continuation = self.term(body, &without(env, [single.binder.name]))?; + return Ok(b.let_(single.binder, knot, continuation)); } if binders .iter() @@ -379,29 +272,15 @@ impl<'a> Rewriter<'_, 'a> { { return Err(Error::RecursiveValue); } - let names: HashSet<_> = binders.iter().map(|r| r.binder.name).collect(); + let names = binders + .iter() + .map(|r| r.binder.name) + .collect::>(); if names.len() != binders.len() { return Err(Error::DuplicateBinder); } - let outer = without(&env, names); - let outer_body = outer.clone(); - tasks.push(Task::Finish(Box::new(move |this, tasks, results| { - let continuation = results.pop().unwrap(); - this.group_after_continuation(binders, outer, continuation, tasks)?; - Ok(None) - }))); - tasks.push(Task::Visit(body, outer_body)); - Ok(()) - } - - fn group_after_continuation( - &mut self, - binders: &'a [RecBinder<'a>], - outer: Environment<'a>, - continuation: &'a Core<'a>, - tasks: &mut Vec>, - ) -> Result<(), Error> { - let b = self.build; + let outer = without(env, names.iter().copied()); + let continuation = self.term(body, &outer)?; if let [single] = binders { // Recheck metadata: a stale static annotation must never discard work. let proven = static_params(single.binder.name, single.params, single.body); @@ -454,47 +333,41 @@ impl<'a> Rewriter<'_, 'a> { direct: Some((self_call, statics.clone())), }, ); - tasks.push(Task::Finish(Box::new(move |this, _, results| { - let b = this.build; - let inner_body = results.pop().unwrap(); - - let mut inner_params = vec![self_arg]; - inner_params.extend_from_slice(&dynamic); - // If every parameter is static, delay the worker to keep knot creation lazy. - let inner = b.lam( - &inner_params, - if dynamic.is_empty() { - b.delay(inner_body) - } else { - inner_body - }, - ); - let inner = b.with_type(inner, self_ty); - let knot = b.app(b.lam(&[self_arg], raw_self_call), &[inner], worker_ty); - let definition = if statics.is_empty() { - knot + let inner_body = self.term(single.body, &inner_env)?; + let mut inner_params = vec![self_arg]; + inner_params.extend_from_slice(&dynamic); + // If every parameter is static, delay the worker to keep knot creation lazy. + let inner = b.lam( + &inner_params, + if dynamic.is_empty() { + b.delay(inner_body) } else { - let applied = if dynamic.is_empty() { - b.force(knot, result_ty) - } else { - b.app( - knot, - &dynamic - .iter() - .map(|p| b.var(p.name, p.ty)) - .collect::>(), - result_ty, - ) - }; - b.lam(single.params, applied) + inner_body + }, + ); + let inner = b.with_type(inner, self_ty); + let knot = b.app(b.lam(&[self_arg], raw_self_call), &[inner], worker_ty); + let definition = if statics.is_empty() { + knot + } else { + let applied = if dynamic.is_empty() { + b.force(knot, result_ty) + } else { + b.app( + knot, + &dynamic + .iter() + .map(|p| b.var(p.name, p.ty)) + .collect::>(), + result_ty, + ) }; - // Recursive calls into a fully static worker must force its delayed body. - // Such calls cannot terminate by changing an argument, but can occur in - // dead branches; represent them correctly without evaluating them early. - Ok(Some(b.let_(single.binder, definition, continuation))) - }))); - tasks.push(Task::Visit(single.body, inner_env)); - return Ok(()); + b.lam(single.params, applied) + }; + // Recursive calls into a fully static worker must force its delayed body. + // Such calls cannot terminate by changing an argument, but can occur in + // dead branches; represent them correctly without evaluating them early. + return Ok(b.let_(single.binder, definition, continuation)); } if binders.len() > usize::from(u16::MAX) + 1 { return Err(Error::TooManyFunctions); @@ -516,80 +389,58 @@ impl<'a> Rewriter<'_, 'a> { let arms = b.arena.alloc_slice_copy(&arms); let dispatcher_ty = Ty::Runtime(b.arena.alloc(RuntimeTy::Dispatcher(arms))); let request_ty = Ty::Runtime(b.arena.alloc(RuntimeTy::Request(arms))); + let results_ty = Ty::Runtime(b.arena.alloc(RuntimeTy::Results(arms))); let dispatcher = self.fresh("dispatch", dispatcher_ty); let request = self.fresh("request", request_ty); - self.mutual( - Mutual { - binders, - outer, - continuation, - arms, - dispatcher, - request, - branches: Vec::new(), - }, - tasks, - ) - } - - fn mutual(&mut self, mut state: Mutual<'a>, tasks: &mut Vec>) -> Result<(), Error> { - let b = self.build; - let index = state.branches.len(); - if let Some(rb) = state.binders.get(index) { - let self_arg = self.fresh("self", state.dispatcher.ty); - let mut recursive_env = state.outer.clone(); - for (target, callee) in state.binders.iter().enumerate() { - let alias = self.packet_alias(self_arg, target as u16, callee.params, state.arms); + let mut branches = Vec::new(); + for (index, rb) in binders.iter().enumerate() { + let self_arg = self.fresh("self", dispatcher_ty); + let mut recursive_env = outer.clone(); + for (target, callee) in binders.iter().enumerate() { + let alias = self.packet_alias(self_arg, target as u16, callee.params, arms); recursive_env.insert( callee.binder.name, Replacement { value: alias, - packet: Some((self_arg, target as u16, state.arms)), + packet: Some((self_arg, target as u16, arms)), direct: None, }, ); } - let env = without(&recursive_env, rb.params.iter().map(|p| p.name)); - tasks.push(Task::Finish(Box::new(move |this, tasks, results| { - let mut params = vec![self_arg]; - params.extend_from_slice(rb.params); - state.branches.push(Branch { - test: Test::Tag(index as u16), - binders: this.build.arena.alloc_slice_copy(¶ms), - body: results.pop().unwrap(), - }); - this.mutual(state, tasks)?; - Ok(None) - }))); - let body = if let ([], CoreKind::Delay(body)) = (rb.params, rb.body.kind) { + let member = if let ([], CoreKind::Delay(body)) = (rb.params, rb.body.kind) { body } else { rb.body }; - tasks.push(Task::Visit(body, env)); - } else { - let results_ty = Ty::Runtime(b.arena.alloc(RuntimeTy::Results(state.arms))); - let dispatch = b.lam( - &[state.request], - b.case( - CaseKind::Tag, - b.var(state.request.name, state.request.ty), - &state.branches, - None, - results_ty, - ), - ); - let dispatch = b.with_type(dispatch, state.dispatcher.ty); - let mut result = state.continuation; - for (index, rb) in state.binders.iter().enumerate().rev() { - let alias = - self.packet_alias(state.dispatcher, index as u16, rb.params, state.arms); - result = b.let_(rb.binder, alias, result); - } - let result = b.let_(state.dispatcher, dispatch, result); - tasks.push(Task::Finish(Box::new(move |_, _, _| Ok(Some(result))))); + let body = self.term( + member, + &without(&recursive_env, rb.params.iter().map(|p| p.name)), + )?; + let mut params = vec![self_arg]; + params.extend_from_slice(rb.params); + branches.push(Branch { + test: Test::Tag(index as u16), + binders: b.arena.alloc_slice_copy(¶ms), + body, + }); } - Ok(()) + let dispatch = b.lam( + &[request], + b.case( + CaseKind::Tag, + b.var(request.name, request.ty), + &branches, + None, + results_ty, + ), + ); + let dispatch = b.with_type(dispatch, dispatcher_ty); + let mut result = continuation; + for (index, rb) in binders.iter().enumerate().rev() { + let alias = self.packet_alias(dispatcher, index as u16, rb.params, arms); + result = b.let_(rb.binder, alias, result); + } + Ok(b.let_(dispatcher, dispatch, result)) } fn packet_alias( @@ -640,59 +491,69 @@ fn visit<'a>( scope: &HashSet>, f: &mut impl FnMut(&Core<'a>, &HashSet>), ) { - let mut pending = vec![(core, scope.clone())]; - let mut children = Vec::new(); - while let Some((core, scope)) = pending.pop() { - f(core, &scope); - let extended = |names: Vec>| { - let mut inner = scope.clone(); - inner.extend(names); - inner - }; - match core.kind { - CoreKind::Lam { params, body } => { - pending.push((body, extended(params.iter().map(|p| p.name).collect()))) + f(core, scope); + let extended = |names: Vec>| { + let mut s = scope.clone(); + s.extend(names); + s + }; + match &core.kind { + CoreKind::Lam { params, body } => { + visit(body, &extended(params.iter().map(|p| p.name).collect()), f) + } + CoreKind::App { func, args } => { + visit(func, scope, f); + for arg in *args { + visit(arg, scope, f); } - CoreKind::Let { - binder, - value, - body, - } => { - pending.push((body, extended(vec![binder.name]))); - pending.push((value, scope)); + } + CoreKind::Let { + binder, + value, + body, + } => { + visit(value, scope, f); + visit(body, &extended(vec![binder.name]), f); + } + CoreKind::LetRec { binders, body } => { + let group = extended(binders.iter().map(|b| b.binder.name).collect()); + for rb in *binders { + let mut inner = group.clone(); + inner.extend(rb.params.iter().map(|p| p.name)); + visit(rb.body, &inner, f); } - CoreKind::LetRec { binders, body } => { - let group = extended(binders.iter().map(|r| r.binder.name).collect()); - pending.push((body, group.clone())); - for rb in binders.iter().rev() { - let mut inner = group.clone(); - inner.extend(rb.params.iter().map(|p| p.name)); - pending.push((rb.body, inner)); - } + visit(body, &group, f); + } + CoreKind::Case { + scrutinee, + branches, + default, + .. + } => { + visit(scrutinee, scope, f); + for b in *branches { + visit( + b.body, + &extended(b.binders.iter().map(|p| p.name).collect()), + f, + ); } - CoreKind::Case { - scrutinee, - branches, - default, - .. - } => { - if let Some(body) = default { - pending.push((body, scope.clone())); - } - pending.extend(branches.iter().rev().map(|branch| { - ( - branch.body, - extended(branch.binders.iter().map(|p| p.name).collect()), - ) - })); - pending.push((scrutinee, scope)); + if let Some(d) = default { + visit(d, scope, f); } - _ => { - children.clear(); - core.push_children_reversed(&mut children); - pending.extend(children.iter().map(|child| (*child, scope.clone()))); + } + CoreKind::Constr { fields: args, .. } | CoreKind::Builtin { args, .. } => { + for arg in *args { + visit(arg, scope, f); } } + CoreKind::Field { record, .. } => visit(record, scope, f), + CoreKind::Trace { message, body } => { + visit(message, scope, f); + visit(body, scope, f); + } + CoreKind::Delay(body) | CoreKind::Force(body) => visit(body, scope, f), + CoreKind::Var(_) | CoreKind::Lit(_) | CoreKind::Error => {} } } diff --git a/docs/codegen.md b/docs/codegen.md index 897be6a9..6065d8e1 100644 --- a/docs/codegen.md +++ b/docs/codegen.md @@ -847,10 +847,9 @@ all traces generated by the selected trace settings; it does not strip traces. Snapshot and performance pipelines reuse this production optimizer. Explicit `comptime` evaluation retains O0 and its existing budget semantics. Core traversal, scope-aware rewriting, ANF, recursion encoding, lowering and UPLC -conversion/validation use explicit heap work lists rather than depth-dependent -Rust calls. Assembly does not enlarge the thread stack. See the -[stack audit](research/compiler-stack-audit.md) for remaining frontend, diagnostic -and nested-type risks. +conversion/validation recurse. The driver runs assembly on the compiler stack +(`nash_driver::stack`). See the [stack audit](research/compiler-stack-audit.md) +for the stack model. ### Field offset extraction diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index f728ecc1..0afd1a04 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -20,7 +20,8 @@ programs on its worker pool, so the CLI gives those workers the compiler stack. The UPLC term printer, De Bruijn conversion, and target validation (`pretty::term`, `to_debruijn`, `validate_*`) recurse again. So do the Core traversals of `nash-ir`: `walk`, `map`, occurrence and free-variable analysis, -discard checks, hygiene, ANF, beta splicing, and Core printing. +discard checks, hygiene, ANF, beta splicing, and Core printing; and in +`nash-codegen` lowering, constant-sharing rewriting, and recursion rewriting. Default O1 exposed a stack overflow in the driver `ledger_contexts` fixture. This audit distinguishes reproduced failures from recursive paths that still diff --git a/plans/08-optimizer.md b/plans/08-optimizer.md index de0af868..8e00a84a 100644 --- a/plans/08-optimizer.md +++ b/plans/08-optimizer.md @@ -1897,9 +1897,9 @@ cleanup loops after the single ANF normalization. Post-recursion work is freshening and optimized lowering with builtin/constant sharing, never a second normalization. Explicit comptime execution remains O0. Chunks 9–10 and the final convergence review are complete. -Default-O1 validation exposed deep-tree stack overflows; assembly traversals now use -heap work lists, with no stack enlargement; UPLC term printing is also iterative. Remaining depth risks and -follow-up probes are recorded in [the compiler stack audit](../docs/research/compiler-stack-audit.md). +Default-O1 validation exposed deep-tree stack overflows; compiler work now runs on +a 128 MiB stack (`nash_driver::stack`) and the assembly traversals recurse. The stack +model is recorded in [the compiler stack audit](../docs/research/compiler-stack-audit.md). Compose only the accepted passes. Establish their actual order from interactions: inlining exposes dead code and known cases, sharing can conflict with inlining, From 82b2b773cc962799b111bb64c31321c569f2e663 Mon Sep 17 00:00:00 2001 From: microproofs Date: Mon, 5 Oct 2026 21:33:05 +0700 Subject: [PATCH 7/9] refactor: remove unused compiler helpers and ignored parameters Signed-off-by: microproofs --- .../remove-unused-compiler-helpers.md | 8 ++ crates/nash-codegen/src/can_to_core.rs | 2 +- .../nash-codegen/src/can_to_core/patterns.rs | 2 - crates/nash-config/src/parse.rs | 5 +- crates/nash-constrain/src/type_.rs | 14 ---- crates/nash-driver/src/compile.rs | 4 +- crates/nash-driver/src/database.rs | 83 ------------------- crates/nash-driver/src/graph.rs | 27 ------ crates/nash-report/src/code.rs | 9 -- crates/nash-report/src/doc.rs | 26 ------ crates/nash-report/src/type_diff.rs | 6 -- crates/nash-test/src/types.rs | 1 - docs/research/compiler-stack-audit.md | 9 ++ 13 files changed, 22 insertions(+), 174 deletions(-) create mode 100644 .sampo/changesets/remove-unused-compiler-helpers.md diff --git a/.sampo/changesets/remove-unused-compiler-helpers.md b/.sampo/changesets/remove-unused-compiler-helpers.md new file mode 100644 index 00000000..366a05b2 --- /dev/null +++ b/.sampo/changesets/remove-unused-compiler-helpers.md @@ -0,0 +1,8 @@ +--- +cargo/nash-driver: minor +cargo/nash-constrain: minor +cargo/nash-report: minor +cargo/nash-test: minor +--- + +Remove unused dependency bookkeeping, report and type helpers, and the unused FailureKind alias. Compilation, diagnostics, and supported editor behavior are unchanged. diff --git a/crates/nash-codegen/src/can_to_core.rs b/crates/nash-codegen/src/can_to_core.rs index 823a49cf..f691bb47 100644 --- a/crates/nash-codegen/src/can_to_core.rs +++ b/crates/nash-codegen/src/can_to_core.rs @@ -134,7 +134,7 @@ impl<'a> Engine<'a, '_, '_> { Binding::Template { id, projection } => { let binder = self.use_template(id, node, ctx)?; if let Some(name) = projection { - self.destruct_projection(id, name, binder, node, ctx)? + self.destruct_projection(id, name, binder)? } else { self.ir.var(binder.name, binder.ty) } diff --git a/crates/nash-codegen/src/can_to_core/patterns.rs b/crates/nash-codegen/src/can_to_core/patterns.rs index 4857ab09..cbb9c925 100644 --- a/crates/nash-codegen/src/can_to_core/patterns.rs +++ b/crates/nash-codegen/src/can_to_core/patterns.rs @@ -264,8 +264,6 @@ impl<'a> Engine<'a, '_, '_> { id: usize, name: &'a str, binder: Binder<'a>, - _node: NodeId, - _ctx: &Context<'a>, ) -> Result<&'a Core<'a>, Error<'a>> { let Source::Destruct { pattern, .. } = self.templates[id].source else { return Err(Error::InvalidConstructor); diff --git a/crates/nash-config/src/parse.rs b/crates/nash-config/src/parse.rs index 200edd68..3720173d 100644 --- a/crates/nash-config/src/parse.rs +++ b/crates/nash-config/src/parse.rs @@ -388,7 +388,7 @@ fn parse_exposed_modules( ) -> Result { // Array = flat list if let Some(arr) = value.as_array() { - let modules = parse_string_array_inner(contents, path, arr, "exposedModules")?; + let modules = parse_string_array_inner(contents, path, arr)?; return Ok(ExposedModules::List(modules)); } @@ -420,14 +420,13 @@ fn parse_string_array( let arr = value.as_array().ok_or_else(|| { ConfigError::expected_array(path, field_name, position_of(contents, value.range())) })?; - parse_string_array_inner(contents, path, arr, field_name) + parse_string_array_inner(contents, path, arr) } fn parse_string_array_inner( contents: &str, path: &Path, arr: &Array, - _field_name: &str, ) -> Result, ConfigError> { let mut result = Vec::new(); diff --git a/crates/nash-constrain/src/type_.rs b/crates/nash-constrain/src/type_.rs index 8b0a3113..948c157b 100644 --- a/crates/nash-constrain/src/type_.rs +++ b/crates/nash-constrain/src/type_.rs @@ -147,10 +147,6 @@ pub struct Mark(u32); pub const NO_MARK: Mark = Mark(2); pub const OCCURS_MARK: Mark = Mark(1); -/// Reserved in Elm for `getVarNames` visit tracking. Nash's `get_var_names` -/// uses a per-call seen set instead (see `nash-solve/src/annotation.rs`), -/// but the mark stays reserved so the mark space matches Elm's. -pub const GET_VAR_NAMES_MARK: Mark = Mark(0); impl Mark { pub fn next(self) -> Mark { @@ -223,13 +219,3 @@ pub fn mk_flex_var<'a>(uf: &mut UnionFind<'a>) -> Variable { pub const fn unnamed_flex_var<'a>() -> Content<'a> { Content::FlexVar(None) } - -// MAKE NAMED VARIABLES - -pub fn name_to_flex<'a>(uf: &mut UnionFind<'a>, name: &'a str) -> Variable { - uf.fresh(make_descriptor(Content::FlexVar(Some(name)))) -} - -pub fn name_to_rigid<'a>(uf: &mut UnionFind<'a>, name: &'a str) -> Variable { - uf.fresh(make_descriptor(Content::RigidVar(name))) -} diff --git a/crates/nash-driver/src/compile.rs b/crates/nash-driver/src/compile.rs index 4bc1f616..dc0741a0 100644 --- a/crates/nash-driver/src/compile.rs +++ b/crates/nash-driver/src/compile.rs @@ -621,7 +621,7 @@ fn scan_module( .filter(|_| include_tests) .flat_map(|tests| tests.imports.iter()), ) { - if let Some(uri) = resolve_import(import.import.value, current, known_modules) + if let Some(uri) = resolve_import(import.import.value, known_modules) && !imports.contains(&uri) { imports.push(uri); @@ -646,7 +646,7 @@ fn scan_module( /// - Package dependencies /// - Source directory structure /// - Module naming conventions -fn resolve_import(name: &str, _current: &Url, known_modules: &[Url]) -> Option { +fn resolve_import(name: &str, known_modules: &[Url]) -> Option { let bundled = crate::bundled_base::uri(name); if known_modules.contains(&bundled) { return Some(bundled); diff --git a/crates/nash-driver/src/database.rs b/crates/nash-driver/src/database.rs index b3163c89..956249a9 100644 --- a/crates/nash-driver/src/database.rs +++ b/crates/nash-driver/src/database.rs @@ -16,19 +16,12 @@ use crate::source::FileSource; /// The database provides: /// - File reading via a `FileSource` abstraction /// - Caching of source text -/// - Dependency tracking for invalidation pub struct Database { /// File source for reading/writing files. source: Arc, /// Cached source text keyed by URI. files: HashMap, - - /// Import relationships: module -> modules it imports. - imports: HashMap>, - - /// Reverse dependencies: module -> modules that import it. - reverse_deps: HashMap>, } impl Database { @@ -37,8 +30,6 @@ impl Database { Database { source: Arc::new(crate::bundled_base::BundledSource(source)), files: HashMap::new(), - imports: HashMap::new(), - reverse_deps: HashMap::new(), } } @@ -102,62 +93,11 @@ impl Database { self.source.write(uri, content).await } - /// Record that `module` imports `imported`. - pub fn record_import(&mut self, module: &Url, imported: Url) { - self.imports - .entry(module.clone()) - .or_default() - .push(imported.clone()); - - self.reverse_deps - .entry(imported) - .or_default() - .push(module.clone()); - } - - /// Get modules that `module` imports. - pub fn imports_of(&self, module: &Url) -> &[Url] { - self.imports - .get(module) - .map(|v| v.as_slice()) - .unwrap_or(&[]) - } - - /// Get modules that import `module`. - pub fn importers_of(&self, module: &Url) -> &[Url] { - self.reverse_deps - .get(module) - .map(|v| v.as_slice()) - .unwrap_or(&[]) - } - /// Invalidate a file's cached content. /// /// This removes the file from the cache, forcing a re-read on next access. - /// It does NOT cascade to reverse dependencies - use `invalidate_cascade` for that. pub fn invalidate(&mut self, uri: &Url) { self.files.remove(uri); - self.imports.remove(uri); - } - - /// Invalidate a file and all files that depend on it. - pub fn invalidate_cascade(&mut self, uri: &Url) { - // Get all reverse deps before mutating - let dependents: Vec = self.importers_of(uri).to_vec(); - - // Invalidate this file - self.invalidate(uri); - - // Recursively invalidate dependents - for dep in dependents { - self.invalidate_cascade(&dep); - } - } - - /// Clear the import graph (useful when rebuilding from scratch). - pub fn clear_imports(&mut self) { - self.imports.clear(); - self.reverse_deps.clear(); } /// Get the underlying file source (for operations that bypass caching). @@ -171,29 +111,6 @@ mod tests { use super::*; use crate::source::InMemorySource; - #[tokio::test] - async fn test_database_imports() { - let mem = InMemorySource::new(); - let mut db = Database::new(mem); - - let main = Url::parse("file:///test/Main.nash").unwrap(); - let utils = Url::parse("file:///test/Utils.nash").unwrap(); - let helpers = Url::parse("file:///test/Helpers.nash").unwrap(); - - // Main imports Utils and Helpers - db.record_import(&main, utils.clone()); - db.record_import(&main, helpers.clone()); - - // Check imports - assert_eq!(db.imports_of(&main).len(), 2); - assert!(db.imports_of(&main).contains(&utils)); - assert!(db.imports_of(&main).contains(&helpers)); - - // Check reverse deps - assert_eq!(db.importers_of(&utils), std::slice::from_ref(&main)); - assert_eq!(db.importers_of(&helpers), std::slice::from_ref(&main)); - } - #[tokio::test] async fn test_database_invalidation() { let mem = InMemorySource::new(); diff --git a/crates/nash-driver/src/graph.rs b/crates/nash-driver/src/graph.rs index 7e6124eb..ba402b06 100644 --- a/crates/nash-driver/src/graph.rs +++ b/crates/nash-driver/src/graph.rs @@ -195,33 +195,6 @@ impl DepGraph { } levels } - - /// Check if module A depends on module B (directly or transitively). - pub fn depends_on(&self, a: &Url, b: &Url) -> bool { - let mut visited = HashSet::new(); - self.depends_on_dfs(a, b, &mut visited) - } - - fn depends_on_dfs(&self, current: &Url, target: &Url, visited: &mut HashSet) -> bool { - if current == target { - return true; - } - - if visited.contains(current) { - return false; - } - visited.insert(current.clone()); - - if let Some(imports) = self.edges.get(current) { - for import in imports { - if self.depends_on_dfs(import, target, visited) { - return true; - } - } - } - - false - } } /// Extract a module name from a file URI for display. diff --git a/crates/nash-report/src/code.rs b/crates/nash-report/src/code.rs index cbe688b1..a7604c18 100644 --- a/crates/nash-report/src/code.rs +++ b/crates/nash-report/src/code.rs @@ -119,15 +119,6 @@ impl<'s> Source<'s> { .is_none_or(|c| !(c.is_alphanumeric() || c == '_')); boundary.then_some((row + 1, 1 + indent as Col)) } - - /// Elm's `nextLineStartsWithCloseCurly`. - pub fn next_line_starts_with_close_curly(&self, row: Row) -> Option<(Row, Col)> { - let line = self.line(row.checked_add(1)?)?; - let indent = line.bytes().take_while(|b| *b == b' ').count(); - line[indent..] - .starts_with('}') - .then_some((row + 1, 1 + indent as Col)) - } } /// Elm's `Next`. diff --git a/crates/nash-report/src/doc.rs b/crates/nash-report/src/doc.rs index f35acd5d..0ba57656 100644 --- a/crates/nash-report/src/doc.rs +++ b/crates/nash-report/src/doc.rs @@ -55,10 +55,6 @@ impl Doc { Doc::Text(s) } - pub fn from_int(n: impl std::fmt::Display) -> Doc { - Doc::text(n.to_string()) - } - /// `a <> b`. pub fn cat(docs: impl IntoIterator) -> Doc { Doc::Cat(docs.into_iter().collect()) @@ -160,9 +156,6 @@ impl Doc { pub fn cyan(self) -> Doc { self.color(BaseColor::Cyan, true) } - pub fn dullcyan(self) -> Doc { - self.color(BaseColor::Cyan, false) - } pub fn blue(self) -> Doc { self.color(BaseColor::Blue, true) } @@ -197,9 +190,6 @@ impl Doc { .chain(chunks), ) } - pub fn to_simple_hint(message: &str) -> Doc { - Doc::to_fancy_hint(message.split_whitespace().map(Doc::text)) - } /// `link word before fileName after`. pub fn link(word: &str, before: &str, file_name: &str, after: &str) -> Doc { @@ -210,14 +200,6 @@ impl Doc { .chain(after.split_whitespace().map(Doc::text)), ) } - pub fn fancy_link(word: &str, before: Vec, file_name: &str, after: Vec) -> Doc { - Doc::fill_sep( - std::iter::once(Doc::cat([Doc::text(word).underline(), Doc::text(":")])) - .chain(before) - .chain(std::iter::once(Doc::text(make_link(file_name)))) - .chain(after), - ) - } pub fn reflow_link(before: &str, file_name: &str, after: &str) -> Doc { Doc::fill_sep( before @@ -260,9 +242,6 @@ impl Doc { pub fn make_link(file_name: &str) -> String { format!("") } -pub fn make_naked_link(file_name: &str) -> String { - format!("https://nash-script.dev/hints/{file_name}") -} /// Elm `args`. pub fn args(n: usize) -> String { @@ -810,11 +789,6 @@ fn chunks(doc: &Doc, width: usize) -> Vec { } impl Doc { - /// Elm's `toLine`: disable optional wrapping, retaining explicit lines. - pub fn to_line(&self) -> String { - self.render(usize::MAX / 2, false) - } - /// Elm's `encode`, including its case-sensitive color names. pub fn encode(&self) -> serde_json::Value { serde_json::Value::Array(self.chunks(80).into_iter().map(|chunk| match chunk { diff --git a/crates/nash-report/src/type_diff.rs b/crates/nash-report/src/type_diff.rs index c63d037d..5574e4fa 100644 --- a/crates/nash-report/src/type_diff.rs +++ b/crates/nash-report/src/type_diff.rs @@ -165,12 +165,6 @@ fn builtin(home: ModuleName<'_>, name: &str, want: &str) -> bool { pub fn is_bool(h: ModuleName<'_>, n: &str) -> bool { builtin(h, n, "bool") } -pub fn is_int(h: ModuleName<'_>, n: &str) -> bool { - builtin(h, n, "int") -} -pub fn is_string(h: ModuleName<'_>, n: &str) -> bool { - builtin(h, n, "string") -} pub fn is_list(h: ModuleName<'_>, n: &str) -> bool { builtin(h, n, "list") } diff --git a/crates/nash-test/src/types.rs b/crates/nash-test/src/types.rs index 1355e0b4..0521c369 100644 --- a/crates/nash-test/src/types.rs +++ b/crates/nash-test/src/types.rs @@ -78,7 +78,6 @@ pub enum Failure { NoCounterexample, InvalidProgram { message: String }, } -pub type FailureKind = Failure; #[derive(Debug, Clone, PartialEq)] pub struct AssertReport { pub site: AssertSite, diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index 0afd1a04..cc1be5d3 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -77,3 +77,12 @@ is not evidence that it ran on an async executor thread. Do not replace these checks with a global test-only `RUST_MIN_STACK` setting: that would hide the production stack boundary. + +## Maintenance cleanup — 5 October + +Removed repository-unused database dependency bookkeeping and recursive +`DepGraph::depends_on`, unused reporting/type helpers, and the `FailureKind` +alias. Removed ignored private parameters in config parsing, import resolution, +and destructuring projection. Actual source-cache invalidation remains covered +by its existing regression. The language server still supports `root_uri` when +workspace folders are absent. From a4e5edf453b4356574fb99319c226fc10aa40e48 Mon Sep 17 00:00:00 2001 From: microproofs Date: Mon, 5 Oct 2026 23:50:11 +0700 Subject: [PATCH 8/9] refactor: finish compiler maintenance and verify LSP root compatibility Signed-off-by: microproofs --- .../compiler-maintenance-final-cleanup.md | 6 ++ crates/nash-codegen/src/evidence.rs | 19 +---- crates/nash-constrain/src/error_type.rs | 8 +- crates/nash-language-server/src/server.rs | 80 +++++++++++++++++++ ...t_uri_fallback_and_workspace_priority.snap | 11 +++ docs/research/compiler-stack-audit.md | 18 +++++ 6 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 .sampo/changesets/compiler-maintenance-final-cleanup.md create mode 100644 crates/nash-language-server/src/snapshots/nash_language_server__server__tests__initialization_preserves_root_uri_fallback_and_workspace_priority.snap diff --git a/.sampo/changesets/compiler-maintenance-final-cleanup.md b/.sampo/changesets/compiler-maintenance-final-cleanup.md new file mode 100644 index 00000000..32d3a82b --- /dev/null +++ b/.sampo/changesets/compiler-maintenance-final-cleanup.md @@ -0,0 +1,6 @@ +--- +cargo/nash-constrain: patch +cargo/nash-codegen: patch +--- + +Traverse diagnostic alias chains with a loop, and remove duplicate internal superclass handling while retaining public input validation. diff --git a/crates/nash-codegen/src/evidence.rs b/crates/nash-codegen/src/evidence.rs index 800ec79b..bc8c4500 100644 --- a/crates/nash-codegen/src/evidence.rs +++ b/crates/nash-codegen/src/evidence.rs @@ -375,19 +375,6 @@ impl<'a> Grounder<'a, '_> { depth: usize, ) -> Result, Error<'a>> { let (trait_, args) = match evidence { - Evidence::Repr { trait_, typ } => { - let super_ = trait_ - .supers() - .get(usize::from(index)) - .ok_or(Error::SuperIndex { - trait_: trait_.qualified(), - index, - })?; - return Ok(Pred::Implied { - trait_: super_.qualified(), - args: self.arena.alloc_slice_copy(&[*typ]), - }); - } Evidence::StructuralEq { typ } => (primitives::eq_trait(), vec![*typ]), Evidence::ReflexiveLift { typ } => (primitives::lift_trait(), vec![*typ, *typ]), Evidence::Impl { @@ -401,8 +388,10 @@ impl<'a> Grounder<'a, '_> { .collect::, _>>()?; (impl_.key.trait_, args) } - Evidence::Given { .. } | Evidence::Super { .. } => { - return Err(Error::UnresolvedEvidence); + // `one` has resolved givens/projections and handled representation + // superclasses directly before calling this private helper. + Evidence::Repr { .. } | Evidence::Given { .. } | Evidence::Super { .. } => { + unreachable!("superclass predicate requires resolved non-representation evidence") } }; let info = self diff --git a/crates/nash-constrain/src/error_type.rs b/crates/nash-constrain/src/error_type.rs index 3865026a..7a2b08dd 100644 --- a/crates/nash-constrain/src/error_type.rs +++ b/crates/nash-constrain/src/error_type.rs @@ -41,9 +41,9 @@ pub enum ErrorType<'a> { }, } -pub fn iterated_dealias<'a>(tipe: &'a ErrorType<'a>) -> &'a ErrorType<'a> { - match tipe { - ErrorType::Alias { real, .. } => iterated_dealias(real), - _ => tipe, +pub fn iterated_dealias<'a>(mut tipe: &'a ErrorType<'a>) -> &'a ErrorType<'a> { + while let ErrorType::Alias { real, .. } = tipe { + tipe = real; } + tipe } diff --git a/crates/nash-language-server/src/server.rs b/crates/nash-language-server/src/server.rs index 1259517f..75c7a58e 100644 --- a/crates/nash-language-server/src/server.rs +++ b/crates/nash-language-server/src/server.rs @@ -118,3 +118,83 @@ impl LanguageServer for Server { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp_server::LspService; + use tower_lsp_server::ls_types::WorkspaceFolder; + + #[tokio::test] + #[allow(deprecated)] + async fn initialization_preserves_root_uri_fallback_and_workspace_priority() { + let temporary = tempfile::tempdir().unwrap(); + let base = temporary.path().canonicalize().unwrap(); + for name in ["legacy", "first", "second"] { + std::fs::create_dir(base.join(name)).unwrap(); + } + let uri = |name: &str| { + Url::from_directory_path(base.join(name)) + .unwrap() + .as_str() + .parse::() + .unwrap() + }; + let folder = |name: &str| WorkspaceFolder { + name: name.to_owned(), + uri: uri(name), + }; + let invalid_uri: Uri = "https://example.invalid/workspace".parse().unwrap(); + let invalid_folder = WorkspaceFolder { + name: "non-file".into(), + uri: invalid_uri.clone(), + }; + let cases = [ + ("absent folders", None, Some(uri("legacy"))), + ("empty folders", Some(vec![]), Some(uri("legacy"))), + ( + "unusable folders", + Some(vec![invalid_folder.clone()]), + Some(uri("legacy")), + ), + ( + "workspace folder overrides legacy root", + Some(vec![folder("first")]), + Some(uri("legacy")), + ), + ("unusable legacy root", None, Some(invalid_uri)), + ( + "multiple folders retain order", + Some(vec![folder("second"), invalid_folder, folder("first")]), + Some(uri("legacy")), + ), + ("no roots clears previous initialization", None, None), + ]; + let (service, _socket) = LspService::new(Server::new); + let mut output = String::new(); + for (label, workspace_folders, root_uri) in cases { + service + .inner() + .initialize(InitializeParams { + workspace_folders, + root_uri, + ..InitializeParams::default() + }) + .await + .expect("initialization succeeds"); + let workspace = service.inner().workspace.lock().await; + let roots: Vec<_> = workspace + .roots + .iter() + .map(|path| { + path.strip_prefix(&base) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + output.push_str(&format!("{label}: {roots:?}\n")); + } + insta::assert_snapshot!(output); + } +} diff --git a/crates/nash-language-server/src/snapshots/nash_language_server__server__tests__initialization_preserves_root_uri_fallback_and_workspace_priority.snap b/crates/nash-language-server/src/snapshots/nash_language_server__server__tests__initialization_preserves_root_uri_fallback_and_workspace_priority.snap new file mode 100644 index 00000000..f49fbb40 --- /dev/null +++ b/crates/nash-language-server/src/snapshots/nash_language_server__server__tests__initialization_preserves_root_uri_fallback_and_workspace_priority.snap @@ -0,0 +1,11 @@ +--- +source: crates/nash-language-server/src/server.rs +expression: output +--- +absent folders: ["legacy"] +empty folders: ["legacy"] +unusable folders: ["legacy"] +workspace folder overrides legacy root: ["first"] +unusable legacy root: [] +multiple folders retain order: ["second", "first"] +no roots clears previous initialization: [] diff --git a/docs/research/compiler-stack-audit.md b/docs/research/compiler-stack-audit.md index cc1be5d3..c941e31e 100644 --- a/docs/research/compiler-stack-audit.md +++ b/docs/research/compiler-stack-audit.md @@ -86,3 +86,21 @@ alias. Removed ignored private parameters in config parsing, import resolution, and destructuring projection. Actual source-cache invalidation remains covered by its existing regression. The language server still supports `root_uri` when workspace folders are absent. + +Error-type alias lookup (`iterated_dealias`) is a loop: Elm's version is tail +recursive. + +Removed duplicate representation-superclass handling from the private evidence +helper: its sole caller has already handled representation evidence and +resolved givens/projections. Public evidence grounding and identity validation +retain their malformed-input checks. Constructor argument/index validation in +the public decision-tree API remains necessary for callers that supply raw +canonical ASTs; source canonicalization alone does not guarantee those inputs. +Evidence identity validation also remains shared: root compilation, eager +specialization, and some method paths resolve evidence directly instead of +passing it through the grounding helper. + +LSP initialization has an explicit regression for absent, empty, and unusable +workspace folders falling back to `root_uri`, valid folders taking priority, +multiple-folder ordering, unusable legacy URIs, and clearing prior roots when +none are provided. Supported editor behavior is unchanged. From 74c221a9d7f5c3dd2c076a6c91b4be7c554c4acd Mon Sep 17 00:00:00 2001 From: microproofs Date: Tue, 6 Oct 2026 09:31:49 +0700 Subject: [PATCH 9/9] update vesting example Signed-off-by: microproofs --- .sampo/changesets/cli-disable-unused-bzip2.md | 5 + Cargo.lock | 20 -- Cargo.toml | 3 +- SPEC.md | 2 +- docs/macros.md | 135 +++++--- examples/order/nash.jsonc | 4 +- examples/order/src/Order.nash | 2 +- examples/vesting/README.md | 11 + examples/vesting/app/src/VestingTests.nash | 102 ++++++ plans/11-macros-comptime.md | 326 +++++++++++++++--- 10 files changed, 501 insertions(+), 109 deletions(-) create mode 100644 .sampo/changesets/cli-disable-unused-bzip2.md create mode 100644 examples/vesting/app/src/VestingTests.nash diff --git a/.sampo/changesets/cli-disable-unused-bzip2.md b/.sampo/changesets/cli-disable-unused-bzip2.md new file mode 100644 index 00000000..8b42703c --- /dev/null +++ b/.sampo/changesets/cli-disable-unused-bzip2.md @@ -0,0 +1,5 @@ +--- +cargo/nash-cli: patch +--- + +Disable unused Bzip2 ZIP support to avoid building bzip2-sys. Retain all other existing ZIP features and XZ compiler-release extraction. diff --git a/Cargo.lock b/Cargo.lock index a85d4488..366537a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,25 +315,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "camino" version = "1.2.6" @@ -3900,7 +3881,6 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" dependencies = [ "aes", "arbitrary", - "bzip2", "constant_time_eq", "crc32fast", "crossbeam-utils", diff --git a/Cargo.toml b/Cargo.toml index 1c4b2370..74c3e2e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,8 @@ tower-lsp-server = { version = "0.23.0", default-features = false, features = [ ] } url = "2" xz2 = "0.1" -zip = "2" +# Keep zip 2 defaults except unused Bzip2 support. +zip = { version = "2", default-features = false, features = ["aes-crypto", "deflate64", "deflate", "lzma", "time", "zstd", "xz"] } # Optimize insta/similar in dev for faster diffs [profile.dev.package] diff --git a/SPEC.md b/SPEC.md index 80e37e5e..44ae0619 100644 --- a/SPEC.md +++ b/SPEC.md @@ -66,7 +66,7 @@ Implementation plans (Plans 08, 09 and 10 are complete): - [x] 08 Optimizer: Single ANF normalization and optimization before recursion rewrite, inlining, builtin force caching, DCE, case-of-known-ctor/constant folding — [plans/08-optimizer.md](plans/08-optimizer.md) (Retained scope complete: recursive unused parameters, unbudgeted constant folding, joint lifting/cleanup fixed point, restricted pair cancellation, O0/O1 build/test/config integration, composition/convergence validation, adjacent application fusion, native value-call packing and late UPLC binding cleanup) - [x] 09 Validators + `nash build` — [plans/09-validators-build.md](plans/09-validators-build.md) - [x] 10 Testing: `tests` block, props, generators, shrinking, power-assert, `nash test` — [plans/10-testing.md](plans/10-testing.md) -- [ ] 11 Macros + comptime — [plans/11-macros-comptime.md](plans/11-macros-comptime.md); includes explicit integer-dispatch AST and positional library macro (chunk 13), reusable call/binding/case-shaped macro inputs required in chunk 14 (library semantics deferred) +- [ ] 11 Macros + comptime — [plans/11-macros-comptime.md](plans/11-macros-comptime.md); includes explicit integer-dispatch AST and positional library macro (chunk 13), reusable call/binding/case-shaped macro inputs required in chunk 14 (library semantics deferred); staged expression/binding foundation and property/assertion migration (chunk 15; public contracts pending) - [ ] 12 Stdlib `nash/base` — [plans/12-stdlib.md](plans/12-stdlib.md) (chunks 1–9 and 11–13 complete; chunk 10 Ast/Derive deferred) - [x] 13 `nash format` (`fmt`), `nash docs` — [plans/13-fmt-docs.md](plans/13-fmt-docs.md) (chunks 1–7 complete) - [x] Representation-classed impl heads: `Big`/`Little` blankets and concrete little impls coexist — [plans/repr-classed-impl-heads.md](plans/repr-classed-impl-heads.md) diff --git a/docs/macros.md b/docs/macros.md index 9804115a..ac7e01b1 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -10,6 +10,22 @@ output, hygienic, `@derive(..)` on declarations, `name!(args)` in expressions, expand-then-recheck loop per module, deriving implemented as macros. +## Implementation sequence and testing migration + +Plan 11 now starts with a small complete expression-macro path: resolve an +imported macro, transport typed syntax, execute ordinary Nash on CEK, decode +hygienic output, and strictly recheck it. AST builders suffice for this first +slice; quote/splice and deriving follow. Binding/case-shaped inputs then establish +scope-preserving composition before library property macros are introduced. + +The accepted direction is to move property preparation/callback generation and +power-assert expansion from dedicated codegen into macros. This is separate work +tracked in Plan 11 chunk 15. Public syntax, generated private-root discovery, +interleaved generation semantics, optional Show evidence, and operand source +origins must be defined before their respective migration steps. The generic +macro shapes below do not by themselves solve those contracts. Runtime generation, +replay, shrinking, execution budgets and reporting remain library/runner work. + ## Concepts | Term | Meaning | @@ -18,7 +34,7 @@ macros. | Invocation | `@name(args)` before a declaration, or `name!(args)` in an expression. | | Reification | Turning compiler AST into `Ast.*` values (little ADTs, so they are UPLC `constr` terms at runtime) and back. | | Expansion round | One pass: find every invocation in a module, run each macro, splice results. | -| Hygiene | Binders created by a macro cannot capture or be captured by user names unless the macro asks for it with `Ast.raw`. | +| Hygiene | Fresh binding identities prevent accidental capture; caller dependencies are explicit inputs. | | Comptime | `comptime e`: evaluate `e` on the CEK machine at compile time, splice the resulting constant. | ## Declaring a macro @@ -255,7 +271,9 @@ directly as `nash_plutus::Term::Constr` nodes, applies the macro program to it, and walks the resulting `constr` tree back into surface AST; no `Data` encoding is involved. One family of types serves both input (typed) and output (surface). Type information lives in `option` slots -that are `Some` on input and are ignored on output. Child lists are +that are `Some` when resolved on input and are ignored on output. Provisional +unresolved syntax retains names, lexical references and origins with absent type +metadata; it must not be erased into a payload-free hole. Child lists are `cons` ([stdlib.md](stdlib.md) "`Cons`") because `list` elements must be `Storable` and `Ast` nodes are `Term`. @@ -391,8 +409,9 @@ Input conventions (reification, `nash-macro`): `kind = Some`. `representation` is `Some` where known, independently of the kind. A transparent alias uses its substituted body. Parameter `repr` preserves the source representation annotation, if present. -- Local variables and their binders are `Raw "x"`. Copying an input - subtree into output keeps it resolving as the user wrote it. +- Local variables and their binders retain lexical identity. Copying bound + syntax freshens binders and their uses together; free caller references retain + their original identity. A raw spelling alone cannot encode this relationship. - Top-level, foreign, constructor, and operator references are `Global modname name`. `BinOp` carries the operator's *function* name. - `if` chains are nested `If`. `let` with several definitions is one `Let` @@ -406,8 +425,9 @@ Input conventions (reification, `nash-macro`): Output conventions (the walk back to surface AST): -- `span` is ignored. Every spliced node gets the invocation site's region - so diagnostics point at the `@derive(..)` or `name!(..)`. +- Preserve original spans on supplied syntax. Generated syntax without an + original caller location uses the invocation region. Attach expansion history + separately, as specified under Source locations and expansion origins. - `typ` is ignored. - `name` decides resolution as described under Hygiene. - Inferred `kind` and `representation` slots are ignored. Kinds and @@ -458,7 +478,20 @@ macros: | `name! pattern = value` | Pattern, value, and remaining lexical body | | `name! subject of` followed by normal arms | Subject and ordered pattern/body arms | -For example, a library could later offer `expect! Some x = value` or: +These shapes remain required in milestone B, before the property migration. +For example, a binding macro can use `expect! Some x = value`, and a case-shaped +macro can use: + +```nash +dispatch! subject of + 0 -> first + 1 -> second +``` + +The case form does not itself select the explicit positional IntegerDispatch +operation. That operation also retains its call-shaped interface +`dispatch!(n, [branch0, branch1])`; library arm/fallback semantics remain to be +specified. Another case-shaped macro could use: ```nash decodeIf! valueData of @@ -523,31 +556,48 @@ Semantics: `quote type (t)`, `quote pattern (p)`, and `quote decl (d)` are deferred shorthand extensions, not prerequisites for constructing those AST values. +## Source locations and expansion origins + +**Settled contract (6 October 2026):** preserve original locations when supplied +syntax is moved or copied, down to individual operands. Freshening bindings does +not change their source origins. Attach invocation provenance and nested expansion +history separately. Generated syntax without an original caller location points +to the macro invocation. Diagnostics retain access to the expansion chain. + +The concrete encoding is still to be chosen. Older output-span sketches must not +be implemented as unconditional replacement of supplied spans by invocation spans. +This is a syntax-metadata preservation rule, not a requirement to share storage +with comments. + ## Hygiene -Names in macro output carry a resolution mode (`Ast.name`). The walk back -to surface AST applies it when splicing: - -| Name | Binder position | Reference position | -|---|---|---| -| `Local s` | Renamed to `s·N` where `N` is unique to this expansion | Renamed to the same `s·N`; a `Local s` reference with no `Local s` binder in the output is an error (`MacroUnboundLocal`) | -| `Raw s` | Spliced as `s` | Spliced as `s`; resolves in the invocation module's scope like user code | -| `Global m s` | Not allowed as a binder (`MacroGlobalBinder`) | Resolves to `m.s` through the build's interface table, ignoring the invocation module's imports and aliases | - -Consequences: - -- A macro that writes `\x -> ... x ...` with `Ast.name "x"` can never - capture a user's `x`, and user code passed in as `Raw "x"` can never be - captured by it. -- A macro that wants to bind a name the user will refer to (an - anaphoric macro) uses `Ast.raw`. -- Top-level declarations emitted by a declaration macro use `Raw` names - for anything the user must be able to call (`impl` method names, the - generated function in `@memoize`), and `Local` names for helpers. A - `Local` top-level name is renamed and is not exported. -- The renamed form `x·1` contains a character that is not a valid - identifier character, so it cannot collide with source text. Diagnostics - print `x` and mention the macro when a renamed name appears. +**Settled binding contract (6 October 2026):** freshen each copied or generated +binder and all references bound by it, preserving lexical scope. Free caller +references keep their original binding identities. Equal display names do not +imply equal identities. In particular, a supplied pattern and body must be +freshened together so the body still refers to that pattern's bindings. + +The name modes below describe resolution intent. Their historical string-based +encoding is illustrative and must be revised to carry this identity contract; +fresh names alone are not a scope-tracking algorithm. The concrete encoding and +explicit caller-visible binding API remain design work. + +**Settled resolution contract (6 October 2026):** + +| Name source | Resolution | +|---|---| +| Macro-written global | Macro defining module; preserve the resolved global identity | +| Supplied caller reference | Original caller binding/global identity | +| Copied or generated local binder | Fresh identity with consistently updated bound references | +| Supplied pattern and remaining body | Preserve their binding relationship while freshening together | +| Named generated declaration or trait method | Explicit declaration API, distinct from reference lookup | + +Arbitrary caller-scope lookup by string and implicit anaphoric bindings are +deferred. Caller dependencies must be passed explicitly. Historical `Raw` and +`Ast.raw` signatures in the AST/builder sketches are not an initial public lookup +API: separate unresolved input spelling from bindings, globals, and declaration +names when finalizing that encoding. Reading an attribute argument's spelling +is not permission to resolve a generated reference in the caller's scope. The gensym pass runs on the decoded surface AST before canonicalization, see plan chunk "Hygiene". @@ -711,21 +761,22 @@ Semantics: the one place the two mechanisms differ: a macro result is an `Ast` `constr` tree that the compiler walks, while a `comptime` result is spliced as a constant into the program. -- `e` must be **closed**: it may not mention local variables of the - enclosing function (lambda parameters, `let` bindings, pattern - variables). Top-level values and imports are allowed. Violation is - `ComptimeNotClosed` naming the variable. +- `e` must be **closed after including its reachable dependencies**. Top-level + values, imports, and closed local dependencies are allowed. Capturing an + enclosing runtime parameter is rejected. Preserve the existing dependency + closure behavior rather than rejecting all local bindings syntactically. - `e` may not contain `comptime` (they are nested constants anyway) or an unexpanded macro invocation (expansion runs first, so this cannot happen). - Evaluation: `nash-codegen` compiles `e` plus its dependencies to a - standalone program and runs it on the CEK machine with the - `comptimeBudget` (default 10x the mainnet transaction budget, settable - in `nash.jsonc` and by `--comptime-budget`). + standalone program and runs it on the CEK machine. The current implementation + uses V3 and `ExBudget::default()`. Configurable `comptimeBudget` and a + `--comptime-budget` flag are not implemented and are not prerequisites for the + first macro slice; any addition needs an explicit configuration contract. - The resulting `Constant` is spliced as a `Core::Lit` node. Failures are `ComptimeFailed` with the machine error and the last trace line. -- Traces emitted during comptime are printed at compile time only with - `--trace-comptime`. +- Current comptime failures include captured traces. A `--trace-comptime` flag + for successful evaluation is not implemented. - Comptime happens per use site, during lowering to Core. Two identical `comptime` expressions evaluate twice (v1; content-hash caching is an open question). @@ -824,9 +875,9 @@ Notes on the sketch: - `ctor.name` from the input is `Global`, so the generated patterns match the right constructors even if the user's module renames them on import. -- The trait name and the method name are `Raw`: `Eq` must resolve at the - invocation site (it is in the prelude), and `eq` is the method the trait - declares. +- The historical `Raw` uses in this sketch must be replaced: the Eq trait + reference resolves in the deriving macro module, while `eq` is an explicit + declaration of that trait method. Neither needs arbitrary caller lookup. - `x0`, `y0`, `a`, `b` are `Local` and get renamed, so a field named `a` cannot interfere. - `Union { name, params, ctors }` is the labeled-constructor pattern sugar diff --git a/examples/order/nash.jsonc b/examples/order/nash.jsonc index f1636e60..abbc6fef 100644 --- a/examples/order/nash.jsonc +++ b/examples/order/nash.jsonc @@ -1,6 +1,4 @@ { "type": "application", - "dependencies": { - "nash/base": { "path": } - } + "dependencies": {} } diff --git a/examples/order/src/Order.nash b/examples/order/src/Order.nash index 341c6801..2fc2d70d 100644 --- a/examples/order/src/Order.nash +++ b/examples/order/src/Order.nash @@ -2,7 +2,7 @@ module Order exposing (compare, invert, sort) import Prelude exposing (..) import Literal -import Bool exposing (not) +import Logic exposing (not) import Ord exposing (Ord) import Ordering exposing (type ordering(..)) diff --git a/examples/vesting/README.md b/examples/vesting/README.md index aa362609..64f2c391 100644 --- a/examples/vesting/README.md +++ b/examples/vesting/README.md @@ -26,3 +26,14 @@ The codegen snapshots and serialized artifact tests apply exactly one context and exercise successful and failed claims and cancellations. The snapshots compare unoptimized and optimized Core/UPLC and execution outcomes. Budget regressions run separately in `tools/optimizer-perf`. + +Run the example's Nash tests from the repository root: + +```sh +cargo run -p nash-cli -- test examples/vesting +``` + +`VestingTests.nash` calls both validator entry points with synthetic V3 contexts. +It checks claim deadline boundaries, owner and non-owner cancellations, and the +parameterized minimum lock. Tests marked `fail` pass when the validator rejects +the context. diff --git a/examples/vesting/app/src/VestingTests.nash b/examples/vesting/app/src/VestingTests.nash new file mode 100644 index 00000000..7ccb2301 --- /dev/null +++ b/examples/vesting/app/src/VestingTests.nash @@ -0,0 +1,102 @@ +module VestingTests exposing ( context ) + + +import Builtin exposing (..) +import Vesting +import VestingParam + + +constr : ( int, list Data ) -> Data +constr ( tag, fields ) = Builtin.constrData tag fields + + +-- Synthetic V3 spending context, matching the application's Data encoding. +-- The deadline is 10 ms; the owner is deliberately the second signatory. +context : int -> int -> list Data -> Data +context action time signers = + let + empty = List [] + + emptyMap = Map [] + + txId = + B + #"0000000000000000000000000000000000000000000000000000000000000000" + + closed = constr ( 1, [] ) + + lowerBound = constr ( 0, [ constr ( 1, [ I time ] ), closed ] ) + + upperBound = constr ( 0, [ constr ( 2, [] ), closed ] ) + + tx = + constr + ( 0 + , [ empty + , empty + , empty + , I 0 + , emptyMap + , empty + , emptyMap + , constr ( 0, [ lowerBound, upperBound ] ) + , List signers + , emptyMap + , emptyMap + , txId + , emptyMap + , empty + , constr ( 1, [] ) + , constr ( 1, [] ) + ] + ) + + datum = constr ( 0, [ B #"aa", I 10 ] ) + + reference = constr ( 0, [ txId, I 0 ] ) + + spending = constr ( 1, [ reference, constr ( 0, [ datum ] ) ] ) + in + constr ( 0, [ tx, constr ( action, [] ), spending ] ) + + +tests + test "claim after deadline succeeds" = + do + Vesting.main (context 0 11 []) + + test "claim at deadline fails" fail = + do + Vesting.main (context 0 10 []) + + test "claim before deadline fails" fail = + do + Vesting.main (context 0 9 []) + + test "owner can cancel before deadline" = + do + Vesting.main (context 1 0 [ B #"bb", B #"aa" ]) + + test "another signer cannot cancel" fail = + do + Vesting.main (context 1 20 [ B #"bb" ]) + + test "unsigned cancellation fails" fail = + do + Vesting.main (context 1 20 []) + + test "parameterized claim after lock succeeds" = + do + VestingParam.main 5 (context 0 16 []) + + test "parameterized claim at lock boundary fails" fail = + do + VestingParam.main 5 (context 0 15 []) + + test "minimum lock does not prevent owner cancellation" = + do + VestingParam.main 5 (context 1 0 [ B #"bb", B #"aa" ]) + + test "minimum lock does not authorize another signer" fail = + do + VestingParam.main 5 (context 1 20 [ B #"bb" ]) diff --git a/plans/11-macros-comptime.md b/plans/11-macros-comptime.md index e84f62cd..e94f98e2 100644 --- a/plans/11-macros-comptime.md +++ b/plans/11-macros-comptime.md @@ -4,9 +4,199 @@ Goal: implement [docs/macros.md](../docs/macros.md): `macro` declarations, `@attr` and `name!()` invocations, `quote`/`~`, the `Ast` reification in a new `nash-macro` crate, the expansion loop in `nash-driver`, hygiene, `comptime`, `@derive` in `nash/base`, diagnostics, and expansion snapshot -tests, and explicit native integer dispatch through a library macro. +tests, explicit native integer dispatch through a library macro, and a staged +migration of property/assertion expansion from codegen into Nash macros. + +## Execution strategy: establish macro basics first + +This section sets implementation order. The numbered chunks below are component +work packages, not a requirement to finish each layer in isolation before running +a macro. Older code sketches illustrate shapes; current compiler APIs and the +contracts below take precedence. No implementation is marked complete by this revision. + +A compiler macro is a function from syntax to syntax, executed during compilation. +The minimum complete path must answer six questions: + +| Basic responsibility | Required result | +|---|---| +| Parse and resolve | Distinguish an imported macro declaration from an ordinary value and resolve an invocation to it. | +| Transport syntax | Supply structured expression nodes, source origins, lexical references, and available types without evaluating the argument. | +| Execute | Compile the macro through ordinary Nash codegen and run it with the existing bounded CEK evaluator. | +| Decode and bind | Validate returned AST and preserve caller references while giving generated bindings distinct identities. | +| Expand and check | Replace the invocation, then canonicalize and infer from fresh state; strictly check the final program. | +| Explain failures | Report the invocation, macro identity, and useful expansion/evaluation errors. | + +Quote syntax is convenient AST construction, not the execution mechanism. +Deriving, property syntax, and integer dispatch are clients of this mechanism. +Neither a second evaluator nor property-specific logic belongs in the macro core. + +### Milestone A: one imported expression macro, end to end + +Start with the call-shaped interface and a small test macro module. Use AST +builders first; quote/splice, attributes, deriving, binding/case forms, and the +property migration need not block this first working slice. This is a development +milestone, not a reduction of the final AST coverage or accepted Plan 11 scope. + +1. [ ] Inventory the current source/canonical variants and define the initial + shared `Ast` encoding with Plan 12. Specify source origins and lexical identity + before writing the reifier. `NodeId` identifies a syntax node, not its binder. +2. [ ] Add annotated macro declarations and exported macro shape metadata. Keep + the existing imported-only invocation rule; test same-module and wrong-shape + errors. Parse/format the supported syntax through the existing frontend. +3. [ ] Compile a real imported identity macro through `Build` and ordinary closed + program assembly. Execute it on syntax with the CEK machine; no host callback + or hardcoded macro name may substitute for the Nash macro. +4. [ ] Integrate invocation replacement into the driver. Infer available argument + types, keep provisional macro-result types separate, and rebuild canonical + nodes and `SolvedTypes` after expansion. Never reuse pointer-keyed evidence + from an earlier round. Final strict inference and coverage are mandatory. +5. [ ] Demonstrate both identity expansion and AST construction with a generated + local binding. Preserve free caller references and resolve macro-definition + globals even when the caller does not import them. Include nested shadowing + and a caller/generated-name collision. +6. [ ] Reject malformed macro output, preserve real input type errors, report + evaluation/budget failures, and bound repeated expansion. Unsupported input + variants in an intermediate slice must produce a diagnostic, never a panic. + +Acceptance: snapshot source, typed macro input, expanded AST, and ordinary Core/ +UPLC for these fixtures, then check execution equivalence and lexical scope. +A trace-bearing argument must not execute during expansion and must execute only +as directed by the expanded program. Follow repository snapshot rules: no duplicate +expected-output assertions; independent semantic checks follow the snapshot. + +Milestone A crosses chunks 1–8 and 11–12 in small validated slices. Diagnostics +and expansion snapshots are part of the first slice, not work deferred until derive. + +### Milestone B: bindings, provisional checking, and full transport + +The revised order retains all three required invocation shapes. In particular, +`expect!`-style bindings and `dispatch!`-style cases are required capabilities, +not optional follow-up work after the property migration: -## Current baseline and scope (26 September 2026) +```nash +someMacro!(argument) + +-- In a let/do binding context; x is available to the remaining body. +expect! Some x = value + +-- An expression with ordered pattern/body arms. +dispatch! subject of + 0 -> first + 1 -> second +``` + +The compiler recognizes the call, binding, and case shapes, not these library +names. Demonstrate each shape with a real Nash macro before completing B, including +qualified invocations. The binding macro receives the remaining lexical body; +the case macro receives the subject and independent arm scopes. Neither input +is evaluated merely to pass syntax to the macro. + +Keep `dispatch!(n, [branch0, branch1])` from chunk 13 as well: it constructs the +explicit positional IntegerDispatch operation. Case-shaped syntax does not by +itself choose positional dispatch, decode Data, insert bounds checks, or define +fallback behavior. Public `expect` failure semantics and `dispatch` arm rules +remain library decisions to settle before shipping those library macros. + +- [ ] Extend the same transport to the complete required AST inventory; keep the + Nash constructors and host tags synchronized. Use bounded-stack traversal for + deep syntax, consistent with the current compiler maintenance. +- [ ] Implement scoped binding identity, unresolved-name transport, and precise + predicate deferral. Missing provisional information is explicit; real + unification errors remain errors. Do not erase unknown names into a payload-free + hole that cannot be reconstructed by a macro. +- [ ] Add binding-shaped input with an explicit remaining-body boundary. Prove two + nested bindings, dependent values, shadowing, and evaluate-once behavior using + test-only macros. Their resulting AST controls execution order. +- [ ] Add case-shaped input with independent arm scopes and final-only coverage. + Do not prematurely unify an input subject with patterns awaiting conversion. +- [ ] Prove nested expansion, module import/export behavior, and fresh dependency + analysis using generated declarations/impls; complete attribute support. + +This is chunks 2–6, 8, and 14 together. The initial identity example does not +justify declaring general hygiene or lenient checking complete. + +### Milestone C: a property macro as the first substantial client + +After binding expansion works, implement chunk 15's smallest property slice: +two dependent draws and a computed label become ordinary generator composition +and body/display callbacks. Settle how the runner discovers the generated private +root before implementing this slice. Preserve the existing runtime protocol first; +do not redesign shrinking while replacing syntax expansion. + +The acceptance evidence includes deleting the replaced property-specific Core +construction, not merely adding macros alongside it. Assertion migration follows +only after source capture and optional display evidence have defined contracts. + +### Milestone D: complete remaining accepted capabilities + +Complete quote/splice, full declaration expansion, Ast/Derive, explicit integer +dispatch, diagnostics/tooling, and the remaining expansion corpus. Quote may be +added earlier when useful; it is not a prerequisite for the first executable +macro. Existing comptime integration stays covered throughout. These remain Plan +11 requirements; the staged order does not silently drop them. + +### Settled: fresh lexical bindings (6 October 2026) + +Use the same scope-preserving freshening principle as compiler optimization. +Every copied or generated binder gets a fresh identity, and its bound references +are updated consistently. Free references in supplied caller syntax retain their +original binding identity. Distinct scopes never share a binder merely because +its display spelling matches. Source names are for display, not identity. + +This settles the hygiene behavior, not the concrete AST encoding. Preserve the +relationship between a supplied pattern and its remaining body when freshening +both. The older `Local string`/gensym sketches below must implement this contract; +a global string rename or one identity per spelling per expansion is insufficient. +The concrete source-origin encoding and explicit caller-visible binding APIs +remain to be settled separately. + +### Settled: source locations and expansion origins (6 October 2026) + +Moving or copying supplied syntax preserves its original source locations, +including individual operands. Freshening lexical bindings does not replace +those locations. Track the macro invocation and nested expansion history +separately. Generated syntax without an original caller location uses its macro +invocation as the diagnostic location. This follows the preservation principle +used for attached syntax information; it does not require reusing comment storage. + +Diagnostics should point to supplied syntax where applicable and make the +expansion chain available. The concrete origin encoding remains implementation +design work; do not collapse every output span to the invocation region. + +### Settled: explicit inputs and global resolution (6 October 2026) + +Macro-written globals resolve in the macro's defining module. Supplied caller +syntax preserves the caller's lexical/global resolution. Pass caller dependencies +explicitly as arguments; arbitrary caller-scope lookup by a generated string and +implicit anaphoric bindings are deferred from the initial implementation. + +Caller-supplied binding patterns remain supported: `expect! Some x = value` +transports the relationship between x and its remaining body. Named generated +declarations and trait methods also remain supported through declaration APIs; +declaring a name is not an escape for looking up arbitrary caller locals. + +Historical `Raw`/`Ast.raw` sketches below are not the accepted initial lookup API. +Revise them to distinguish unresolved input spelling, lexical binding identity, +resolved globals, and declared names. Unresolved attribute-name syntax may be +inspected as input without granting implicit caller lookup in generated output. +Deriving uses resolved trait references and explicit method declarations. + +### Decisions required before their dependent implementation + +| Decision | Resolve before | +|---|---| +| Fresh binding and source-origin behavior settled above; choose their concrete encoding | Milestone A reifier/unreifier | +| Representation of unresolved names/types and exact deferred relationships | General provisional checking in B | +| Binding/case payload signatures and remaining-body boundaries | Structured forms in B | +| Property syntax, private-root discovery, metadata, and interleaved execution semantics | Property migration in C | +| Optional `Show` evidence and operand-level source fidelity | Power-assert migration | + +Prefer extending existing representations and library protocols. Record each +settled contract in `docs/macros.md` and the relevant testing spec; do not create +parallel type registries, inference engines, or alternate production paths merely +to get a demonstration running. + +## Current baseline and scope (6 October 2026) Procedural macros remain pending. "Initial scope" means this plan's first implementation, not a language release or Plutus version. This section supersedes @@ -25,7 +215,8 @@ historical implementation sketches below. `lower_value(&ModuleSet, ...)` or `Core::Const` APIs merely to match this sketch. - Chunks 1–8, 10–13 are pending macro work. Chunk 9 is existing comptime plus integration checks. Chunk 14 requires reusable structured invocation forms, without defining - library macro behavior. + library macro behavior. Chunk 15 defines the testing migration milestones; its + public syntax and discovery contract must be settled before that migration. AST builders construct expressions, patterns, arms, types, functions, and declarations. Initial quote/splice shorthand handles expressions only; @@ -847,7 +1038,10 @@ impl<'p, 'a> Reifier<'p, 'a> { &[self.global(*reference), self.cons_list(arguments.iter().map(|a| self.expr(a)))], ), CanExpr::Comptime(inner) => self.constr(tags::expr::COMPTIME, &[self.expr(inner)]), - CanExpr::Hole => unreachable!("holes never survive to reification: strict pass ran"), + // Provisional unresolved syntax must retain its name, scope and origin. + // Encode that metadata with an absent type; the concrete node shape + // is defined by the shared transport contract before implementation. + // A payload-free Hole/unreachable arm is not a valid implementation. }; self.constr(0, &[meta, node]) // `Expr meta node` } @@ -1267,28 +1461,16 @@ None. Conceptually Racket's "marks", simplified to one mark per expansion. applies it to the reified input terms under a budget and returns the result `Term` (the discharged value) or the failure message. -**Code** +**Compilation contract** -```rust -// crates/nash-codegen/src/macro_.rs -/// Compile one macro definition to a closed program: the macro's Core -/// term with all dependencies inlined. Arguments are little `Ast` values, -/// i.e. `constr` terms applied directly, so no wrapper conversion is needed. -pub fn compile_macro<'p>( - arena: &'p Arena, - set: &ModuleSet<'_>, - module: ModuleName<'_>, - macro_def: &MacroDef<'_>, -) -> &'p Program<'p, DeBruijn> { - let term = lower_value(arena, set, QualifiedName { home: module, name: macro_def.name.value }); - Program::new(arena, Version::plutus_v3(arena), term) -} +Use the current `Build` specialization and dependency closure, followed by +ordinary closed Core assembly and Flat encoding. Compile an exported macro root +with its reachable definitions; do not inline every dependency or recreate the +removed `ModuleSet`/`lower_value` APIs. Retain owned encoded programs across module +arenas and use shape metadata from the defining module's interface. -/// Serialized so the driver can keep it across module arenas. -pub fn encode_macro(program: &Program<'_, DeBruijn>) -> Vec { - nash_plutus::flat::encode(program) -} -``` +The following execution sketch illustrates the CEK boundary; adapt signatures and +errors to the current APIs rather than introducing a second evaluator. ```rust // crates/nash-macro/src/run.rs @@ -1350,7 +1532,7 @@ definition with its dependencies to a `Program`), `crates/aiken-project/src/lib. - Budget exhaustion returns `Failed` with the machine's budget error. **Done when** tests pass through current Build specialization and closed Core -program assembly. Adapt the historical compilation sketch above to those APIs. +program assembly, including invocation through the real driver expansion path. --- @@ -1937,7 +2119,8 @@ semantics in the parser, solver, or lowerer. ### Deliberately not decided here -No public `expect`, `decodeIf`, `clauses`, or assertion migration is specified. +This capability chunk specifies no public `expect`, `decodeIf`, or `clauses` API. +Testing/assertion migration is staged separately in chunk 15. Failure/fallback rules, validation depth (including ignored fields), target-type selection, accepted dispatch arms, and library treatment of irrefutable patterns remain library design decisions. Do not replace current power-assert reporting, @@ -1949,20 +2132,81 @@ it does not require Plan 08 or implementation of particular standard macros. --- -## Order and dependencies +## Chunk 15: move property and assertion expansion into Nash macros + +**Accepted direction, implementation pending.** Replace testing-specific syntax +expansion and Core construction with macros that emit ordinary typed Nash. This +chunk extends the earlier plan; completing generic macros alone does not complete +the migration. It does not yet choose the public replacement spelling for `prop`, +`via`, or `assert`. + +### First property slice + +- [ ] Specify how a generated private test root is discovered without exposing it + as an ordinary public definition. Retain name, expected failure, budgets, source + origin, selected-test behavior, and counterexample display metadata. Choose a + thin compiler/driver discovery contract or library descriptor representation; + expression output alone does not register a test. +- [ ] Define the property block's syntax and lexical boundaries using generic + macro input forms. Existing `via` is not automatically valid macro input. Do + not add parser/solver/codegen rules keyed to a particular library macro name. +- [ ] Define interleaving: bindings that depend on earlier draws, ordinary lets, + conditional draws, statements/assertions before later draws, and draws inside + called functions. Preserve the chosen evaluation order; do not blindly hoist + generation across branches or effects. Explicitly delimit any deferred forms. +- [ ] Implement a real library macro producing generator composition plus + property/display callbacks. Begin with two dependent draws and a computed + label, then test execution order and failures according to the settled contract. +- [ ] Reuse `Test.prepare`, `Test.both` where suitable, and `Prop` composition. + Preserve the preparation/body/display protocol unless a documented semantic + requirement necessitates a coordinated change. Binding macros do not by + themselves settle the property block's preparation boundary. +- [ ] Execute through `nash test`, including selection, expected failure, budget, + labels, counterexample display, and deterministic replay/shrinking regressions. +- [ ] Remove replaced generator/callback Core construction in + `nash-codegen/src/tests.rs` and obsolete frontend special cases as consumers + migrate. Keep only necessary root compilation/discovery. Do not declare this + done while both production expansion implementations remain. + +### Power-assert migration + +- [ ] Define how a macro obtains operand syntax/origins and decides whether `Show` + evidence exists. Typed AST alone does not provide today's optional trait probe. + Choose a generic capability or an explicit library policy, not an assertion-name + exception in codegen. Preserve caller operand spans separately from generated + invocation spans. +- [ ] Preserve evaluate-once behavior, short circuiting, trace order, failure-only + display, and source-linked capture metadata. Test values with and without Show. +- [ ] Generate ordinary calls to assertion/reporting helpers and delete replaced + capture instrumentation in `nash-codegen/src/assertion.rs` after parity checks. + Any intentional output change must be separately specified and snapshot-reviewed. + +### Responsibilities that remain + +The runner still discovers/selects roots through the chosen contract, evaluates +programs, enforces budgets/expected outcomes, and reports results. `Prop` and the +Rust runner retain choice recording, replay, shrinking, and seed management. +Macros transform syntax; they do not perform random sampling at compile time. +The ordinary backend still compiles the emitted functions and the macro programs. + +Update `docs/testing.md`, Plan 10's migration notes, examples and fixtures when the +new public contract is settled. Plan 10's completed current implementation stays +recorded as completed; this new migration is tracked here. -``` -1 syntax ─┐ -2 can ────┼─ 3 node types ─ 4 reifier ─ 5 unreifier ─ 6 hygiene ─┐ - │ ├─ 8 driver loop ─ 10 derive ─ 12 snapshots -7 codegen (needs plans/07) ────────────────────────────────────┘ │ -9 comptime (needs plans/02, plans/07) 11 diagnostics -``` - -Chunks 1–6 can land before plans/07; chunks 7–10 need it. Chunk 11 can -land any time after 8; chunk 12 after 10. +## Order and dependencies -Chunk 13 requires chunks 2–8 and Plan 12 Ast coordination, not Plan 08 or deriving. -Chunk 14 extends generic parsing, AST transport, hygiene, and checking with -binding/case-shaped inputs; no particular library macro or extra quote syntax -is required. +Follow milestones A → B → C → D above, using the numbered chunks as component +checklists. Cross-layer slices must compile and execute real macros early. + +- A combines declaration/call syntax, typed input, minimal transport, hygiene, + imported macro compilation, driver replacement, diagnostics, and snapshots. +- B completes transport and provisional checking, adds structured binding/case + forms, and establishes declaration expansion. Coordinate chunk 14 across all + earlier shape definitions rather than bolting it on after a two-shape design. +- C uses those capabilities for chunk 15. Discovery and assertion capabilities + are explicit gates, not assumed consequences of expression macros. +- D completes the remaining accepted scope. Chunk 13's explicit dispatch needs + the generic macro path and AST/Core support; it does not depend on deriving. +- Chunk 9 preserves existing comptime throughout. Ast encoding and Plan 12's Ast/ + Derive work must stay synchronized. Macro-free modules retain the ordinary + single strict checking path; do not impose repeated provisional passes on them.