From 28c946c2f331ce53748e2fa74662b85a4856c6ea Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 15:39:40 -0400 Subject: [PATCH 01/11] perf(parse): remove accumulator copies Signed-off-by: microproofs --- .sampo/changesets/parser-accumulators.md | 5 ++ crates/nash-parse/src/expression/mod.rs | 63 +++++++++++++++--------- plans/frontend-hardening.md | 31 ++++++++++++ 3 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 .sampo/changesets/parser-accumulators.md create mode 100644 plans/frontend-hardening.md diff --git a/.sampo/changesets/parser-accumulators.md b/.sampo/changesets/parser-accumulators.md new file mode 100644 index 00000000..13ddfa4e --- /dev/null +++ b/.sampo/changesets/parser-accumulators.md @@ -0,0 +1,5 @@ +--- +cargo/nash-parse: patch +--- + +Accumulate function arguments and binary operators without cloning partial chains. Keep parser arena allocation linear in operator-chain length. diff --git a/crates/nash-parse/src/expression/mod.rs b/crates/nash-parse/src/expression/mod.rs index 33e6e25f..1c0f46b5 100644 --- a/crates/nash-parse/src/expression/mod.rs +++ b/crates/nash-parse/src/expression/mod.rs @@ -106,8 +106,6 @@ impl<'a> Parser<'a> { let mut current_end = end; loop { - let state_for_fallback = (ops.clone(), current_expr, current_args.clone(), current_end); - let result = if self.is_trailing_section_operator() { ExprEndState::Done } else { @@ -121,10 +119,7 @@ impl<'a> Parser<'a> { let new_end = p.get_position(); p.chomp(error::Expr::Space)?; - let mut new_args = current_args.clone(); - new_args.push(arg); - - Ok(ExprEndState::MoreArgs(new_args, new_end)) + Ok(ExprEndState::MoreArgs(arg, new_end)) }), // operator Box::new(|p: &mut Parser<'a>| { @@ -162,10 +157,7 @@ impl<'a> Parser<'a> { p.alloc(Located::at(neg_region, Expr::Negate(negated_expr))); p.chomp(error::Expr::Space)?; - let mut new_args = current_args.clone(); - new_args.push(neg); - - Ok(ExprEndState::MoreArgs(new_args, neg_end)) + Ok(ExprEndState::MoreArgs(neg, neg_end)) } else { // Regular binary operator p.one_of( @@ -213,20 +205,20 @@ impl<'a> Parser<'a> { }; match result { - ExprEndState::MoreArgs(new_args, new_end) => { - current_args = new_args; + ExprEndState::MoreArgs(arg, new_end) => { + current_args.push(arg); current_end = new_end; } ExprEndState::MoreOps(op, new_expr, new_end) => { // Push (toCall current_expr current_args, op) onto ops - let call_expr = to_call(self, start, current_expr, current_args.clone()); + let call_expr = + to_call(self, start, current_expr, std::mem::take(&mut current_args)); let operand = self.alloc(BinOpOperand { expr: call_expr, op, }); ops.push(operand); current_expr = new_expr; - current_args = Vec::new(); current_end = new_end; } ExprEndState::Final(op, final_expr, final_end) => { @@ -247,20 +239,20 @@ impl<'a> Parser<'a> { return Ok((result, final_end)); } ExprEndState::Done => { - // Finalize - use saved state - let (saved_ops, saved_expr, saved_args, saved_end) = state_for_fallback; - let final_call = to_call(self, start, saved_expr, saved_args); + // No accumulator changes occur until a parse attempt succeeds. + let final_call = to_call(self, start, current_expr, current_args); - if saved_ops.is_empty() { - return Ok((final_call, saved_end)); + if ops.is_empty() { + return Ok((final_call, current_end)); } else { - let ops_slice = saved_ops.into_bump_slice(); + let ops_slice = ops.into_bump_slice(); let binops = Expr::BinOps { operands: ops_slice, last: final_call, }; - let result = self.alloc(Located::at(Region::new(start, saved_end), binops)); - return Ok((result, saved_end)); + let result = + self.alloc(Located::at(Region::new(start, current_end), binops)); + return Ok((result, current_end)); } } } @@ -383,8 +375,8 @@ fn to_call<'a>( /// State for expression end parsing (function application and binary operators). enum ExprEndState<'a> { - /// More function arguments accumulated - MoreArgs(Vec<&'a Located>>, Position), + /// One successfully parsed function argument + MoreArgs(&'a Located>, Position), /// Binary operator found, continue parsing chain MoreOps(&'a Located<&'a str>, &'a Located>, Position), /// Final expression found (let, case, if, lambda) after operator @@ -536,6 +528,29 @@ pub(crate) use assert_indented_expression_snapshot; #[cfg(test)] mod tests { + #[test] + fn operator_chain_arena_growth_is_linear() { + let mut previous = None; + for count in [1000, 2000, 4000] { + let source = vec!["x"; count].join(" + "); + let bump = bumpalo::Bump::new(); + let mut parser = crate::Parser::new(&bump, source.as_bytes()); + let (expression, _) = parser.expression().expect("operator chain"); + assert!(parser.is_eof()); + let nash_source::Expr::BinOps { operands, .. } = expression.value else { + panic!("expected binary operators"); + }; + assert_eq!(operands.len(), count - 1); + let allocated = bump.allocated_bytes(); + eprintln!("{count} operands: {allocated} arena bytes"); + if let Some(previous) = previous { + // Allow arena chunk rounding while rejecting quadratic growth. + assert!(allocated <= previous * 3, "superlinear arena growth"); + } + previous = Some(allocated); + } + } + #[test] fn call_with_bytes_argument() { assert_expression_snapshot!("f #\"01\" x"); diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md new file mode 100644 index 00000000..8ea67f2e --- /dev/null +++ b/plans/frontend-hardening.md @@ -0,0 +1,31 @@ +# Frontend hardening before Plan 07 + +Implement the six Nash/Alder comparison findings, then evaluate a direct inference replacement. Preserve Nash language semantics. Do not begin Plan 07 or add compatibility paths. + +## Chunks + +- [x] Remove expression-parser accumulator copies. A successful attempt returns one new argument or operator; only then does the loop change its accumulators. Existing parsing and error snapshots stay unchanged. +- [ ] Require valid UTF-8 at the parser boundary and remove unchecked conversions. +- [ ] Widen coordinates with a safe input bound; guard mutually recursive parsing with a measured nesting limit. +- [ ] Delete unused driver interface-cache machinery and orphaned dependencies. +- [ ] Separate canonical module data from local scopes without cloning the whole environment. +- [ ] Bound trait selection and evidence lookup using existing map ordering. +- [ ] Replace the constraint tree and intermediate inference Type with direct AST inference, subject to the adoption gates below. + +## Verification and commits + +Use a separate reviewed jj commit for each verified logical chunk. Before each commit run cargo fmt --all, cargo check --workspace --all-targets --all-features, cargo clippy --all-targets --all-features -- -D warnings, and cargo test. Add focused regressions first, inspect snapshots, and run relevant scratch cases. Update this record and Sampo changesets with each chunk. Do not push. + +Parser allocation regression: before the first change, 1,000 and 2,000 operands retained 4,192,960 and 16,775,744 arena bytes. Afterward 1,000 / 2,000 / 4,000 operands retain 130,048 / 261,056 / 523,136 bytes in both debug and release probes. The test checks complete consumption, operand count, and bounded growth. Function application and negative-argument paths now append a single parsed argument instead of copying the accumulated list. + +Chunk 1 verification passed: formatting, workspace check (all targets/features), clippy (all targets/features, warnings denied), full workspace tests, `nash check scratch`, and the release allocation test. Existing snapshots were unchanged. + +## Direct inference adoption gates + +Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. + +Remove both Constraint and the intermediate inference Type without introducing a delayed replacement IR. Count all production Rust changes across the affected pipeline, including helpers and moved code; tests are counted separately. Adopt only with demonstrated behavioral parity and a net production-code reduction. If a gate fails, keep the original engine and document concrete evidence. Do not retain dual engines in the final implementation. + +## Final verification + +Check Unicode and escape behavior, coordinates in debug and release, mixed recursive forms on a fixed stack, scope errors and shadowing, trait-selection equivalence, and inference differential cases. Re-run workspace checks against the final state. Report jj commits, measurements, parity results, net code changes, and any unmet adoption gate. Leave no unintended or uncommitted task changes. From 3bd387aabd3755da2f734db212859647c128d3f5 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 15:42:22 -0400 Subject: [PATCH 02/11] fix(parse): require UTF-8 source text Accept str instead of arbitrary byte buffers. Checked conversions prevent invalid string creation through the safe parser API. Signed-off-by: microproofs --- .sampo/changesets/parser-text-input.md | 6 + CLAUDE.md | 2 +- crates/nash-can/src/module.rs | 6 +- crates/nash-can/src/pattern.rs | 4 +- crates/nash-can/src/types.rs | 4 +- crates/nash-can/tests/core_casts.rs | 4 +- crates/nash-can/tests/do_notation.rs | 10 +- crates/nash-can/tests/impls.rs | 44 ++----- crates/nash-can/tests/kind_predicates.rs | 10 +- crates/nash-can/tests/kinds.rs | 26 ++-- crates/nash-can/tests/structural_eq.rs | 8 +- crates/nash-can/tests/traits.rs | 6 +- crates/nash-can/tests/twins.rs | 12 +- crates/nash-driver/src/compile.rs | 4 +- .../src/compile/nitpick_source_tests.rs | 6 +- crates/nash-parse/src/declaration/infix.rs | 2 +- crates/nash-parse/src/declaration/mod.rs | 4 +- crates/nash-parse/src/exposing.rs | 4 +- crates/nash-parse/src/expression/do_.rs | 2 +- crates/nash-parse/src/expression/mod.rs | 14 +-- ...xpression__string__tests__raw_unicode.snap | 19 +++ ...ion__string__tests__unicode_multiline.snap | 19 +++ ...n__string__tests__unicode_with_escape.snap | 19 +++ crates/nash-parse/src/expression/string.rs | 15 +++ crates/nash-parse/src/expression/variable.rs | 8 +- crates/nash-parse/src/import.rs | 2 +- crates/nash-parse/src/lib.rs | 20 +-- crates/nash-parse/src/module.rs | 6 +- crates/nash-parse/src/pattern/mod.rs | 6 +- crates/nash-parse/src/space.rs | 8 +- crates/nash-parse/src/string.rs | 14 ++- crates/nash-parse/src/tests_block.rs | 4 +- crates/nash-parse/src/type_.rs | 10 +- crates/nash-report/src/canonicalize.rs | 4 +- crates/nash-report/src/localizer.rs | 6 +- crates/nash-report/src/pattern.rs | 2 +- crates/nash-report/src/syntax/expr.rs | 2 +- crates/nash-report/src/syntax/tests.rs | 2 +- crates/nash-report/src/type_/tests.rs | 10 +- crates/nash-report/src/warning.rs | 4 +- crates/nash-solve/src/solve.rs | 20 +-- crates/nash-solve/tests/evidence.rs | 4 +- crates/nash-solve/tests/inference.rs | 116 +++++------------- .../tests/representation_predicates.rs | 12 +- plans/frontend-hardening.md | 4 +- 45 files changed, 237 insertions(+), 277 deletions(-) create mode 100644 .sampo/changesets/parser-text-input.md create mode 100644 crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__raw_unicode.snap create mode 100644 crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_multiline.snap create mode 100644 crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_with_escape.snap diff --git a/.sampo/changesets/parser-text-input.md b/.sampo/changesets/parser-text-input.md new file mode 100644 index 00000000..7fbdd36b --- /dev/null +++ b/.sampo/changesets/parser-text-input.md @@ -0,0 +1,6 @@ +--- +cargo/nash-parse: minor +cargo/nash-driver: patch +--- + +Require UTF-8 text at the parser boundary instead of arbitrary bytes. Remove unchecked string conversions and pass source text directly from the driver. diff --git a/CLAUDE.md b/CLAUDE.md index a58ccdb8..0e4f2f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ We use **bumpalo** for arena allocation: ```rust let bump = Bump::new(); let src: &str = bump.alloc_str(&file_contents); -let mut parser = Parser::new(&bump, src.as_bytes()); +let mut parser = Parser::new(&bump, src); ``` ### AST Type Guidelines diff --git a/crates/nash-can/src/module.rs b/crates/nash-can/src/module.rs index 66c0fe8a..a22e9ec8 100644 --- a/crates/nash-can/src/module.rs +++ b/crates/nash-can/src/module.rs @@ -1495,7 +1495,7 @@ mod tests { context: Context<'a, '_>, ) -> Result, Vec>> { let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, src); let module = parser.module().expect("expected successful parse"); canonicalize(bump, context, &module).map(|r| r.module) } @@ -2297,7 +2297,7 @@ mod tests { let bump = Bump::new(); let module = nash_parse::Parser::new( &bump, - b"module Main exposing (..)\nimport Builtin exposing (type bool(..))\nignore flag =\n case flag of\n False -> ()\n True -> ()\n", + "module Main exposing (..)\nimport Builtin exposing (type bool(..))\nignore flag =\n case flag of\n False -> ()\n True -> ()\n", ).module().unwrap(); let interfaces = BTreeMap::from([("Builtin", crate::kinds::builtin_interface(&bump))]); let result = canonicalize( @@ -2613,7 +2613,7 @@ mod tests { context: Context<'a, '_>, ) -> Result<(CanModule<'a>, Vec>), Vec>> { let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, src); let module = parser.module().expect("expected successful parse"); canonicalize(bump, context, &module).map(|r| (r.module, r.warnings)) } diff --git a/crates/nash-can/src/pattern.rs b/crates/nash-can/src/pattern.rs index ffca4c35..b8ca39f8 100644 --- a/crates/nash-can/src/pattern.rs +++ b/crates/nash-can/src/pattern.rs @@ -439,7 +439,7 @@ mod tests { fn env_with_bool<'a>(bump: &'a Bump) -> Env<'a> { let module = nash_parse::Parser::new( bump, - b"module Main exposing (..)\nimport Builtin exposing (type bool(..))\n", + "module Main exposing (..)\nimport Builtin exposing (type bool(..))\n", ) .module() .unwrap(); @@ -456,7 +456,7 @@ mod tests { fn parse_pattern<'a>(bump: &'a Bump, input: &str) -> &'a Located> { let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, src); let (pat, _end) = parser.pattern_expr().expect("expected successful parse"); pat } diff --git a/crates/nash-can/src/types.rs b/crates/nash-can/src/types.rs index 31218a0d..43a010d1 100644 --- a/crates/nash-can/src/types.rs +++ b/crates/nash-can/src/types.rs @@ -856,7 +856,7 @@ mod tests { fn parse_type<'a>(bump: &'a Bump, input: &str) -> &'a Located> { let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, src); let (typ, _end) = parser.type_expr().expect("expected successful parse"); typ } @@ -1028,7 +1028,7 @@ mod context_tests { let source = bump.alloc_str(&format!( "module Main exposing (..)\n\nf : {annotation}\nf x = x\n" )); - let mut parser = nash_parse::Parser::new(bump, source.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, source); parser.module().unwrap().values[0].value.annotation.unwrap() } diff --git a/crates/nash-can/tests/core_casts.rs b/crates/nash-can/tests/core_casts.rs index f6a63ff9..79ccc57f 100644 --- a/crates/nash-can/tests/core_casts.rs +++ b/crates/nash-can/tests/core_casts.rs @@ -36,9 +36,7 @@ fn casts_require_exact_core_package_for_every_import_route() { let source = bump.alloc_str(&format!( "module Main exposing (..)\n{import}\nlift : int -> Int\nlift = {reference}\n" )); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let interfaces = std::collections::BTreeMap::from([( "Builtin", nash_can::kinds::builtin_interface(&bump), diff --git a/crates/nash-can/tests/do_notation.rs b/crates/nash-can/tests/do_notation.rs index 3ceab145..9615ca81 100644 --- a/crates/nash-can/tests/do_notation.rs +++ b/crates/nash-can/tests/do_notation.rs @@ -2,7 +2,7 @@ use bumpalo::Bump; use indoc::indoc; fn monad(bump: &Bump, core: bool) -> nash_can::Interface<'_> { - let module = nash_parse::Parser::new(bump, b"module Monad exposing (Monad)\ntrait Monad 'm where\n bind : 'm 'a -> ('a -> 'm 'b) -> 'm 'b\n").module().unwrap(); + let module = nash_parse::Parser::new(bump, "module Monad exposing (Monad)\ntrait Monad 'm where\n bind : 'm 'a -> ('a -> 'm 'b) -> 'm 'b\n").module().unwrap(); let canonical = nash_can::canonicalize( bump, nash_can::Context { @@ -34,9 +34,7 @@ fn do_scopes_statements_and_uses_the_core_method() { x "# ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, nash_can::Context { @@ -62,9 +60,7 @@ fn do_rejects_missing_core_and_refutable_patterns() { ] { let interfaces = std::collections::BTreeMap::from([("Monad", monad(&bump, core))]); let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Monad\ntype option 'a = Some 'a\nrun m = do\n {pattern} <- {rhs}\n m\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let errors = nash_can::canonicalize( &bump, nash_can::Context { diff --git a/crates/nash-can/tests/impls.rs b/crates/nash-can/tests/impls.rs index 896d577b..f98a3cab 100644 --- a/crates/nash-can/tests/impls.rs +++ b/crates/nash-can/tests/impls.rs @@ -82,9 +82,7 @@ fn impl_cannot_own_an_imported_trait_and_imported_heads() { lower x = x " ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, nash_can::Context { @@ -141,9 +139,7 @@ fn explicit_lift_impls_cannot_overlap_the_big_reflexive_rule() { ("type alias Alias 'a = 'a", "(Alias 'a) (Alias 'b)"), ] { let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Lift exposing (Lift)\n{declaration}\nimpl Lift {heads} where\n lift x = x\n lower x = x\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); results.push( nash_can::canonicalize( &bump, @@ -173,7 +169,7 @@ fn explicit_lift_impls_cannot_overlap_the_big_reflexive_rule() { } fn core_lift<'a>(bump: &'a Bump) -> nash_can::Interface<'a> { - let module = nash_parse::Parser::new(bump, b"module Lift exposing (Lift)\ntrait Lift 'small 'big where\n lift : 'small -> 'big\n lower : 'big -> 'small\n").module().unwrap(); + let module = nash_parse::Parser::new(bump, "module Lift exposing (Lift)\ntrait Lift 'small 'big where\n lift : 'small -> 'big\n lower : 'big -> 'small\n").module().unwrap(); let result = nash_can::canonicalize( bump, nash_can::Context { @@ -197,9 +193,7 @@ fn reflexive_lift_proves_big_without_narrowing_rigid_variables() { ("container", "(List 'a)"), ] { let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Lift exposing (Lift)\ntype {container} 'a = Wrap 'a\ntrait Tag 'a where\n tag : 'a -> 'a\ntrait Tag 'a => Top 'a where\n top : 'a -> 'a\nimpl Lift {lifted} {lifted} => Tag ({container} 'a) where\n tag x = x\nimpl Top ({container} 'a) where\n top x = x\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); results.push( nash_can::canonicalize( &bump, @@ -225,9 +219,7 @@ fn reflexive_lift_accepts_big_but_not_const() { let mut results = Vec::new(); for head in ["Color", "()"] { let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Lift exposing (Lift)\ntype Color = Red\ntrait Lift 'a 'a => RoundTrip 'a where\n roundTrip : 'a -> 'a\nimpl RoundTrip {head} where\n roundTrip x = x\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); results.push( nash_can::canonicalize( &bump, @@ -262,9 +254,7 @@ fn reflexive_lift_requires_the_exact_core_trait_identity() { ), ] { let source = bump.alloc_str(&format!("module {module_name} exposing (..)\ntrait Lift 'small 'big where\n lift : 'small -> 'big\n lower : 'big -> 'small\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, nash_can::Context { @@ -386,9 +376,7 @@ fn superclass_impl_is_available_from_an_interface() { compare x = x " ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, nash_can::Context { @@ -540,9 +528,7 @@ fn global_overlap_between_core_modules() { ("Second", "(list int, list int)"), ] { let source = bump.alloc_str(&format!("module {name} exposing (..)\nimport Keep exposing (Keep)\nimpl Keep {head} where\n keep x = x\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, nash_can::Context { @@ -559,7 +545,7 @@ fn global_overlap_between_core_modules() { } let mut all_interfaces = interfaces.clone(); all_interfaces.extend(compiled); - let module = nash_parse::Parser::new(&bump, b"module Main exposing (..)\n") + let module = nash_parse::Parser::new(&bump, "module Main exposing (..)\n") .module() .unwrap(); let result = nash_can::canonicalize( @@ -597,9 +583,7 @@ fn global_impl_metadata_is_available_without_imports() { }; let interfaces = std::collections::BTreeMap::from([("Instances", interface)]); let source = "module Main exposing (..)\n"; - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, nash_can::Context { @@ -641,9 +625,7 @@ fn unit_and_tuple_impls_belong_to_core() { keep x = x " ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let ordinary = nash_can::canonicalize( &bump, nash_can::Context { @@ -856,9 +838,7 @@ fn canonicalize<'a>( source: &str, ) -> Result, Vec>> { let source = bump.alloc_str(source); - let module = nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(bump, source).module().unwrap(); nash_can::canonicalize(bump, nash_can::Context::default(), &module) } diff --git a/crates/nash-can/tests/kind_predicates.rs b/crates/nash-can/tests/kind_predicates.rs index 67a52407..5d814510 100644 --- a/crates/nash-can/tests/kind_predicates.rs +++ b/crates/nash-can/tests/kind_predicates.rs @@ -8,7 +8,7 @@ fn check<'a>(bump: &'a Bump, body: &str) -> Result, Vec< let source = bump.alloc_str(&format!( "module Main exposing (..)\n\nimport Builtin exposing (..)\n\n{body}\n" )); - let module = nash_parse::Parser::new(bump, source.as_bytes()) + let module = nash_parse::Parser::new(bump, source) .module() .expect("fixture parses"); let interfaces = BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(bump))]); @@ -221,9 +221,7 @@ fn imported<'a>(bump: &'a Bump, body: &str) -> Result, V let builtin = nash_can::kinds::builtin_interface(bump); let interfaces = BTreeMap::from([("Builtin", builtin)]); let source = bump.alloc_str("module Types exposing (..)\nimport Builtin exposing (..)\ntype Box 'a = Box 'a\ntype wrap 'f 'a = Wrap ('f 'a)\ntype option 'a = None | Some 'a\ntype alias count = int\n"); - let parsed = nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(bump, source).module().unwrap(); let checked = nash_can::canonicalize( bump, Context { @@ -236,9 +234,7 @@ fn imported<'a>(bump: &'a Bump, body: &str) -> Result, V let interface = nash_can::from_module(bump, &checked.module, &BTreeMap::new()); let interfaces = BTreeMap::from([("Builtin", builtin), ("Types", interface)]); let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Builtin exposing (..)\nimport Types exposing (..)\n{body}\n")); - let parsed = nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(bump, source).module().unwrap(); nash_can::canonicalize( bump, Context { diff --git a/crates/nash-can/tests/kinds.rs b/crates/nash-can/tests/kinds.rs index 2a113cdc..48c22b0c 100644 --- a/crates/nash-can/tests/kinds.rs +++ b/crates/nash-can/tests/kinds.rs @@ -7,7 +7,7 @@ macro_rules! assert_kinds_snapshot { ($source:expr) => {{ let bump = Bump::new(); let source = bump.alloc_str(&format!("module Main exposing (..)\n\nimport Builtin exposing (..)\n\n{}\n", $source)); - let module = nash_parse::Parser::new(&bump, source.as_bytes()).module().expect("source parses"); + let module = nash_parse::Parser::new(&bump, source).module().expect("source parses"); let interfaces = BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(&bump))]); let result = canonicalize(&bump, Context { package: None, interfaces: Some(&interfaces) }, &module).expect("kind checking succeeds"); let unions: Vec<_> = result.module.unions.iter().map(|u| (u.value.name.value, u.value.kind, u.value.context)).collect(); @@ -22,7 +22,7 @@ macro_rules! assert_kind_error_snapshot { ($source:expr, $expected:pat) => {{ let bump = Bump::new(); let source = bump.alloc_str(&format!("module Main exposing (..)\n\nimport Builtin exposing (..)\n\n{}\n", $source)); - let module = nash_parse::Parser::new(&bump, source.as_bytes()).module().expect("source parses"); + let module = nash_parse::Parser::new(&bump, source).module().expect("source parses"); let interfaces = BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(&bump))]); let errors = canonicalize(&bump, Context { package: None, interfaces: Some(&interfaces) }, &module).expect_err("declaration or annotation checking fails"); assert!(errors.iter().all(|error| matches!(error, $expected)), "wrong diagnostic: {errors:?}"); @@ -262,9 +262,7 @@ fn named_constructor_arity_remains_a_canonicalization_error() { let source = bump.alloc_str( "module Main exposing (..)\n\nimport Builtin exposing (..)\n\ntype alias x = int Int\n", ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let interfaces = BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(&bump))]); let errors = canonicalize( &bump, @@ -322,9 +320,7 @@ fn alias_substitution_preserves_application_head_and_argument() { fn applied_head_is_a_free_variable() { let bump = Bump::new(); let source = bump.alloc_str("module Main exposing (..)\n\ntype wrap 'a = Wrap ('f 'a)\n"); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let errors = canonicalize(&bump, Context::default(), &module).unwrap_err(); assert!(matches!( errors.as_slice(), @@ -338,9 +334,7 @@ fn annotation_storable_parameter() { assert_kinds_snapshot!("f : 'a -> list 'a -> list 'a\nf x xs = xs"); let bump = Bump::new(); let source = "module Main exposing (..)\nimport Builtin exposing (..)\nf : 'a -> list 'a -> list 'a\nf x xs = xs\n"; - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let interfaces = BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(&bump))]); let result = canonicalize( &bump, @@ -413,7 +407,7 @@ fn imported_interfaces_retain_higher_kinded_types() { let interface = { let source_arena = &destination; let source = source_arena.alloc_str("module Shapes exposing (type wrap(..), type applied)\n\ntype wrap 'f 'a = Wrap ('f 'a)\ntype alias applied 'f 'a = 'f 'a\n"); - let module = nash_parse::Parser::new(source_arena, source.as_bytes()) + let module = nash_parse::Parser::new(source_arena, source) .module() .unwrap(); let canonical = canonicalize(source_arena, Context::default(), &module).unwrap(); @@ -434,7 +428,7 @@ fn imported_interfaces_retain_higher_kinded_types() { assert!(matches!(interface.aliases[0].typ.value, Type::App { .. })); let interfaces = BTreeMap::from([("Shapes", interface)]); let source = destination.alloc_str("module Main exposing (..)\n\nimport Shapes exposing (type wrap)\n\ntype holder 'f 'a = Holder (wrap 'f 'a)\n"); - let module = nash_parse::Parser::new(&destination, source.as_bytes()) + let module = nash_parse::Parser::new(&destination, source) .module() .unwrap(); let canonical = canonicalize( @@ -569,9 +563,7 @@ fn annotation_checks_alias_contract_before_argument_splitting() { }; let interfaces = BTreeMap::from([("Restricted", interface)]); let source = bump.alloc_str("module Main exposing (..)\n\nimport Restricted exposing (type restricted)\n\nf : restricted ()\nf x = x\n"); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let errors = canonicalize( &bump, Context { @@ -782,7 +774,7 @@ macro_rules! self_application_cases { .split("```").nth(2 * $number - 1).expect("supplied case").trim(); let bump = Bump::new(); let source = bump.alloc_str(source); - let module = nash_parse::Parser::new(&bump, source.as_bytes()).module().expect("source parses"); + let module = nash_parse::Parser::new(&bump, source).module().expect("source parses"); let result = canonicalize(&bump, Context { package: None, interfaces: None }, &module); let errors = result.expect_err("self application fails the H98 occurs check"); assert!(errors.iter().all(|error| matches!(error, Error::KindInfinite { .. })), "declaration-time occurs check: {errors:?}"); diff --git a/crates/nash-can/tests/structural_eq.rs b/crates/nash-can/tests/structural_eq.rs index 73720fe5..9404d70c 100644 --- a/crates/nash-can/tests/structural_eq.rs +++ b/crates/nash-can/tests/structural_eq.rs @@ -19,9 +19,7 @@ fn structural_eq_rejects_big_overrides_only_for_exact_core_trait() { nash_can::kinds::builtin_interface(&bump), )]); let source = "module Eq exposing (Eq)\ntrait Eq 'a where\n eq : 'a -> 'a -> bool\n"; - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -37,9 +35,7 @@ fn structural_eq_rejects_big_overrides_only_for_exact_core_trait() { nash_can::from_module(&bump, &canonical.module, &Default::default()), ); let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Eq exposing (Eq)\nimport Builtin\ntype Token = Token Int\ntype alias Box = {{ item : Int }}\ntype alias Alias 'a = 'a\ntype alias Applied 'f 'a = 'f 'a\nimpl Eq {head} where\n eq _ _ = Builtin.True\n")); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, Context { diff --git a/crates/nash-can/tests/traits.rs b/crates/nash-can/tests/traits.rs index 4f5e3a4a..ef7e4d60 100644 --- a/crates/nash-can/tests/traits.rs +++ b/crates/nash-can/tests/traits.rs @@ -3,11 +3,7 @@ use indoc::indoc; fn parse<'a>(bump: &'a Bump, source: &str) -> &'a nash_source::Module<'a> { let source = bump.alloc_str(source); - bump.alloc( - nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(), - ) + bump.alloc(nash_parse::Parser::new(bump, source).module().unwrap()) } #[test] diff --git a/crates/nash-can/tests/twins.rs b/crates/nash-can/tests/twins.rs index 798c5472..0ca80f47 100644 --- a/crates/nash-can/tests/twins.rs +++ b/crates/nash-can/tests/twins.rs @@ -11,17 +11,13 @@ fn twin_imports_preserve_privacy_and_explicit_exposure() { ] { let bump = Bump::new(); let source = bump.alloc_str(&format!("module Status exposing ({exports})\ntype status = Ready | Waiting\ntype Status = Ready | Waiting\n")); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap(); let interface = nash_can::from_module(&bump, &canonical.module, &Default::default()); let interfaces = std::collections::BTreeMap::from([("Status", interface)]); for (constructor, expected) in [("Ready", bare), ("S.Ready", qualified)] { let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Status as S exposing ({imports})\nvalue = {constructor}\n")); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let result = nash_can::canonicalize( &bump, Context { @@ -63,9 +59,7 @@ fn twin_exception_rejects_unrelated_and_malformed_duplicates() { ] { let bump = Bump::new(); let source = bump.alloc_str(&format!("module Status exposing (..)\n{declarations}")); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let errors = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap_err(); assert!( errors diff --git a/crates/nash-driver/src/compile.rs b/crates/nash-driver/src/compile.rs index fe3a73f7..617da8d5 100644 --- a/crates/nash-driver/src/compile.rs +++ b/crates/nash-driver/src/compile.rs @@ -299,7 +299,7 @@ fn compile_module<'s>( let bump = store; let src: &str = bump.alloc_str(source); - let mut parser = nash_parse::Parser::new(bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, src); let module = match parser.module() { Ok(module) => module, Err(error) => { @@ -425,7 +425,7 @@ fn extract_imports(source: &str, current: &Url, known_modules: &[Url]) -> Vec( package: Option>, ) -> (&'a nash_ast::Module<'a>, nash_can::Annotations<'a>) { let source = bump.alloc_str(source); - let parsed = nash_parse::Parser::new(bump, source.as_bytes()) + let parsed = nash_parse::Parser::new(bump, source) .module() .expect("source must parse"); let can = nash_can::canonicalize( @@ -1412,9 +1412,7 @@ fn string_escapes_round_trip() { nash_nitpick::Pattern::Literal(nash_nitpick::Literal::Str(original)), ); let source = bump.alloc_str(&format!("module Main exposing (..)\nf {rendered} = ()\n")); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let can = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let nash_ast::Decls::Declare { definition: nash_ast::Def::Def { args, .. }, diff --git a/crates/nash-parse/src/declaration/infix.rs b/crates/nash-parse/src/declaration/infix.rs index 21141cb2..e8c3127c 100644 --- a/crates/nash-parse/src/declaration/infix.rs +++ b/crates/nash-parse/src/declaration/infix.rs @@ -129,7 +129,7 @@ mod tests { let bump = bumpalo::Bump::new(); let src = concat!($src, "\n"); let src_in_arena = bump.alloc_str(src); - let mut parser = Parser::new(&bump, src_in_arena.as_bytes()); + let mut parser = Parser::new(&bump, src_in_arena); match parser.infix_decl() { Ok(infix) => { insta::with_settings!({ diff --git a/crates/nash-parse/src/declaration/mod.rs b/crates/nash-parse/src/declaration/mod.rs index 795e8431..8536d398 100644 --- a/crates/nash-parse/src/declaration/mod.rs +++ b/crates/nash-parse/src/declaration/mod.rs @@ -150,7 +150,7 @@ macro_rules! assert_decl_snapshot { let bump = bumpalo::Bump::new(); let src = indoc::indoc!($src); let src_in_arena = bump.alloc_str(src); - let mut parser = crate::Parser::new(&bump, src_in_arena.as_bytes()); + let mut parser = crate::Parser::new(&bump, src_in_arena); match parser.declaration() { Ok((decl, _end)) => { parser.chomp(|_, _, _| ()).expect("expected trailing space"); @@ -173,7 +173,7 @@ macro_rules! assert_decl_error_snapshot { let bump = bumpalo::Bump::new(); let src = indoc::indoc!($src); let src_in_arena = bump.alloc_str(src); - let mut parser = crate::Parser::new(&bump, src_in_arena.as_bytes()); + let mut parser = crate::Parser::new(&bump, src_in_arena); let error = parser.declaration().expect_err("expected declaration parse error"); insta::with_settings!({ description => src, diff --git a/crates/nash-parse/src/exposing.rs b/crates/nash-parse/src/exposing.rs index 15ac795d..c3ea3c3a 100644 --- a/crates/nash-parse/src/exposing.rs +++ b/crates/nash-parse/src/exposing.rs @@ -187,7 +187,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.exposing(); match result { Ok(ref exposing) => { @@ -212,7 +212,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let error = parser.exposing().expect_err("expected exposing parse error"); insta::with_settings!({ description => format!("Code:\n\n{}", input), diff --git a/crates/nash-parse/src/expression/do_.rs b/crates/nash-parse/src/expression/do_.rs index f559f940..401eb5f2 100644 --- a/crates/nash-parse/src/expression/do_.rs +++ b/crates/nash-parse/src/expression/do_.rs @@ -173,7 +173,7 @@ mod tests { let bump = bumpalo::Bump::new(); let indented = crate::test_support::indent_fragment(indoc::indoc!($code)); let source = bump.alloc_str(&indented); - let mut parser = crate::Parser::new(&bump, source.as_bytes()); + let mut parser = crate::Parser::new(&bump, source); parser .chomp(|_, _, _| "space error") .expect("expected leading indent"); diff --git a/crates/nash-parse/src/expression/mod.rs b/crates/nash-parse/src/expression/mod.rs index 1c0f46b5..3aeae083 100644 --- a/crates/nash-parse/src/expression/mod.rs +++ b/crates/nash-parse/src/expression/mod.rs @@ -391,7 +391,7 @@ macro_rules! assert_expr_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let result = parser.term().expect("expected successful parse"); parser.chomp(|_, _, _| ()).expect("expected trailing space"); assert!(parser.is_eof(), "expression parser left trailing input"); @@ -411,7 +411,7 @@ macro_rules! assert_expr_error_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let result = parser.term().expect_err("expected parse error"); insta::with_settings!({ @@ -429,7 +429,7 @@ macro_rules! assert_expression_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let (result, _end) = parser.expression().expect("expected successful parse"); parser.chomp(|_, _, _| ()).expect("expected trailing space"); assert!(parser.is_eof(), "expression parser left trailing input"); @@ -449,7 +449,7 @@ macro_rules! assert_expression_error_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let result = parser.expression().expect_err("expected parse error"); insta::with_settings!({ @@ -471,7 +471,7 @@ macro_rules! assert_indented_expr_snapshot { let fragment = indoc::indoc!($code); let indented = $crate::test_support::indent_fragment(fragment); let src = bump.alloc_str(&indented); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); parser .chomp(|_, _, _| "space error") .expect("expected leading indent"); @@ -496,7 +496,7 @@ macro_rules! assert_indented_expression_snapshot { let fragment = indoc::indoc!($code); let indented = $crate::test_support::indent_fragment(fragment); let src = bump.alloc_str(&indented); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); parser .chomp(|_, _, _| "space error") .expect("expected leading indent"); @@ -534,7 +534,7 @@ mod tests { for count in [1000, 2000, 4000] { let source = vec!["x"; count].join(" + "); let bump = bumpalo::Bump::new(); - let mut parser = crate::Parser::new(&bump, source.as_bytes()); + let mut parser = crate::Parser::new(&bump, &source); let (expression, _) = parser.expression().expect("operator chain"); assert!(parser.is_eof()); let nash_source::Expr::BinOps { operands, .. } = expression.value else { diff --git a/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__raw_unicode.snap b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__raw_unicode.snap new file mode 100644 index 00000000..c06c26ca --- /dev/null +++ b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__raw_unicode.snap @@ -0,0 +1,19 @@ +--- +source: crates/nash-parse/src/expression/string.rs +description: "Code:\n\n\"รฉๆผข๐Ÿ˜€\"" +--- +Located { + region: Region { + start: Position { + line: 1, + column: 1, + }, + end: Position { + line: 1, + column: 12, + }, + }, + value: Str( + "รฉๆผข๐Ÿ˜€", + ), +} diff --git a/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_multiline.snap b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_multiline.snap new file mode 100644 index 00000000..3d645a56 --- /dev/null +++ b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_multiline.snap @@ -0,0 +1,19 @@ +--- +source: crates/nash-parse/src/expression/string.rs +description: "Code:\n\n\"\"\"รฉ\r\nๆผข๐Ÿ˜€\"\"\"" +--- +Located { + region: Region { + start: Position { + line: 1, + column: 1, + }, + end: Position { + line: 2, + column: 11, + }, + }, + value: Str( + "รฉ\nๆผข๐Ÿ˜€", + ), +} diff --git a/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_with_escape.snap b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_with_escape.snap new file mode 100644 index 00000000..f2e48ac5 --- /dev/null +++ b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__unicode_with_escape.snap @@ -0,0 +1,19 @@ +--- +source: crates/nash-parse/src/expression/string.rs +description: "Code:\n\n\"รฉ\\nๆผข\\u{1F600}\"" +--- +Located { + region: Region { + start: Position { + line: 1, + column: 1, + }, + end: Position { + line: 1, + column: 19, + }, + }, + value: Str( + "รฉ\nๆผข๐Ÿ˜€", + ), +} diff --git a/crates/nash-parse/src/expression/string.rs b/crates/nash-parse/src/expression/string.rs index e315f6db..fcda15a4 100644 --- a/crates/nash-parse/src/expression/string.rs +++ b/crates/nash-parse/src/expression/string.rs @@ -53,6 +53,21 @@ mod tests { assert_expr_snapshot!(r#""\u{1F600}""#); } + #[test] + fn raw_unicode() { + assert_expr_snapshot!(r#""รฉๆผข๐Ÿ˜€""#); + } + + #[test] + fn unicode_with_escape() { + assert_expr_snapshot!(r#""รฉ\nๆผข\u{1F600}""#); + } + + #[test] + fn unicode_multiline() { + assert_expr_snapshot!("\"\"\"รฉ\r\nๆผข๐Ÿ˜€\"\"\""); + } + #[test] fn error_endless() { assert_expr_error_snapshot!(r#""hello"#); diff --git a/crates/nash-parse/src/expression/variable.rs b/crates/nash-parse/src/expression/variable.rs index a38d3ad6..16227e55 100644 --- a/crates/nash-parse/src/expression/variable.rs +++ b/crates/nash-parse/src/expression/variable.rs @@ -210,7 +210,7 @@ impl<'a> Parser<'a> { /// Get a str slice from start_pos to current position. pub(crate) fn slice_from(&self, start_pos: usize) -> &'a str { let bytes = &self.src[start_pos..self.pos]; - unsafe { std::str::from_utf8_unchecked(bytes) } + std::str::from_utf8(bytes).expect("source slice must end at UTF-8 boundaries") } /// Check if current position is a dot followed by uppercase. @@ -239,8 +239,10 @@ impl<'a> Parser<'a> { self.advance(); // consume first lowercase char self.chomp_inner_chars(); - let module = unsafe { std::str::from_utf8_unchecked(&self.src[start_pos..module_end]) }; - let name = unsafe { std::str::from_utf8_unchecked(&self.src[name_start..self.pos]) }; + let module = std::str::from_utf8(&self.src[start_pos..module_end]) + .expect("source slice must end at UTF-8 boundaries"); + let name = std::str::from_utf8(&self.src[name_start..self.pos]) + .expect("source slice must end at UTF-8 boundaries"); if keyword::is_reserved(name) { return Err(to_error(row, col)); diff --git a/crates/nash-parse/src/import.rs b/crates/nash-parse/src/import.rs index ffc01856..023f66b4 100644 --- a/crates/nash-parse/src/import.rs +++ b/crates/nash-parse/src/import.rs @@ -187,7 +187,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.import(); match result { Ok(ref import) => { diff --git a/crates/nash-parse/src/lib.rs b/crates/nash-parse/src/lib.rs index 08199622..3a45e044 100644 --- a/crates/nash-parse/src/lib.rs +++ b/crates/nash-parse/src/lib.rs @@ -36,7 +36,7 @@ struct ParserState { /// Combines the arena allocator with parsing state for a unified API. /// All parsed AST nodes are allocated in the provided bump arena. /// -/// The source bytes should already be allocated in the arena (via `bump.alloc_str`), +/// The source text should already be allocated in the arena (via `bump.alloc_str`), /// so all string slices in the resulting AST share the `'a` lifetime. pub struct Parser<'a> { /// Arena allocator for AST nodes @@ -54,13 +54,19 @@ pub struct Parser<'a> { } impl<'a> Parser<'a> { - /// Create a new parser for the given source bytes. + /// Create a new parser for valid UTF-8 source text. /// /// The source should already be allocated in the arena. - pub fn new(bump: &'a Bump, src: &'a [u8]) -> Self { + /// + /// Arbitrary byte buffers are not a parser input: + /// ```compile_fail + /// let bump = bumpalo::Bump::new(); + /// nash_parse::Parser::new(&bump, &[b'"', 0xff, b'"']); + /// ``` + pub fn new(bump: &'a Bump, src: &'a str) -> Self { Parser { bump, - src, + src: src.as_bytes(), pos: 0, // Elm starts at 0; 1 is behaviorally identical because // `checkIndent`'s `col > 1` guard dominates at top level. @@ -477,7 +483,7 @@ mod tests { fn test_parser_new() { let bump = Bump::new(); let src = bump.alloc_str("hello"); - let parser = Parser::new(&bump, src.as_bytes()); + let parser = Parser::new(&bump, src); assert_eq!(parser.row(), 1); assert_eq!(parser.col(), 1); @@ -489,7 +495,7 @@ mod tests { fn test_parser_advance() { let bump = Bump::new(); let src = bump.alloc_str("ab\ncd"); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); assert_eq!(parser.position(), (1, 1)); parser.advance(); // 'a' @@ -506,7 +512,7 @@ mod tests { fn test_parser_eof() { let bump = Bump::new(); let src = bump.alloc_str("x"); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); assert!(!parser.is_eof()); parser.advance(); diff --git a/crates/nash-parse/src/module.rs b/crates/nash-parse/src/module.rs index 806793dc..5827eeb3 100644 --- a/crates/nash-parse/src/module.rs +++ b/crates/nash-parse/src/module.rs @@ -324,7 +324,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.module_header(); match result { Ok((kind, name, exposing)) => { @@ -381,7 +381,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.module(); match result { Ok(module) => { @@ -404,7 +404,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let error = parser.module().expect_err("expected module parse error"); insta::with_settings!({ description => format!("Code:\n\n{}", input), diff --git a/crates/nash-parse/src/pattern/mod.rs b/crates/nash-parse/src/pattern/mod.rs index aecccc92..34fd8e49 100644 --- a/crates/nash-parse/src/pattern/mod.rs +++ b/crates/nash-parse/src/pattern/mod.rs @@ -324,7 +324,7 @@ macro_rules! assert_pattern_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let (result, _end) = parser.pattern_expr().expect("expected successful parse"); parser.chomp(|_, _, _| ()).expect("expected trailing space"); assert!(parser.is_eof(), "pattern parser left trailing input"); @@ -344,7 +344,7 @@ macro_rules! assert_pattern_error_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let result = parser.pattern_expr().expect_err("expected parse error"); insta::with_settings!({ @@ -365,7 +365,7 @@ macro_rules! assert_indented_pattern_snapshot { let fragment = indoc::indoc!($code); let indented = $crate::test_support::indent_fragment(fragment); let src = bump.alloc_str(&indented); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); parser .chomp(|_, _, _| "space error") .expect("expected leading indent"); diff --git a/crates/nash-parse/src/space.rs b/crates/nash-parse/src/space.rs index 59aeeac2..8b9f4309 100644 --- a/crates/nash-parse/src/space.rs +++ b/crates/nash-parse/src/space.rs @@ -324,7 +324,7 @@ mod tests { fn parse_and_chomp(input: &str) -> (SpaceStatus, usize, Row, Col) { let bump = Bump::new(); let src = bump.alloc_str(input); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let (status, row, col) = parser.eat_spaces(); (status, parser.pos, row, col) @@ -405,7 +405,7 @@ mod tests { fn doc_comment_simple() { let bump = Bump::new(); let src = bump.alloc_str("{-| hello -}"); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.doc_comment(|_, _| "expected", |_, _, _| "space error"); assert!(result.is_ok()); @@ -420,7 +420,7 @@ mod tests { fn doc_comment_multiline() { let bump = Bump::new(); let src = bump.alloc_str("{-| line one\nline two -}"); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.doc_comment(|_, _| "expected", |_, _, _| "space error"); assert!(result.is_ok()); @@ -432,7 +432,7 @@ mod tests { fn doc_comment_not_doc() { let bump = Bump::new(); let src = bump.alloc_str("{- not a doc comment -}"); - let mut parser = Parser::new(&bump, src.as_bytes()); + let mut parser = Parser::new(&bump, src); let result = parser.doc_comment(|_, _| "expected", |_, _, _| "space error"); assert!(result.is_err()); diff --git a/crates/nash-parse/src/string.rs b/crates/nash-parse/src/string.rs index 5369493f..0790cbb0 100644 --- a/crates/nash-parse/src/string.rs +++ b/crates/nash-parse/src/string.rs @@ -87,8 +87,8 @@ impl<'a> Parser<'a> { } else { // Return slice directly let bytes = &self.src[start_pos..end_pos]; - // SAFETY: We've verified this is valid UTF-8 by scanning byte-by-byte - let s = unsafe { std::str::from_utf8_unchecked(bytes) }; + let s = std::str::from_utf8(bytes) + .expect("source slice must end at UTF-8 boundaries"); return StringResult::Ok(s); } } @@ -149,7 +149,8 @@ impl<'a> Parser<'a> { return self.build_escaped_string(start_pos, end_pos, true); } else { let bytes = &self.src[start_pos..end_pos]; - let s = unsafe { std::str::from_utf8_unchecked(bytes) }; + let s = std::str::from_utf8(bytes) + .expect("source slice must end at UTF-8 boundaries"); return StringResult::Ok(s); } } else { @@ -248,8 +249,8 @@ impl<'a> Parser<'a> { while pos < end && self.src[pos] != b'}' { pos += 1; } - let hex_str = - unsafe { std::str::from_utf8_unchecked(&self.src[hex_start..pos]) }; + let hex_str = std::str::from_utf8(&self.src[hex_start..pos]) + .expect("source slice must end at UTF-8 boundaries"); if let Ok(code) = u32::from_str_radix(hex_str, 16) && let Some(c) = char::from_u32(code) { @@ -272,7 +273,8 @@ impl<'a> Parser<'a> { // Regular UTF-8 character let width = utf8_char_width(b); let char_bytes = &self.src[pos..pos + width]; - let s = unsafe { std::str::from_utf8_unchecked(char_bytes) }; + let s = std::str::from_utf8(char_bytes) + .expect("source slice must end at UTF-8 boundaries"); result.push_str(s); pos += width; } diff --git a/crates/nash-parse/src/tests_block.rs b/crates/nash-parse/src/tests_block.rs index d2ee52db..f770b1f3 100644 --- a/crates/nash-parse/src/tests_block.rs +++ b/crates/nash-parse/src/tests_block.rs @@ -253,7 +253,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let source = bump.alloc_str(input); - let mut parser = Parser::new(&bump, source.as_bytes()); + let mut parser = Parser::new(&bump, source); let module = parser.module().expect("expected successful module parse"); insta::with_settings!({ description => format!("Code:\n\n{}", input), @@ -269,7 +269,7 @@ mod tests { let input = indoc!($input); let bump = Bump::new(); let source = bump.alloc_str(input); - let mut parser = Parser::new(&bump, source.as_bytes()); + let mut parser = Parser::new(&bump, source); let error = parser.module().expect_err("expected module parse error"); insta::with_settings!({ description => format!("Code:\n\n{}", input), diff --git a/crates/nash-parse/src/type_.rs b/crates/nash-parse/src/type_.rs index b8dcb521..074d4866 100644 --- a/crates/nash-parse/src/type_.rs +++ b/crates/nash-parse/src/type_.rs @@ -651,7 +651,7 @@ macro_rules! assert_type_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let (result, _end) = parser.type_expr().expect("expected successful parse"); parser.chomp(|_, _, _| ()).expect("expected trailing space"); assert!(parser.is_eof(), "type parser left trailing input"); @@ -670,7 +670,7 @@ macro_rules! assert_type_error_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let result = parser.type_expr().expect_err("expected parse error"); insta::with_settings!({ @@ -687,7 +687,7 @@ macro_rules! assert_scheme_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let (result, _end) = parser.type_scheme().expect("expected successful parse"); parser.chomp(|_, _, _| ()).expect("expected trailing space"); assert!(parser.is_eof(), "type scheme parser left trailing input"); @@ -706,7 +706,7 @@ macro_rules! assert_scheme_error_snapshot { ($code:expr) => {{ let bump = bumpalo::Bump::new(); let src = bump.alloc_str(indoc::indoc!($code)); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); let result = parser.type_scheme().expect_err("expected parse error"); insta::with_settings!({ @@ -727,7 +727,7 @@ macro_rules! assert_indented_type_snapshot { let fragment = indoc::indoc!($code); let indented = $crate::test_support::indent_fragment(fragment); let src = bump.alloc_str(&indented); - let mut parser = $crate::Parser::new(&bump, src.as_bytes()); + let mut parser = $crate::Parser::new(&bump, src); parser .chomp(|_, _, _| "space error") .expect("expected leading indent"); diff --git a/crates/nash-report/src/canonicalize.rs b/crates/nash-report/src/canonicalize.rs index eae2fefa..f8076295 100644 --- a/crates/nash-report/src/canonicalize.rs +++ b/crates/nash-report/src/canonicalize.rs @@ -2599,7 +2599,7 @@ mod branches { let input = "module Main exposing (..)\nfirst = missing\nsecond = absent\n"; let bump = bumpalo::Bump::new(); let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(&bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(&bump, src); let module = parser.module().expect("parse"); let errors = nash_can::canonicalize(&bump, nash_can::Context::default(), &module) .expect_err("canonical errors"); @@ -2710,7 +2710,7 @@ mod branches { let input = "module Bad exposing (..)\ntrait Keep 'a where\n keep : 'a -> 'a\nimpl Keep () where\n keep x = x\nimpl Keep () where\n keep x = x\n"; let bump = bumpalo::Bump::new(); let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(&bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(&bump, src); let module = parser.module().expect("parse"); let errors = nash_can::canonicalize(&bump, nash_can::Context::default(), &module) .expect_err("overlapping impls"); diff --git a/crates/nash-report/src/localizer.rs b/crates/nash-report/src/localizer.rs index 0be470e2..e336f837 100644 --- a/crates/nash-report/src/localizer.rs +++ b/crates/nash-report/src/localizer.rs @@ -190,7 +190,7 @@ mod tests { #[test] fn source_module_does_not_invent_default_imports() { let arena = bumpalo::Bump::new(); - let module = nash_parse::Parser::new(&arena, b"module Local exposing (..)\nx = 1\n") + let module = nash_parse::Parser::new(&arena, "module Local exposing (..)\nx = 1\n") .module() .unwrap(); let localizer = Localizer::from_module(&module, &[]); @@ -202,7 +202,7 @@ mod tests { let bump = bumpalo::Bump::new(); let module = nash_parse::Parser::new( &bump, - b"module Local exposing (..)\ntype Token = Token\ntype alias Wrapper = Token\n", + "module Local exposing (..)\ntype Token = Token\ntype alias Wrapper = Token\n", ) .module() .unwrap(); @@ -233,7 +233,7 @@ mod tests { fn shadowed_primitive_keeps_qualified_builtin_identity() { let bump = bumpalo::Bump::new(); let module = - nash_parse::Parser::new(&bump, b"module Local exposing (..)\ntype Int = Custom\n") + nash_parse::Parser::new(&bump, "module Local exposing (..)\ntype Int = Custom\n") .module() .unwrap(); let localizer = Localizer::from_module(&module, &[]); diff --git a/crates/nash-report/src/pattern.rs b/crates/nash-report/src/pattern.rs index 5f7acd5b..881c39d6 100644 --- a/crates/nash-report/src/pattern.rs +++ b/crates/nash-report/src/pattern.rs @@ -91,7 +91,7 @@ mod tests { fn reports(input: &str) -> Vec { let bump = bumpalo::Bump::new(); let text = bump.alloc_str(input); - let module = nash_parse::Parser::new(&bump, text.as_bytes()) + let module = nash_parse::Parser::new(&bump, text) .module() .expect("parse"); let interfaces = std::collections::BTreeMap::from([( diff --git a/crates/nash-report/src/syntax/expr.rs b/crates/nash-report/src/syntax/expr.rs index d7cdb830..c0b9c9c8 100644 --- a/crates/nash-report/src/syntax/expr.rs +++ b/crates/nash-report/src/syntax/expr.rs @@ -1583,7 +1583,7 @@ mod tests { fn $name() { let input = $input; let bump = bumpalo::Bump::new(); - let error = nash_parse::Parser::new(&bump, input.as_bytes()) + let error = nash_parse::Parser::new(&bump, input) .module() .expect_err("expected syntax error"); let source = Source::new(input); diff --git a/crates/nash-report/src/syntax/tests.rs b/crates/nash-report/src/syntax/tests.rs index b02d3101..01bb045b 100644 --- a/crates/nash-report/src/syntax/tests.rs +++ b/crates/nash-report/src/syntax/tests.rs @@ -3,7 +3,7 @@ use crate::render_plain; fn parse_error_report(input: &str) -> String { let bump = bumpalo::Bump::new(); - let error = nash_parse::Parser::new(&bump, input.as_bytes()) + let error = nash_parse::Parser::new(&bump, input) .module() .expect_err("expected parse error"); render_plain( diff --git a/crates/nash-report/src/type_/tests.rs b/crates/nash-report/src/type_/tests.rs index d7997e3a..846c62bd 100644 --- a/crates/nash-report/src/type_/tests.rs +++ b/crates/nash-report/src/type_/tests.rs @@ -560,7 +560,7 @@ fn expression_and_pattern_without_expectation() { fn type_error_reports(input: &str) -> String { let bump = bumpalo::Bump::new(); let source = bump.alloc_str(input); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) + let module = nash_parse::Parser::new(&bump, source) .module() .expect("parse fixture"); let localizer = Localizer::from_module(&module, &[]); @@ -784,9 +784,7 @@ fn problem_hints() { fn missing_impl_local_union_deriving_not_yet_available() { let source = "module Main exposing (..)\ntype step = Done | Next Builtin.int\n"; let bump = bumpalo::Bump::new(); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let l = Localizer::from_module(&module, &[]); let typ = ErrorType::Type { home: nash_ast::ModuleName { @@ -820,9 +818,7 @@ fn missing_impl_local_union_deriving_not_yet_available() { fn missing_impl_imported_or_custom_trait_has_no_derive_hint() { let source = "module Main exposing (..)\ntype step = Done\n"; let bump = bumpalo::Bump::new(); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let l = Localizer::from_module(&module, &[]); for (home, trait_) in [ ( diff --git a/crates/nash-report/src/warning.rs b/crates/nash-report/src/warning.rs index 495cafd8..2b4e03d3 100644 --- a/crates/nash-report/src/warning.rs +++ b/crates/nash-report/src/warning.rs @@ -45,7 +45,7 @@ mod tests { fn only_warning(input: &str) -> Report { let bump = bumpalo::Bump::new(); let text = bump.alloc_str(input); - let module = nash_parse::Parser::new(&bump, text.as_bytes()) + let module = nash_parse::Parser::new(&bump, text) .module() .expect("parse"); let can = nash_can::canonicalize(&bump, nash_can::Context::default(), &module) @@ -75,7 +75,7 @@ mod tests { }, )]); let text = bump.alloc_str(input); - let module = nash_parse::Parser::new(&bump, text.as_bytes()) + let module = nash_parse::Parser::new(&bump, text) .module() .expect("parse"); let can = nash_can::canonicalize( diff --git a/crates/nash-solve/src/solve.rs b/crates/nash-solve/src/solve.rs index 0e98a1e5..2a516de3 100644 --- a/crates/nash-solve/src/solve.rs +++ b/crates/nash-solve/src/solve.rs @@ -3406,9 +3406,7 @@ mod copy_tests { fn superclass_givens_record_transitive_paths_and_substitute_arguments() { let bump = Bump::new(); let source = "module Main exposing (..)\ntype Container 'a = Wrap 'a\ntrait Eq 'a where\n eq : 'a -> 'a\ntrait Eq 'a => Ord 'a where\n ord : 'a -> 'a\ntrait Ord 'a => Top 'a where\n top : 'a -> 'a\ntrait Eq 'b => Select 'a 'b where\n select : 'a -> 'b -> 'a\nf : Top 'a => 'a -> 'a\nf x = eq x\ng : Select 'a 'b => 'a -> 'b -> 'b\ng x y = eq y\nh : (Top 'a, Eq 'a) => 'a -> 'a\nh x = eq x\n"; - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); @@ -3480,9 +3478,7 @@ mod copy_tests { fn givens_discharge_body_uses_without_escaping_their_scope() { let bump = Bump::new(); let source = "module Main exposing (..)\ntrait Keep 'a where\n keep : 'a -> 'a\nf : Keep 'a => 'a -> 'a\nf x = keep x\ng : Keep () => ()\ng = keep ()\nh = keep ()\n"; - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); @@ -3547,9 +3543,7 @@ mod copy_tests { fn scheme_records_freeze_local_quantifiers_before_outer_generalization() { let bump = Bump::new(); let source = "module Main exposing (..)\nouter x =\n let\n local y = (x, y)\n in\n local ()\n"; - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); @@ -3636,9 +3630,7 @@ mod copy_tests { fn nested_impl_solutions_preserve_substitution_and_child_origins() { let bump = Bump::new(); let source = "module Main exposing (..)\ntype Color = Red\ntrait Keep 'a where\n keep : 'a -> 'a\nimpl Keep Color where\n keep x = x\nimpl Keep 'a => Keep (list 'a) where\n keep xs = xs\nvalue = keep [[Red]]\n"; - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); @@ -3751,9 +3743,7 @@ mod copy_tests { fn retained_impl_children_and_recursive_uses_reference_final_context_slots() { let bump = Bump::new(); let source = "module Main exposing (..)\ntrait Base 'a where\n base : 'a -> 'a\ntrait Base 'a => Strong 'a where\n strong : 'a -> 'a\ntrait Strong 'a => Top 'a where\n top : 'a -> 'a\nimpl Base 'a => Base (list 'a) where\n base xs = xs\nf x = (base [let local y = g y in local x], top x)\ng x = case f x of\n (xs, y) -> y\nh x = (f x, f x)\n"; - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut group_binder = None; diff --git a/crates/nash-solve/tests/evidence.rs b/crates/nash-solve/tests/evidence.rs index 95ff0078..0df65cb1 100644 --- a/crates/nash-solve/tests/evidence.rs +++ b/crates/nash-solve/tests/evidence.rs @@ -6,9 +6,7 @@ use nash_solve::evidence::{Failure, resolve}; fn fixture<'a>(bump: &'a Bump, source: &str) -> (Tables<'a>, Annotations<'a>) { let source = bump.alloc_str(source); - let parsed = nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(bump, source).module().unwrap(); let canonical = nash_can::canonicalize( bump, Context { diff --git a/crates/nash-solve/tests/inference.rs b/crates/nash-solve/tests/inference.rs index 3bf5c37a..e24e1475 100644 --- a/crates/nash-solve/tests/inference.rs +++ b/crates/nash-solve/tests/inference.rs @@ -31,9 +31,7 @@ fn literal_interfaces(bump: &Bump) -> std::collections::BTreeMap<&str, nash_can: fromBytes x = x " )); - let module = nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(bump, source).module().unwrap(); let can = nash_can::canonicalize( bump, Context { @@ -55,7 +53,7 @@ fn literal_interfaces(bump: &Bump) -> std::collections::BTreeMap<&str, nash_can: fn infer<'a>(bump: &'a Bump, input: &str) -> Result, Vec>> { let src = bump.alloc_str(input); - let mut parser = nash_parse::Parser::new(bump, src.as_bytes()); + let mut parser = nash_parse::Parser::new(bump, src); let module = parser.module().expect("expected successful parse"); let interfaces = literal_interfaces(bump); let can_result = nash_can::canonicalize( @@ -517,9 +515,7 @@ fn solved_output_records_empty_context_calls_and_preserves_capture_names() { (local (), local x) "# ); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); @@ -594,9 +590,7 @@ fn builtin_list_annotations_match_literals_and_patterns() { head :: tail -> head "# )); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let interfaces = std::collections::BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(&bump))]); let canonical = nash_can::canonicalize( @@ -818,9 +812,7 @@ fn literal_method_defaulting_retries_impls_with_the_enclosing_given() { .replace("FromInt", trait_name) .replace("fromInt", method) .replace("int", primitive); - let parsed = nash_parse::Parser::new(&bump, literal.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, &literal).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -872,9 +864,7 @@ fn literal_method_defaulting_retries_impls_with_the_enclosing_given() { .replace("FromInt", trait_name) .replace("fromInt", method) .replace("int", primitive); - let parsed = nash_parse::Parser::new(&bump, main.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, &main).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -1478,9 +1468,7 @@ fn recursive_definition_metadata_preserves_names_types_and_given_variables() { h x = f x " ); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap(); let mut original_names = Vec::new(); let mut decls = canonical.module.decls; @@ -1665,7 +1653,7 @@ fn negation_retains_num_evidence() { let mut interfaces = literal_interfaces(&bump); let num = nash_parse::Parser::new( &bump, - b"module Num exposing (Num)\ntrait Num 'a where\n negate : 'a -> 'a\n", + "module Num exposing (Num)\ntrait Num 'a where\n negate : 'a -> 'a\n", ) .module() .unwrap(); @@ -1683,9 +1671,7 @@ fn negation_retains_num_evidence() { nash_can::from_module(&bump, &num.module, &Default::default()), ); let source = bump.alloc_str("module Main exposing (..)\nimport Num as N\nimport Literal exposing (..)\nnegate x = x\nflip x = -x\nnegative = -7\n"); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let can = nash_can::canonicalize( &bump, Context { @@ -1761,9 +1747,7 @@ fn literal_syntax_records_impls_and_pattern_givens() { let bump = Bump::new(); let mut interfaces = literal_interfaces(&bump); let eq_source = bump.alloc_str("module Eq exposing (..)\nimport Builtin exposing (..)\ntrait Eq 'a where eq : 'a -> 'a -> bool\n"); - let eq_module = nash_parse::Parser::new(&bump, eq_source.as_bytes()) - .module() - .unwrap(); + let eq_module = nash_parse::Parser::new(&bump, eq_source).module().unwrap(); let eq = nash_can::canonicalize( &bump, Context { @@ -1783,9 +1767,7 @@ fn literal_syntax_records_impls_and_pattern_givens() { ("bytes", "#\"00ff\"", "FromBytes"), ] { let input = bump.alloc_str(&format!("module Main exposing (..)\nimport Builtin exposing (..)\nfixed : {primitive}\nfixed = {literal}\nmatch value =\n case value of\n {literal} -> ()\n _ -> ()\n")); - let parsed = nash_parse::Parser::new(&bump, input.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, input).module().unwrap(); let can = nash_can::canonicalize( &bump, Context { @@ -1873,9 +1855,7 @@ fn user_twins_preserve_local_imported_and_pattern_identity() { "module Main exposing (..)\nimport Status as S exposing (..)\nsmallPayload x y = Payload x y\nbigPayload x y = S.Payload x y\nreadSmall (Payload x y) = (x, y)\nreadBig (S.Payload x y) = (x, y)\nlittleUse = Ready\nbigUse = S.Ready\nlittlePattern x = case x of\n Ready -> ()\n Waiting -> ()\nbigPattern x = case x of\n S.Ready -> ()\n S.Waiting -> ()\n", ] { let source = bump.alloc_str(source); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -2102,9 +2082,7 @@ fn destructured_bindings_preserve_contexts_and_polymorphism() { (identity (), identity (\x -> x)) "# )); - let parsed = nash_parse::Parser::new(&bump, input.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, input).module().unwrap(); let can = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); let constraint = nash_constrain::constrain(&bump, &mut uf, &can.module); @@ -2518,9 +2496,7 @@ fn operator_methods_preserve_provider_and_backing_method() { ]; let mut output = Vec::new(); for (name, source) in sources { - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -2805,9 +2781,7 @@ fn nested_operator_sections_apply() { let operators = "module Operators exposing (..)\n\ninfix left 6 (+) = first\n\nfirst x y = x\n"; let annotations = infer(&bump, operators).expect("operator module infers"); let source = bump.alloc_str(operators); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &module).unwrap(); let interface = nash_can::from_module(&bump, &canonical.module, &annotations); let mut interfaces = literal_interfaces(&bump); @@ -2823,9 +2797,7 @@ fn nested_operator_sections_apply() { "# ); let source = bump.alloc_str(input); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -2934,9 +2906,7 @@ fn higher_kinded_partial_alias_retains_its_nominal_impl() { ), ] { let source = bump.alloc_str(source); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3063,9 +3033,7 @@ fn imported_values_retain_declared_and_inferred_representation_contexts() { wrapper x xs = first x xs " )); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3115,9 +3083,7 @@ fn imported_values_retain_declared_and_inferred_representation_contexts() { ), name = name )); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3157,9 +3123,7 @@ fn do_infers_monad() { bind : 'm 'a -> ('a -> 'm 'b) -> 'm 'b "# ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3184,9 +3148,7 @@ fn do_infers_monad() { expanded m = bind m (\x -> bind m (\y -> pure (x, y))) "# ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3266,7 +3228,7 @@ fn nested_use_requires_owners_storable_constraint() { } fn lift_interface(bump: &Bump, core: bool) -> nash_can::Interface<'_> { - let module = nash_parse::Parser::new(bump, b"module Lift exposing (Lift)\ntrait Lift 'small 'big where\n lift : 'small -> 'big\n lower : 'big -> 'small\nimpl Lift () () where\n lift x = x\n lower x = x\n").module().unwrap(); + let module = nash_parse::Parser::new(bump, "module Lift exposing (Lift)\ntrait Lift 'small 'big where\n lift : 'small -> 'big\n lower : 'big -> 'small\nimpl Lift () () where\n lift x = x\n lower x = x\n").module().unwrap(); let canonical = nash_can::canonicalize( bump, Context { @@ -3309,9 +3271,7 @@ fn reflexive_lift_retains_big_evidence() { nested = keep (Box Red) "# ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3384,9 +3344,7 @@ fn reflexive_lift_neither_narrows_types_nor_uses_foreign_identity() { ] { let interfaces = std::collections::BTreeMap::from([("Lift", lift_interface(&bump, core))]); let source = bump.alloc_str(&format!("module Main exposing (..)\nimport Lift exposing (Lift)\ntype Color = Red\nbad : {annotation}\nbad x = lift x\n")); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3493,9 +3451,7 @@ fn higher_kinded_traits_resolve_distinct_constructors() { ); let bump = Bump::new(); let source = bump.alloc_str(source); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3552,7 +3508,7 @@ fn imported_higher_kinded_value_preserves_application() { let bump = Bump::new(); let module = nash_parse::Parser::new( &bump, - b"module Higher exposing (value)\nvalue : 'f 'a -> 'f 'a\nvalue x = x\n", + "module Higher exposing (value)\nvalue : 'f 'a -> 'f 'a\nvalue x = x\n", ) .module() .unwrap(); @@ -3588,9 +3544,7 @@ fn imported_higher_kinded_value_preserves_application() { let interfaces = std::collections::BTreeMap::from([("Higher", interface)]); let source = bump.alloc_str("module Main exposing (..)\n\nimport Higher\n\nvalue = Higher.value\n"); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3648,9 +3602,7 @@ fn core_cast_schemes_preserve_nominal_source_and_target_types() { validate = Builtin.castValidateData " ); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let interfaces = literal_interfaces(&bump); let canonical = nash_can::canonicalize( &bump, @@ -3726,9 +3678,7 @@ fn literal_impls_preserve_little_defaults_with_big_and_utf8_candidates() { None, ), ] { - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -3804,9 +3754,7 @@ fn big_equality_is_automatic_and_retains_structural_evidence() { None, ), ] { - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, Context { @@ -4066,9 +4014,7 @@ fn deferred_captured_field_preserves_trait_evidence() { (get (), g p) "# ); - let module = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &module).unwrap(); let mut uf = UnionFind::new(); let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); diff --git a/crates/nash-solve/tests/representation_predicates.rs b/crates/nash-solve/tests/representation_predicates.rs index 7f24260e..2a83c16b 100644 --- a/crates/nash-solve/tests/representation_predicates.rs +++ b/crates/nash-solve/tests/representation_predicates.rs @@ -9,9 +9,7 @@ fn infer<'a>( let source = bump.alloc_str(&format!( "module Main exposing (..)\nimport Builtin exposing (..)\n{body}\n" )); - let parsed = nash_parse::Parser::new(bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(bump, source).module().unwrap(); let interfaces = BTreeMap::from([("Builtin", nash_can::kinds::builtin_interface(bump))]); let canonical = nash_can::canonicalize( bump, @@ -283,9 +281,7 @@ fn imported_scheme_defaults_are_fixed_before_instantiation() { let source = bump.alloc_str(&format!( "module {name} exposing (..)\nimport Builtin exposing (..)\n{body}\n" )); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, nash_can::Context { @@ -333,9 +329,7 @@ fn representation_givens_follow_transparent_alias_bodies() { let source = bump.alloc_str(&format!( "module {name} exposing (..)\nimport Builtin exposing (..)\n{body}\n" )); - let parsed = nash_parse::Parser::new(&bump, source.as_bytes()) - .module() - .unwrap(); + let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize( &bump, nash_can::Context { diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index 8ea67f2e..8e1d076d 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -5,7 +5,7 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen ## Chunks - [x] Remove expression-parser accumulator copies. A successful attempt returns one new argument or operator; only then does the loop change its accumulators. Existing parsing and error snapshots stay unchanged. -- [ ] Require valid UTF-8 at the parser boundary and remove unchecked conversions. +- [x] Require valid UTF-8 at the parser boundary and remove unchecked conversions. - [ ] Widen coordinates with a safe input bound; guard mutually recursive parsing with a measured nesting limit. - [ ] Delete unused driver interface-cache machinery and orphaned dependencies. - [ ] Separate canonical module data from local scopes without cloning the whole environment. @@ -20,6 +20,8 @@ Parser allocation regression: before the first change, 1,000 and 2,000 operands Chunk 1 verification passed: formatting, workspace check (all targets/features), clippy (all targets/features, warnings denied), full workspace tests, `nash check scratch`, and the release allocation test. Existing snapshots were unchanged. +Chunk 2 requires `Parser::new` source text to be `&str`; all callers are updated directly, with no byte-input adapter. All seven unchecked UTF-8 conversions are replaced with checked conversions. Added snapshots preserve raw Unicode, mixed Unicode/escapes, and CRLF normalization. A compile-fail doctest rejects arbitrary bytes. Formatting, workspace check, clippy, full tests (including the doctest), and `nash check scratch` passed. Only the three reviewed new Unicode snapshots were added. + ## Direct inference adoption gates Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. From 8bb97f680047cc8818b890af407247cdd585000e Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 15:50:43 -0400 Subject: [PATCH 03/11] fix(parse): widen source coordinates Use source-sized coordinates and checked protocol conversion. Preserve full diagnostic widths and reject Unicode code overflow. Signed-off-by: microproofs --- .sampo/changesets/source-coordinates.md | 10 +++ CLAUDE.md | 2 +- crates/nash-can/src/kinds.rs | 4 +- crates/nash-can/src/module.rs | 20 ++--- .../nash-language-server/src/diagnostics.rs | 82 +++++++++++++------ crates/nash-parse/src/declaration/infix.rs | 2 +- crates/nash-parse/src/error.rs | 24 +++--- crates/nash-parse/src/expression/do_.rs | 2 +- crates/nash-parse/src/expression/macro_.rs | 2 +- ...sts__error_overflowing_unicode_escape.snap | 13 +++ crates/nash-parse/src/expression/string.rs | 5 ++ crates/nash-parse/src/expression/variable.rs | 25 +++--- crates/nash-parse/src/import.rs | 2 +- crates/nash-parse/src/lib.rs | 59 +++++++++++-- crates/nash-parse/src/pattern/term.rs | 4 +- crates/nash-parse/src/space.rs | 4 +- crates/nash-parse/src/string.rs | 11 +-- crates/nash-parse/src/symbol.rs | 8 +- crates/nash-parse/src/type_.rs | 7 +- crates/nash-region/src/lib.rs | 9 +- crates/nash-report/src/code.rs | 14 ++-- crates/nash-report/src/code/snippet.rs | 6 +- crates/nash-report/src/json.rs | 2 +- crates/nash-report/src/render.rs | 2 +- crates/nash-report/src/syntax/expr.rs | 12 +-- crates/nash-report/src/syntax/mod.rs | 2 +- crates/nash-report/src/syntax/pattern.rs | 2 +- crates/nash-source/src/lib.rs | 4 +- docs/overview.md | 6 ++ plans/frontend-hardening.md | 5 +- 30 files changed, 232 insertions(+), 118 deletions(-) create mode 100644 .sampo/changesets/source-coordinates.md create mode 100644 crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__error_overflowing_unicode_escape.snap diff --git a/.sampo/changesets/source-coordinates.md b/.sampo/changesets/source-coordinates.md new file mode 100644 index 00000000..07d57908 --- /dev/null +++ b/.sampo/changesets/source-coordinates.md @@ -0,0 +1,10 @@ +--- +cargo/nash-region: minor +cargo/nash-source: minor +cargo/nash-parse: minor +cargo/nash-can: patch +cargo/nash-report: minor +cargo/nash-language-server: patch +--- + +Use source-sized coordinates and diagnostic widths throughout parsing and reporting. Check LSP coordinate conversion instead of truncating. Reject oversized Unicode escapes without integer overflow, and make arbitrary lookahead offsets safe. diff --git a/CLAUDE.md b/CLAUDE.md index 0e4f2f12..4a845393 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ let mut parser = Parser::new(&bump, src); **Inline small `Copy` types** - don't put them behind `&'a`: - Small enums (e.g., `VarType`, `Associativity`) - just store the value - Newtypes around primitives (e.g., `Precedence(u16)`) - just store the value -- `Region` (8 bytes of integers) - same size as a pointer, no benefit to indirection +- `Region` uses native-size source coordinates (32 bytes on 64-bit hosts); keep it inline in AST nodes. **Use `&'a T` for**: - Large types diff --git a/crates/nash-can/src/kinds.rs b/crates/nash-can/src/kinds.rs index d8034e30..eb3797b8 100644 --- a/crates/nash-can/src/kinds.rs +++ b/crates/nash-can/src/kinds.rs @@ -928,7 +928,7 @@ struct ContextInput<'a> { enum ContextFailure<'a> { Representation(RepresentationFailure<'a>), IrregularRecursion { - reference: GroupReference<'a>, + reference: &'a GroupReference<'a>, parameter: &'a str, }, } @@ -990,7 +990,7 @@ fn close_contexts<'a>( && !matches!(typ.value, Type::Var(_)) { return Err(ContextFailure::IrregularRecursion { - reference, + reference: bump.alloc(reference), parameter, }); } diff --git a/crates/nash-can/src/module.rs b/crates/nash-can/src/module.rs index a22e9ec8..240e6ab2 100644 --- a/crates/nash-can/src/module.rs +++ b/crates/nash-can/src/module.rs @@ -36,18 +36,6 @@ pub struct CanResult<'a> { pub warnings: Vec>, } -fn canonicalize_header<'a>( - context: Context<'a, '_>, - module: &SourceModule<'a>, -) -> Result, Error<'a>> { - let name = module.name.ok_or(Error::MissingModuleHeader)?; - - Ok(ModuleName { - package: context.package, - name: name.value, - }) -} - pub fn canonicalize<'a>( bump: &'a Bump, context: Context<'a, '_>, @@ -68,7 +56,13 @@ pub fn canonicalize<'a>( region, }]); } - let home = canonicalize_header(context, module).map_err(|e| vec![e])?; + let name = module + .name + .ok_or_else(|| vec![Error::MissingModuleHeader])?; + let home = ModuleName { + package: context.package, + name: name.value, + }; let mut env = environment::foreign::create_initial_env(bump, home, context.interfaces, module.imports)?; diff --git a/crates/nash-language-server/src/diagnostics.rs b/crates/nash-language-server/src/diagnostics.rs index 1dca6e1a..ab91d010 100644 --- a/crates/nash-language-server/src/diagnostics.rs +++ b/crates/nash-language-server/src/diagnostics.rs @@ -7,6 +7,15 @@ use tower_lsp_server::ls_types::{ }; pub fn to_lsp(report: &Report, source: &Source<'_>, uri: &Uri) -> Diagnostic { + checked_to_lsp(report, source, uri).unwrap_or_else(|| Diagnostic { + severity: Some(DiagnosticSeverity::ERROR), + source: Some("nash".into()), + message: "Cannot represent this diagnostic's source position in LSP: line or UTF-16 column exceeds the protocol's 32-bit limit.".into(), + ..Diagnostic::default() + }) +} + +fn checked_to_lsp(report: &Report, source: &Source<'_>, uri: &Uri) -> Option { let related = match &report.snippet { Snippet::Pair { first, .. } => Some((first.region, first.text.clone())), Snippet::Region { @@ -15,8 +24,18 @@ pub fn to_lsp(report: &Report, source: &Source<'_>, uri: &Uri) -> Diagnostic { } if *highlight != report.region => Some((*highlight, "Related source".into())), _ => None, }; - Diagnostic { - range: to_range(report.region, source), + let related_information = match related { + Some((region, message)) => Some(vec![DiagnosticRelatedInformation { + location: Location { + uri: uri.clone(), + range: to_range(region, source)?, + }, + message, + }]), + None => None, + }; + Some(Diagnostic { + range: to_range(report.region, source)?, severity: Some(match report.severity { Severity::Error => DiagnosticSeverity::ERROR, Severity::Warning => DiagnosticSeverity::WARNING, @@ -28,56 +47,67 @@ pub fn to_lsp(report: &Report, source: &Source<'_>, uri: &Uri) -> Diagnostic { report.before.render(80, false), report.after.render(80, false) ), - related_information: related.map(|(region, message)| { - vec![DiagnosticRelatedInformation { - location: Location { - uri: uri.clone(), - range: to_range(region, source), - }, - message, - }] - }), + related_information, data: (!report.suggestions.is_empty()).then(|| serde_json::json!(report.suggestions)), ..Diagnostic::default() - } + }) } -pub fn to_range(region: Region, source: &Source<'_>) -> Range { - Range::new( - to_position(region.start, source), - to_position(region.end, source), - ) +pub fn to_range(region: Region, source: &Source<'_>) -> Option { + Some(Range::new( + to_position(region.start, source)?, + to_position(region.end, source)?, + )) } -fn to_position(position: NashPosition, source: &Source<'_>) -> Position { +fn to_position(position: NashPosition, source: &Source<'_>) -> Option { let offset = source.offset(position); let prefix = &source.text()[..offset]; - let line = prefix.bytes().filter(|&b| b == b'\n').count() as u32; + let line = prefix.bytes().filter(|&b| b == b'\n').count(); let character = prefix .rsplit('\n') .next() .unwrap_or("") .encode_utf16() - .count() as u32; - Position::new(line, character) + .count(); + protocol_position(line, character) +} + +fn protocol_position(line: usize, character: usize) -> Option { + Some(Position::new( + line.try_into().ok()?, + character.try_into().ok()?, + )) } #[cfg(test)] mod tests { use super::*; use nash_report::{Doc, Label}; - fn region(sr: u16, sc: u16, er: u16, ec: u16) -> Region { + fn region(sr: usize, sc: usize, er: usize, ec: usize) -> Region { Region::new(NashPosition::new(sr, sc), NashPosition::new(er, ec)) } #[test] + fn protocol_positions_do_not_truncate() { + let max = u32::MAX as usize; + assert_eq!( + protocol_position(max, max), + Some(Position::new(u32::MAX, u32::MAX)) + ); + if let Some(too_large) = max.checked_add(1) { + assert_eq!(protocol_position(too_large, 0), None); + assert_eq!(protocol_position(0, too_large), None); + } + } + #[test] fn ranges_count_utf16_surrogate_pairs() { let source = Source::new("a๐Ÿ˜€รฉz\nlast"); assert_eq!( - to_range(region(1, 6, 1, 8), &source), + to_range(region(1, 6, 1, 8), &source).unwrap(), Range::new(Position::new(0, 3), Position::new(0, 4)) ); assert_eq!( - to_range(region(2, 5, 2, 5), &source), + to_range(region(2, 5, 2, 5), &source).unwrap(), Range::new(Position::new(1, 4), Position::new(1, 4)) ); } @@ -99,10 +129,10 @@ mod tests { ); let source = Source::new("x\nx"); let diagnostic = to_lsp(&report, &source, &uri); - assert_eq!(diagnostic.range, to_range(report.region, &source)); + assert_eq!(diagnostic.range, to_range(report.region, &source).unwrap()); assert_eq!( diagnostic.related_information.unwrap()[0].location.range, - to_range(region(1, 1, 1, 2), &source) + to_range(region(1, 1, 1, 2), &source).unwrap() ); assert_eq!(diagnostic.message, "Duplicate names:\n\nRename one."); } diff --git a/crates/nash-parse/src/declaration/infix.rs b/crates/nash-parse/src/declaration/infix.rs index e8c3127c..ba44454d 100644 --- a/crates/nash-parse/src/declaration/infix.rs +++ b/crates/nash-parse/src/declaration/infix.rs @@ -103,7 +103,7 @@ impl<'a> Parser<'a> { /// Parse a precedence digit (0-9). /// /// Mirrors Elm's `Number.precedence`. - fn precedence(&mut self, to_error: impl FnOnce(u16, u16) -> E) -> Result { + fn precedence(&mut self, to_error: impl FnOnce(usize, usize) -> E) -> Result { match self.peek() { Some(b) if b.is_ascii_digit() => { let value = (b - b'0') as u16; diff --git a/crates/nash-parse/src/error.rs b/crates/nash-parse/src/error.rs index de053c9a..9b894170 100644 --- a/crates/nash-parse/src/error.rs +++ b/crates/nash-parse/src/error.rs @@ -59,7 +59,7 @@ pub enum Tests<'a> { Test(&'a Test<'a>, Row, Col), Start(Row, Col), IndentStart(Row, Col), - Alignment(u16, Row, Col), + Alignment(usize, Row, Col), } #[derive(Debug)] @@ -86,7 +86,7 @@ pub enum Test<'a> { IndentBody(Row, Col), IndentBinder(Row, Col), IndentIn(Row, Col), - BinderAlignment(u16, Row, Col), + BinderAlignment(usize, Row, Col), } #[derive(Debug)] @@ -131,7 +131,7 @@ pub enum Impl<'a> { IndentHead(Row, Col), IndentWhere(Row, Col), IndentMethod(Row, Col), - Alignment(u16, Row, Col), + Alignment(usize, Row, Col), } #[derive(Debug)] @@ -152,7 +152,7 @@ pub enum Trait<'a> { IndentMethod(Row, Col), IndentColon(Row, Col), IndentType(Row, Col), - Alignment(u16, Row, Col), + Alignment(usize, Row, Col), } #[derive(Debug)] @@ -273,7 +273,7 @@ pub enum Do<'a> { IndentStmt(Row, Col), IndentArrow(Row, Col), IndentExpr(Row, Col), - Alignment(u16, Row, Col), + Alignment(usize, Row, Col), } #[derive(Debug)] @@ -348,7 +348,7 @@ pub enum Case<'a> { IndentPattern(Row, Col), IndentArrow(Row, Col), IndentBranch(Row, Col), - PatternAlignment(u16, Row, Col), + PatternAlignment(usize, Row, Col), } #[derive(Debug)] @@ -371,7 +371,7 @@ pub enum If<'a> { pub enum Let<'a> { Space(Space, Row, Col), In(Row, Col), - DefAlignment(u16, Row, Col), + DefAlignment(usize, Row, Col), DefName(Row, Col), Def(&'a str, &'a Def<'a>, Row, Col), Destruct(&'a Destruct<'a>, Row, Col), @@ -393,7 +393,7 @@ pub enum Def<'a> { IndentEquals(Row, Col), IndentType(Row, Col), IndentBody(Row, Col), - Alignment(u16, Row, Col), + Alignment(usize, Row, Col), } #[derive(Debug)] @@ -430,7 +430,7 @@ pub enum Pattern<'a> { pub enum Bytes { Endless, OddLength, - BadHexDigit(u16), + BadHexDigit(usize), } #[derive(Debug)] @@ -545,10 +545,10 @@ pub enum StringError { #[derive(Debug)] pub enum Escape { Unknown, - BadUnicodeFormat(u16), - BadUnicodeCode(u16), + BadUnicodeFormat(usize), + BadUnicodeCode(usize), BadUnicodeLength { - code: u16, + code: usize, expected: i32, actual: i32, }, diff --git a/crates/nash-parse/src/expression/do_.rs b/crates/nash-parse/src/expression/do_.rs index 401eb5f2..5e979184 100644 --- a/crates/nash-parse/src/expression/do_.rs +++ b/crates/nash-parse/src/expression/do_.rs @@ -31,7 +31,7 @@ impl<'a> Parser<'a> { } /// Parse aligned statements ending in an expression. - pub(crate) fn do_body(&mut self, parent_indent: u16) -> Result, Do<'a>> { + pub(crate) fn do_body(&mut self, parent_indent: usize) -> Result, Do<'a>> { let (first, mut end) = self.do_stmt()?; let mut statements = vec![first]; diff --git a/crates/nash-parse/src/expression/macro_.rs b/crates/nash-parse/src/expression/macro_.rs index a2da74ad..eafa213f 100644 --- a/crates/nash-parse/src/expression/macro_.rs +++ b/crates/nash-parse/src/expression/macro_.rs @@ -29,7 +29,7 @@ impl<'a> Parser<'a> { let name_start = Position::new( variable.region.end.line, - variable.region.end.column - u16::try_from(name.len()).expect("identifier too long"), + variable.region.end.column - name.len(), ); let name = self.alloc(Located::at( Region::new(name_start, variable.region.end), diff --git a/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__error_overflowing_unicode_escape.snap b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__error_overflowing_unicode_escape.snap new file mode 100644 index 00000000..3fc64608 --- /dev/null +++ b/crates/nash-parse/src/expression/snapshots/nash_parse__expression__string__tests__error_overflowing_unicode_escape.snap @@ -0,0 +1,13 @@ +--- +source: crates/nash-parse/src/expression/string.rs +description: "Code:\n\n\"\\u{FFFFFFFFF}\"" +--- +String( + Escape( + BadUnicodeCode( + 12, + ), + ), + 1, + 3, +) diff --git a/crates/nash-parse/src/expression/string.rs b/crates/nash-parse/src/expression/string.rs index fcda15a4..d718f7b9 100644 --- a/crates/nash-parse/src/expression/string.rs +++ b/crates/nash-parse/src/expression/string.rs @@ -68,6 +68,11 @@ mod tests { assert_expr_snapshot!("\"\"\"รฉ\r\nๆผข๐Ÿ˜€\"\"\""); } + #[test] + fn error_overflowing_unicode_escape() { + assert_expr_error_snapshot!(r#""\u{FFFFFFFFF}""#); + } + #[test] fn error_endless() { assert_expr_error_snapshot!(r#""hello"#); diff --git a/crates/nash-parse/src/expression/variable.rs b/crates/nash-parse/src/expression/variable.rs index 16227e55..c5239733 100644 --- a/crates/nash-parse/src/expression/variable.rs +++ b/crates/nash-parse/src/expression/variable.rs @@ -29,7 +29,7 @@ impl<'a> Parser<'a> { /// Parses `[a-z][a-zA-Z0-9_]*`, checks it's not a reserved word. pub(crate) fn lower_name( &mut self, - to_error: impl FnOnce(u16, u16) -> E, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result<&'a str, E> { let (row, col) = self.position(); let start_pos = self.pos; @@ -56,7 +56,7 @@ impl<'a> Parser<'a> { /// Parse a quoted type variable and return its name without the quote. pub(crate) fn type_var_name( &mut self, - to_error: impl FnOnce(u16, u16) -> E, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result<&'a str, E> { let (row, col) = self.position(); if self.peek() != Some(b'\'') @@ -74,7 +74,7 @@ impl<'a> Parser<'a> { /// Parse an uppercase or lowercase type declaration name. pub(crate) fn type_decl_name( &mut self, - to_error: impl FnOnce(u16, u16) -> E, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result<&'a str, E> { match self.peek() { Some(b) if b.is_ascii_uppercase() => self.upper_name(to_error), @@ -92,7 +92,7 @@ impl<'a> Parser<'a> { /// Parses `[A-Z][a-zA-Z0-9_]*`. No reserved word check for uppercase. pub(crate) fn upper_name( &mut self, - to_error: impl FnOnce(u16, u16) -> E, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result<&'a str, E> { let (row, col) = self.position(); let start_pos = self.pos; @@ -140,7 +140,10 @@ impl<'a> Parser<'a> { /// - `Module.foo` -> VarQual(LowVar, "Module", "foo") /// - `Module.Foo` -> VarQual(CapVar, "Module", "Foo") /// - `A.B.C.foo` -> VarQual(LowVar, "A.B.C", "foo") - fn foreign_alpha(&mut self, to_error: impl FnOnce(u16, u16) -> E) -> Result, E> { + fn foreign_alpha( + &mut self, + to_error: impl FnOnce(usize, usize) -> E, + ) -> Result, E> { let (row, col) = self.position(); let start_pos = self.pos; @@ -229,9 +232,9 @@ impl<'a> Parser<'a> { fn parse_qualified_lower( &mut self, start_pos: usize, - row: u16, - col: u16, - to_error: impl FnOnce(u16, u16) -> E, + row: usize, + col: usize, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result, E> { let module_end = self.pos; self.advance(); // consume dot @@ -259,9 +262,9 @@ impl<'a> Parser<'a> { fn chomp_qualified_upper( &mut self, start_pos: usize, - row: u16, - col: u16, - to_error: impl FnOnce(u16, u16) -> E, + row: usize, + col: usize, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result, E> { loop { if self.is_dot_upper() { diff --git a/crates/nash-parse/src/import.rs b/crates/nash-parse/src/import.rs index 023f66b4..f70c94e7 100644 --- a/crates/nash-parse/src/import.rs +++ b/crates/nash-parse/src/import.rs @@ -141,7 +141,7 @@ impl<'a> Parser<'a> { /// ``` pub(crate) fn module_name( &mut self, - to_error: impl FnOnce(u16, u16) -> E, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result<&'a str, E> { let (row, col) = self.position(); let start_pos = self.pos; diff --git a/crates/nash-parse/src/lib.rs b/crates/nash-parse/src/lib.rs index 3a45e044..1ff25fab 100644 --- a/crates/nash-parse/src/lib.rs +++ b/crates/nash-parse/src/lib.rs @@ -19,14 +19,14 @@ pub(crate) mod test_support; mod tests_block; mod type_; -pub type Row = u16; -pub type Col = u16; +pub type Row = usize; +pub type Col = usize; /// Saved parser state for backtracking. #[derive(Clone, Copy)] struct ParserState { pos: usize, - indent: u16, + indent: usize, row: Row, col: Col, } @@ -46,7 +46,7 @@ pub struct Parser<'a> { /// Current byte position pos: usize, /// Current indentation level (for layout-sensitive parsing) - indent: u16, + indent: usize, /// Current row (1-indexed) row: Row, /// Current column (1-indexed) @@ -114,13 +114,13 @@ impl<'a> Parser<'a> { /// Current indentation level. #[inline] - pub fn indent(&self) -> u16 { + pub fn indent(&self) -> usize { self.indent } /// Set the indentation level. #[inline] - pub fn set_indent(&mut self, indent: u16) { + pub fn set_indent(&mut self, indent: usize) { self.indent = indent; } @@ -159,7 +159,7 @@ impl<'a> Parser<'a> { /// ``` pub fn with_backset_indent( &mut self, - backset: u16, + backset: usize, parser: impl FnOnce(&mut Self) -> Result, ) -> Result { let old_indent = self.indent; @@ -417,7 +417,7 @@ impl<'a> Parser<'a> { /// Peek at a byte at the given offset from current position. #[inline] pub fn peek_at(&self, offset: usize) -> Option { - self.src.get(self.pos + offset).copied() + self.src.get(self.pos.checked_add(offset)?).copied() } /// Get the remaining bytes from current position. @@ -477,6 +477,49 @@ impl<'a> Parser<'a> { #[cfg(test)] mod tests { + #[test] + fn coordinates_cover_large_sources() { + let bump = Bump::new(); + let lines = "\n".repeat(65_536); + let mut parser = Parser::new(&bump, &lines); + parser.advance_by(lines.len()); + assert_eq!(parser.row(), 65_537); + assert_eq!(parser.col(), 1); + + let string = format!("\"{}\"", "x".repeat(65_536)); + let mut parser = Parser::new(&bump, &string); + parser.expression().expect("long string"); + assert!(parser.is_eof()); + assert_eq!(parser.col(), string.len() + 1); + + let indented = format!("{}x", " ".repeat(65_536)); + let mut parser = Parser::new(&bump, &indented); + parser.chomp(|_, _, _| ()).expect("long indentation"); + assert_eq!(parser.col(), 65_537); + parser.expression().expect("indented expression"); + assert!(parser.is_eof()); + + let escaped = format!("\"\\u{{{}}}\"", "F".repeat(65_536)); + let mut parser = Parser::new(&bump, &escaped); + let error::Expr::String( + error::StringError::Escape(error::Escape::BadUnicodeCode(width)), + 1, + 3, + ) = parser.expression().expect_err("oversized Unicode escape") + else { + panic!("expected an invalid Unicode code with its full width"); + }; + assert_eq!(width, 65_539); + } + + #[test] + fn lookahead_offset_cannot_wrap() { + let bump = Bump::new(); + let mut parser = Parser::new(&bump, "xy"); + parser.advance(); + assert_eq!(parser.peek_at(usize::MAX), None); + } + use super::*; #[test] diff --git a/crates/nash-parse/src/pattern/term.rs b/crates/nash-parse/src/pattern/term.rs index 3e87172e..4a5c4115 100644 --- a/crates/nash-parse/src/pattern/term.rs +++ b/crates/nash-parse/src/pattern/term.rs @@ -112,8 +112,8 @@ impl<'a> Parser<'a> { &mut self, start: Position, ctor_start: usize, - row: u16, - col: u16, + row: usize, + col: usize, ) -> Result<&'a Located>, error::Pattern<'a>> { // Keep chomping Module.Module... until we hit the final name loop { diff --git a/crates/nash-parse/src/space.rs b/crates/nash-parse/src/space.rs index 8b9f4309..b7fd7bb9 100644 --- a/crates/nash-parse/src/space.rs +++ b/crates/nash-parse/src/space.rs @@ -90,7 +90,7 @@ impl<'a> Parser<'a> { /// Check that current column equals indent level (for alignment). /// /// Mirrors Elm's `Space.checkAligned`. - pub fn check_aligned(&self, to_error: impl FnOnce(u16, Row, Col) -> E) -> Result<(), E> { + pub fn check_aligned(&self, to_error: impl FnOnce(usize, Row, Col) -> E) -> Result<(), E> { if self.col == self.indent { Ok(()) } else { @@ -263,7 +263,7 @@ impl<'a> Parser<'a> { } /// Helper for eating multi-line comments with nesting. - fn eat_multi_comment_help(&mut self, open_comments: u16) -> SpaceStatus { + fn eat_multi_comment_help(&mut self, open_comments: usize) -> SpaceStatus { loop { match self.peek() { // Newline diff --git a/crates/nash-parse/src/string.rs b/crates/nash-parse/src/string.rs index 0790cbb0..8ee5938a 100644 --- a/crates/nash-parse/src/string.rs +++ b/crates/nash-parse/src/string.rs @@ -310,7 +310,7 @@ impl<'a> Parser<'a> { loop { match self.peek_at(offset) { None => { - return EscapeResult::Problem(Escape::BadUnicodeFormat(offset as u16)); + return EscapeResult::Problem(Escape::BadUnicodeFormat(offset)); } Some(b'}') => { break; @@ -323,25 +323,26 @@ impl<'a> Parser<'a> { } else { (b - b'A' + 10) as u32 }; - code = code * 16 + digit; + // Saturation keeps an oversized escape invalid without overflowing. + code = code.saturating_mul(16).saturating_add(digit); num_digits += 1; offset += 1; } Some(_) => { - return EscapeResult::Problem(Escape::BadUnicodeFormat(offset as u16)); + return EscapeResult::Problem(Escape::BadUnicodeFormat(offset)); } } } // Check code validity if code > 0x10FFFF { - return EscapeResult::Problem(Escape::BadUnicodeCode((offset + 1) as u16)); + return EscapeResult::Problem(Escape::BadUnicodeCode(offset + 1)); } // Check digit count (must be 4-6) if !(4..=6).contains(&num_digits) { return EscapeResult::Problem(Escape::BadUnicodeLength { - code: (offset + 1) as u16, + code: offset + 1, expected: if num_digits < 4 { 4 } else { 6 }, actual: num_digits, }); diff --git a/crates/nash-parse/src/symbol.rs b/crates/nash-parse/src/symbol.rs index 2a5284d5..d2113bbc 100644 --- a/crates/nash-parse/src/symbol.rs +++ b/crates/nash-parse/src/symbol.rs @@ -25,8 +25,8 @@ impl<'a> Parser<'a> { /// - `:` (colon - reserved for type annotations) pub(crate) fn operator( &mut self, - to_expectation: impl FnOnce(u16, u16) -> E, - to_error: impl FnOnce(BadOperator, u16, u16) -> E, + to_expectation: impl FnOnce(usize, usize) -> E, + to_error: impl FnOnce(BadOperator, usize, usize) -> E, ) -> Result<&'a str, E> { let (row, col) = self.position(); let start_pos = self.pos; @@ -63,8 +63,8 @@ impl<'a> Parser<'a> { /// Parse an operator and wrap it in a Located. pub(crate) fn add_location_operator( &mut self, - to_expectation: impl FnOnce(u16, u16) -> E, - to_error: impl FnOnce(BadOperator, u16, u16) -> E, + to_expectation: impl FnOnce(usize, usize) -> E, + to_error: impl FnOnce(BadOperator, usize, usize) -> E, ) -> Result<&'a Located<&'a str>, E> { let start = self.get_position(); let op = self.operator(to_expectation, to_error)?; diff --git a/crates/nash-parse/src/type_.rs b/crates/nash-parse/src/type_.rs index 074d4866..4195d871 100644 --- a/crates/nash-parse/src/type_.rs +++ b/crates/nash-parse/src/type_.rs @@ -584,7 +584,10 @@ impl<'a> Parser<'a> { /// Parse a type name, with an uppercase module path and either type casing. /// /// Mirrors Elm's `Var.foreignUpper`. - fn type_name(&mut self, to_error: impl FnOnce(u16, u16) -> E) -> Result, E> { + fn type_name( + &mut self, + to_error: impl FnOnce(usize, usize) -> E, + ) -> Result, E> { let (row, col) = self.position(); let start_pos = self.pos; @@ -612,7 +615,7 @@ impl<'a> Parser<'a> { fn chomp_qualified_upper_for_type( &mut self, start_pos: usize, - to_error: impl FnOnce(u16, u16) -> E, + to_error: impl FnOnce(usize, usize) -> E, ) -> Result, E> { loop { if self.is_dot_upper() { diff --git a/crates/nash-region/src/lib.rs b/crates/nash-region/src/lib.rs index fdcc0765..3745a07b 100644 --- a/crates/nash-region/src/lib.rs +++ b/crates/nash-region/src/lib.rs @@ -63,14 +63,17 @@ impl Region { } } +/// One-based source coordinates (columns count UTF-8 bytes). +/// A valid source string is at most isize::MAX bytes long, so its coordinates, +/// including one-past-end positions, fit usize without a separate size limit. #[derive(Clone, Debug, Eq, Copy, PartialEq, Hash)] pub struct Position { - pub line: u16, - pub column: u16, + pub line: usize, + pub column: usize, } impl Position { - pub const fn new(line: u16, column: u16) -> Self { + pub const fn new(line: usize, column: usize) -> Self { Self { line, column } } diff --git a/crates/nash-report/src/code.rs b/crates/nash-report/src/code.rs index d3a88c8e..24866acb 100644 --- a/crates/nash-report/src/code.rs +++ b/crates/nash-report/src/code.rs @@ -28,7 +28,7 @@ impl<'s> Source<'s> { /// Byte offset of a 1-based position produced by the parser. pub fn offset(&self, position: Position) -> usize { - let row = usize::from(position.line.saturating_sub(1)); + let row = position.line.saturating_sub(1); let Some(&start) = self.line_starts.get(row) else { return self.text.len(); }; @@ -37,7 +37,7 @@ impl<'s> Source<'s> { .get(row + 1) .map_or(self.text.len(), |next| next - 1); let mut offset = start - .saturating_add(usize::from(position.column.saturating_sub(1))) + .saturating_add(position.column.saturating_sub(1)) .min(end); while !self.text.is_char_boundary(offset) { offset -= 1; @@ -58,10 +58,10 @@ impl<'s> Source<'s> { /// Text of a 1-based row, without its newline. pub fn line(&self, row: Row) -> Option<&'s str> { - let start = *self.line_starts.get(usize::from(row.checked_sub(1)?))?; + let start = *self.line_starts.get(row.checked_sub(1)?)?; let end = self .line_starts - .get(usize::from(row)) + .get(row) .map_or(self.text.len(), |next| next - 1); Some(&self.text[start..end.max(start)]) } @@ -70,7 +70,7 @@ impl<'s> Source<'s> { pub fn what_is_next(&self, row: Row, col: Col) -> Next<'s> { let Some(rest) = self .line(row) - .and_then(|line| line.get(usize::from(col.checked_sub(1)?)..)) + .and_then(|line| line.get(col.checked_sub(1)?..)) else { return Next::Other(None); }; @@ -150,7 +150,7 @@ pub fn to_region(row: Row, col: Col) -> Region { } /// Elm's `toWiderRegion`. -pub fn to_wider_region(row: Row, col: Col, extra: u16) -> Region { +pub fn to_wider_region(row: Row, col: Col, extra: usize) -> Region { Region::new( Position::new(row, col), Position::new(row, col.saturating_add(extra)), @@ -159,7 +159,7 @@ pub fn to_wider_region(row: Row, col: Col, extra: u16) -> Region { /// Elm's `toKeywordRegion`. pub fn to_keyword_region(row: Row, col: Col, keyword: &str) -> Region { - to_wider_region(row, col, keyword.len() as u16) + to_wider_region(row, col, keyword.len()) } #[cfg(test)] diff --git a/crates/nash-report/src/code/snippet.rs b/crates/nash-report/src/code/snippet.rs index c0ce587b..4cdc4d3f 100644 --- a/crates/nash-report/src/code/snippet.rs +++ b/crates/nash-report/src/code/snippet.rs @@ -119,8 +119,8 @@ fn display_line(line: &str) -> String { text } -fn visual_column(line: &str, byte_column: u16) -> usize { - let mut offset = usize::from(byte_column.saturating_sub(1)).min(line.len()); +fn visual_column(line: &str, byte_column: usize) -> usize { + let mut offset = byte_column.saturating_sub(1).min(line.len()); // Match Source::offset when an invalid input points inside a UTF-8 scalar. while !line.is_char_boundary(offset) { offset -= 1; @@ -141,7 +141,7 @@ mod tests { use crate::{Doc, Label, Snippet, Source}; use nash_region::{Position, Region}; - fn region(sr: u16, sc: u16, er: u16, ec: u16) -> Region { + fn region(sr: usize, sc: usize, er: usize, ec: usize) -> Region { Region::new(Position::new(sr, sc), Position::new(er, ec)) } diff --git a/crates/nash-report/src/json.rs b/crates/nash-report/src/json.rs index c959a9f0..ee1bd9a7 100644 --- a/crates/nash-report/src/json.rs +++ b/crates/nash-report/src/json.rs @@ -51,7 +51,7 @@ mod tests { use crate::{Doc, Label, Snippet}; use nash_region::Position; - fn region(sr: u16, sc: u16, er: u16, ec: u16) -> Region { + fn region(sr: usize, sc: usize, er: usize, ec: usize) -> Region { Region::new(Position::new(sr, sc), Position::new(er, ec)) } diff --git a/crates/nash-report/src/render.rs b/crates/nash-report/src/render.rs index 6da5a588..b6c0761b 100644 --- a/crates/nash-report/src/render.rs +++ b/crates/nash-report/src/render.rs @@ -166,7 +166,7 @@ mod tests { use super::*; use crate::{Doc, Label}; use nash_region::{Position, Region}; - fn region(a: u16, b: u16) -> Region { + fn region(a: usize, b: usize) -> Region { Region::new(Position::new(1, a), Position::new(1, b)) } fn snippet() -> Report { diff --git a/crates/nash-report/src/syntax/expr.rs b/crates/nash-report/src/syntax/expr.rs index c0b9c9c8..d24240a7 100644 --- a/crates/nash-report/src/syntax/expr.rs +++ b/crates/nash-report/src/syntax/expr.rs @@ -81,7 +81,7 @@ fn unfinished(title: &str, thing: &str, r: Row, c: Col, sr: Row, sc: Col, hint: sc, ) } -fn width(mut report: Report, amount: u16) -> Report { +fn width(mut report: Report, amount: usize) -> Report { report.region.end.column = report.region.start.column.saturating_add(amount); report.snippet = crate::Snippet::Region { region: report.region, @@ -572,7 +572,7 @@ fn to_record_report( "It looks like you are trying to use `{k}` as a field name, but that is a reserved word. Try using a different name!" ), ), - k.len() as u16, + k.len(), ), sr, sc, @@ -778,7 +778,7 @@ fn to_func_report(source: &Source<'_>, ctx: Context<'_>, e: &Func<'_>, sr: Row, "It looks like you are trying to use `{k}` as an argument, but it is a reserved word in this language. Try using a different argument name!" ), ), - k.len() as u16, + k.len(), ), sr, sc, @@ -821,7 +821,7 @@ fn to_let_report(source: &Source<'_>, ctx: Context<'_>, e: &Let<'_>, sr: Row, sc Let::In(r,c)|Let::DefAlignment(_,r,c)=>return unfinished("LET PROBLEM", "a `let` expression", r,c,sr,sc,"Based on the indentation, I was expecting to see the `in` keyword next. Is there a typo? This can also happen if you are trying to define another value within the `let` but it is not indented enough. Make sure each definition has exactly the same amount of spaces before it. They should line up exactly!"), Let::IndentIn(r,c)=>(r,c,"I was expecting to see the `in` keyword next. Or maybe more of that expression?".into()), Let::DefName(r,c)=>match source.what_is_next(r,c) { - Next::Keyword(k)=>return wide(width(problem("RESERVED WORD",r,c,"I was partway through parsing a `let` expression, but I got stuck here:",&format!("It looks like you are trying to use `{k}` as a variable name, but it is a reserved word! Try using a different name instead.")),k.len() as u16),sr,sc), + Next::Keyword(k)=>return wide(width(problem("RESERVED WORD",r,c,"I was partway through parsing a `let` expression, but I got stuck here:",&format!("It looks like you are trying to use `{k}` as a variable name, but it is a reserved word! Try using a different name instead.")),k.len()),sr,sc), _=>(r,c,"I was expecting the name of a definition next.".to_owned()), }, Let::IndentDef(r,c)=>(r,c,"I was expecting a value to be defined here. It may need more indentation.".into()), @@ -852,9 +852,9 @@ pub(crate) fn to_let_def_report( Def::Arg(e,r,c)=>return pattern::to_pattern_report(source,pattern::PContext::Arg,e,r,c), Def::Body(e,r,c)=>return to_expr_report(source,Context::InDef(name,sr,sc),e,r,c), Def::NameRepeat(r,c)=>(r,c,"EXPECTING DEFINITION",format!("I just saw the type annotation for `{name}` so I was expecting to see its definition here. Type annotations always appear directly above the relevant definition, without anything else in between.")), - Def::NameMatch(actual,r,c)=>return wide(width(problem("NAME MISMATCH",r,c,&format!("I just saw a type annotation for `{name}`, but it is followed by a definition for `{actual}`:"),"These names do not match! Is there a typo?"),actual.len() as u16),sr,sc).with_suggestions(vec![name.to_owned()]), + Def::NameMatch(actual,r,c)=>return wide(width(problem("NAME MISMATCH",r,c,&format!("I just saw a type annotation for `{name}`, but it is followed by a definition for `{actual}`:"),"These names do not match! Is there a typo?"),actual.len()),sr,sc).with_suggestions(vec![name.to_owned()]), Def::Equals(r,c)=>match source.what_is_next(r,c) { - Next::Keyword(k)=>return wide(width(problem("RESERVED WORD",r,c,&format!("The name `{k}` is reserved, so it cannot be used as an argument:"),"Try renaming it to something else."),k.len() as u16),sr,sc), + Next::Keyword(k)=>return wide(width(problem("RESERVED WORD",r,c,&format!("The name `{k}` is reserved, so it cannot be used as an argument:"),"Try renaming it to something else."),k.len()),sr,sc), Next::Operator("->")=>(r,c,"MISSING COLON?","I was not expecting to see an arrow here. Maybe this is a type annotation missing its colon?".into()), _=>(r,c,"PROBLEM IN DEFINITION","I was expecting to see an argument or an equals sign next.".into()), }, diff --git a/crates/nash-report/src/syntax/mod.rs b/crates/nash-report/src/syntax/mod.rs index 40f7c7ea..20eb40ae 100644 --- a/crates/nash-report/src/syntax/mod.rs +++ b/crates/nash-report/src/syntax/mod.rs @@ -28,7 +28,7 @@ pub fn to_report(source: &Source<'_>, error: &Error<'_>) -> Report { suggestions: Vec::new(), }, Error::ModuleNameMismatch { expected, actual, row, col } => Report::snippet( - "MODULE NAME MISMATCH", to_wider_region(*row, *col, actual.len() as u16), None, + "MODULE NAME MISMATCH", to_wider_region(*row, *col, actual.len()), None, Doc::text("It looks like this module name is out of sync:"), Doc::stack([ Doc::reflow(&format!("I need it to match the file path, so I was expecting to see `{expected}` here. Make the following change, and you should be all set!")), diff --git a/crates/nash-report/src/syntax/pattern.rs b/crates/nash-report/src/syntax/pattern.rs index 227f2ef6..eaa1bbe8 100644 --- a/crates/nash-report/src/syntax/pattern.rs +++ b/crates/nash-report/src/syntax/pattern.rs @@ -67,7 +67,7 @@ pub(crate) fn to_pattern_report( .unwrap_or_else(|| "x or age".into()); Report::snippet( "UNEXPECTED NAME", - to_wider_region(r, c, u16::try_from(width).unwrap_or(1)), + to_wider_region(r, c, usize::try_from(width).unwrap_or(1)), None, Doc::reflow("Variable names cannot start with underscores like this:"), Doc::reflow(&format!( diff --git a/crates/nash-source/src/lib.rs b/crates/nash-source/src/lib.rs index 91ba795b..f2bb2229 100644 --- a/crates/nash-source/src/lib.rs +++ b/crates/nash-source/src/lib.rs @@ -446,8 +446,8 @@ pub struct Snippet<'a> { pub data: &'a [u8], // already the relevant slice // offset: usize, // length: usize, - pub off_row: u16, - pub off_col: u16, + pub off_row: usize, + pub off_col: usize, } #[derive(Debug)] diff --git a/docs/overview.md b/docs/overview.md index 3db97343..182b0deb 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -16,6 +16,12 @@ Nash has no compatibility commitments to earlier syntax, APIs, or cache formats. Implement the current design directly and remove superseded paths. Cache files are disposable; do not add version migrations or older-format readers. +Source coordinates and indentation use `usize`, matching source byte offsets. +Valid Rust strings have at most `isize::MAX` bytes, so one-based positions and +EOF fit without a separate parser input-size failure. This makes `Region` +32 bytes on a 64-bit host. Protocol boundaries such as LSP check their narrower +coordinate limits explicitly; they must not truncate positions. + ## Status Done (ported from the Elm compiler, Haskell -> Rust): diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index 8e1d076d..0e45d3ca 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -6,7 +6,8 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen - [x] Remove expression-parser accumulator copies. A successful attempt returns one new argument or operator; only then does the loop change its accumulators. Existing parsing and error snapshots stay unchanged. - [x] Require valid UTF-8 at the parser boundary and remove unchecked conversions. -- [ ] Widen coordinates with a safe input bound; guard mutually recursive parsing with a measured nesting limit. +- [x] Widen coordinates with a safe source bound. +- [ ] Guard mutually recursive parsing with a measured nesting limit and remove flat-sequence recursion. - [ ] Delete unused driver interface-cache machinery and orphaned dependencies. - [ ] Separate canonical module data from local scopes without cloning the whole environment. - [ ] Bound trait selection and evidence lookup using existing map ordering. @@ -22,6 +23,8 @@ Chunk 1 verification passed: formatting, workspace check (all targets/features), Chunk 2 requires `Parser::new` source text to be `&str`; all callers are updated directly, with no byte-input adapter. All seven unchecked UTF-8 conversions are replaced with checked conversions. Added snapshots preserve raw Unicode, mixed Unicode/escapes, and CRLF normalization. A compile-fail doctest rejects arbitrary bytes. Formatting, workspace check, clippy, full tests (including the doctest), and `nash check scratch` passed. Only the three reviewed new Unicode snapshots were added. +Chunk 3a uses usize coordinates and indentation, bounded by the source string allocation (at most isize::MAX bytes). This avoids a fallible constructor and removes coordinate casts; Region grows to 32 bytes on 64-bit hosts. LSP explicitly reports positions beyond its 32-bit range. Source bounds, arbitrary lookahead, and oversized Unicode escape widths are covered by regressions. The Unicode numeric accumulator saturates only as an invalid-code marker, preventing overflow while retaining full diagnostic width. Larger error payloads prompted removal of a trivial header adapter and arena storage of the rare irregular-recursion reference; no lint was suppressed. Formatting, check, clippy, all workspace tests, the positive scratch project, and all 441 parser tests plus doctests in release passed. CLI JSON negative scratch cases report the exact missing-name positions at line 65,539 and column 65,544. + ## Direct inference adoption gates Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. From 9977e607c8b870f448f5251a19cc9bf6d1c043f6 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 15:57:31 -0400 Subject: [PATCH 04/11] fix(parse): bound recursive nesting Signed-off-by: microproofs --- .sampo/changesets/parser-nesting.md | 6 + crates/nash-parse/src/declaration/union.rs | 35 +++-- crates/nash-parse/src/error.rs | 1 + crates/nash-parse/src/expression/accessor.rs | 36 ++--- crates/nash-parse/src/expression/case.rs | 35 ++--- crates/nash-parse/src/expression/if_.rs | 111 ++++++------- crates/nash-parse/src/expression/lambda.rs | 47 +++--- crates/nash-parse/src/expression/let_.rs | 34 ++-- crates/nash-parse/src/expression/mod.rs | 8 + crates/nash-parse/src/lib.rs | 146 +++++++++++++++++- crates/nash-parse/src/pattern/mod.rs | 10 ++ crates/nash-parse/src/space.rs | 6 +- 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 | 8 + docs/overview.md | 6 + plans/frontend-hardening.md | 6 +- 18 files changed, 358 insertions(+), 164 deletions(-) create mode 100644 .sampo/changesets/parser-nesting.md create mode 100644 crates/nash-report/src/syntax/snapshots/nash_report__syntax__variants__variant_excessive_nesting.snap diff --git a/.sampo/changesets/parser-nesting.md b/.sampo/changesets/parser-nesting.md new file mode 100644 index 00000000..672a284c --- /dev/null +++ b/.sampo/changesets/parser-nesting.md @@ -0,0 +1,6 @@ +--- +cargo/nash-parse: patch +cargo/nash-report: patch +--- + +Report excessive expression, pattern, and type nesting before stack exhaustion. Parse flat sequences and nested comments with loops. diff --git a/crates/nash-parse/src/declaration/union.rs b/crates/nash-parse/src/declaration/union.rs index af287e3f..46b98953 100644 --- a/crates/nash-parse/src/declaration/union.rs +++ b/crates/nash-parse/src/declaration/union.rs @@ -213,23 +213,26 @@ impl<'a> Parser<'a> { fn chomp_variants( &mut self, mut variants: Vec<&'a Ctor<'a>>, - end: Position, + mut end: Position, ) -> Result<(Vec<&'a Ctor<'a>>, Position), CustomType<'a>> { - let variants_for_fallback = variants.clone(); - - self.one_of_with_fallback( - vec![Box::new(|p: &mut Parser<'a>| { - p.check_indent(end.line, end.column, CustomType::IndentBar)?; - p.word1(b'|', CustomType::Bar)?; - p.chomp_and_check_indent(CustomType::Space, CustomType::IndentAfterBar)?; - - let (variant, new_end) = p.variant()?; - variants.push(variant); - - p.chomp_variants(variants, new_end) - })], - (variants_for_fallback, end), - ) + loop { + let next = self.one_of_with_fallback( + vec![Box::new(|p: &mut Parser<'a>| { + p.check_indent(end.line, end.column, CustomType::IndentBar)?; + p.word1(b'|', CustomType::Bar)?; + p.chomp_and_check_indent(CustomType::Space, CustomType::IndentAfterBar)?; + p.variant().map(Some) + })], + None, + )?; + match next { + Some((variant, new_end)) => { + variants.push(variant); + end = new_end; + } + None => return Ok((variants, end)), + } + } } } diff --git a/crates/nash-parse/src/error.rs b/crates/nash-parse/src/error.rs index 9b894170..db1c433f 100644 --- a/crates/nash-parse/src/error.rs +++ b/crates/nash-parse/src/error.rs @@ -568,6 +568,7 @@ pub enum Number { #[derive(Debug)] pub enum Space { + TooDeep, HasTab, EndlessMultiComment, } diff --git a/crates/nash-parse/src/expression/accessor.rs b/crates/nash-parse/src/expression/accessor.rs index 7fff668c..d719d367 100644 --- a/crates/nash-parse/src/expression/accessor.rs +++ b/crates/nash-parse/src/expression/accessor.rs @@ -47,26 +47,22 @@ impl<'a> Parser<'a> { start: Position, expr: &'a Located>, ) -> Result<&'a Located>, error::Expr<'a>> { - self.one_of_with_fallback( - vec![Box::new(|p: &mut Parser<'a>| { - p.word1(b'.', error::Expr::Dot)?; - let pos = p.get_position(); - let field = p.lower_name(error::Expr::Access)?; - let end = p.get_position(); - - let located_field = p.alloc(Located::at(Region::new(pos, end), field)); - let access_expr = p.alloc(Located::at( - Region::new(start, end), - Expr::Access { - record: expr, - field: located_field, - }, - )); - - p.accessible(start, access_expr) - })], - expr, - ) + let mut expr = expr; + while self.peek() == Some(b'.') { + self.advance(); + let pos = self.get_position(); + let field = self.lower_name(error::Expr::Access)?; + let end = self.get_position(); + let field = self.alloc(Located::at(Region::new(pos, end), field)); + expr = self.alloc(Located::at( + Region::new(start, end), + Expr::Access { + record: expr, + field, + }, + )); + } + Ok(expr) } } diff --git a/crates/nash-parse/src/expression/case.rs b/crates/nash-parse/src/expression/case.rs index 599d434d..359df834 100644 --- a/crates/nash-parse/src/expression/case.rs +++ b/crates/nash-parse/src/expression/case.rs @@ -125,25 +125,24 @@ impl<'a> Parser<'a> { fn chomp_case_end( &mut self, mut arms: Vec<&'a CaseArm<'a>>, - end: Position, + mut end: Position, ) -> Result<(Vec<&'a CaseArm<'a>>, Position), Case<'a>> { - // Clone for the fallback - let arms_for_fallback = arms.clone(); - - self.one_of_with_fallback( - vec![Box::new(|p: &mut Parser<'a>| { - // Check alignment for next pattern - p.check_aligned(Case::PatternAlignment)?; - - // Parse the next branch - let (arm, new_end) = p.chomp_case_branch()?; - arms.push(arm); - - // Continue parsing more branches - p.chomp_case_end(arms, new_end) - })], - (arms_for_fallback, end), - ) + loop { + let next = self.one_of_with_fallback( + vec![Box::new(|p: &mut Parser<'a>| { + p.check_aligned(Case::PatternAlignment)?; + p.chomp_case_branch().map(Some) + })], + None, + )?; + match next { + Some((arm, new_end)) => { + arms.push(arm); + end = new_end; + } + None => return Ok((arms, end)), + } + } } } diff --git a/crates/nash-parse/src/expression/if_.rs b/crates/nash-parse/src/expression/if_.rs index c7fc475a..8d32b91a 100644 --- a/crates/nash-parse/src/expression/if_.rs +++ b/crates/nash-parse/src/expression/if_.rs @@ -5,7 +5,6 @@ //! Parses: `if cond then branch else branch` //! Also handles: `if c1 then b1 else if c2 then b2 else b3` -use bumpalo::collections::Vec as BumpVec; use nash_region::{Located, Position, Region}; use nash_source::{Expr, IfBranch}; @@ -60,68 +59,54 @@ impl<'a> Parser<'a> { start: Position, mut branches: Vec<&'a IfBranch<'a>>, ) -> Result<(&'a Located>, Position), If<'a>> { - // Parse condition - self.chomp_and_check_indent(If::Space, If::IndentCondition)?; - let (condition, cond_end) = self.if_condition()?; - - // Parse `then` - self.check_indent(cond_end.line, cond_end.column, If::IndentThen)?; - self.keyword_then(If::Then)?; - - // Parse then branch - self.chomp_and_check_indent(If::Space, If::IndentThenBranch)?; - let (then_branch, then_end) = self.if_then_branch()?; - - // Parse `else` - self.check_indent(then_end.line, then_end.column, If::IndentElse)?; - self.keyword_else(If::Else)?; - - // Create the new branch - let branch = self.bump.alloc(IfBranch { - condition, - then_branch, - }); - branches.push(branch); - - // Parse else branch: either `else if ...` or final else expression - self.chomp_and_check_indent(If::Space, If::IndentElseBranch)?; - - // Clone for second closure - let branches_for_else = branches.clone(); - - self.one_of( - If::ElseBranchStart, - vec![ - // `else if ...` - continue the chain - Box::new(|p: &mut Parser<'a>| { - p.keyword_if(If::ElseBranchStart)?; - p.chomp_if_end(start, branches) - }), - // Final else expression - Box::new(|p: &mut Parser<'a>| { - let (else_branch, else_end) = p.if_else_branch()?; - - // Convert branches to bump slice - // Note: Elm reverses because it uses `:` (prepend), we use push (append) - // so our branches are already in correct order - let mut branch_vec: BumpVec<'a, &'a IfBranch<'a>> = BumpVec::new_in(p.bump); - for b in branches_for_else { - branch_vec.push(b); - } - let branches_slice = branch_vec.into_bump_slice(); - - let if_expr = Expr::If { - branches: branches_slice, - final_else: else_branch, - }; - - Ok(( - p.alloc(Located::at(Region::new(start, else_end), if_expr)), - else_end, - )) - }), - ], - ) + loop { + // Parse condition + self.chomp_and_check_indent(If::Space, If::IndentCondition)?; + let (condition, cond_end) = self.if_condition()?; + + // Parse `then` + self.check_indent(cond_end.line, cond_end.column, If::IndentThen)?; + self.keyword_then(If::Then)?; + + // Parse then branch + self.chomp_and_check_indent(If::Space, If::IndentThenBranch)?; + let (then_branch, then_end) = self.if_then_branch()?; + + // Parse `else` + self.check_indent(then_end.line, then_end.column, If::IndentElse)?; + self.keyword_else(If::Else)?; + + // Create the new branch + let branch = self.bump.alloc(IfBranch { + condition, + then_branch, + }); + branches.push(branch); + + // Parse else branch: either `else if ...` or final else expression + self.chomp_and_check_indent(If::Space, If::IndentElseBranch)?; + + let final_else = self.one_of( + If::ElseBranchStart, + vec![ + Box::new(|p: &mut Parser<'a>| { + p.keyword_if(If::ElseBranchStart)?; + Ok(None) + }), + Box::new(|p: &mut Parser<'a>| p.if_else_branch().map(Some)), + ], + )?; + if let Some((else_branch, else_end)) = final_else { + let if_expr = Expr::If { + branches: self.bump.alloc_slice_copy(&branches), + final_else: else_branch, + }; + return Ok(( + self.alloc(Located::at(Region::new(start, else_end), if_expr)), + else_end, + )); + } + } } /// Parse condition expression in an if. diff --git a/crates/nash-parse/src/expression/lambda.rs b/crates/nash-parse/src/expression/lambda.rs index baac75a9..c82887bd 100644 --- a/crates/nash-parse/src/expression/lambda.rs +++ b/crates/nash-parse/src/expression/lambda.rs @@ -91,30 +91,29 @@ impl<'a> Parser<'a> { &mut self, mut args: Vec<&'a Located>>, ) -> Result>>, Func<'a>> { - // Clone for second closure - cheap since it's just a Vec of references - let args_for_arrow = args.clone(); - - // Use one_of to match Elm's error behavior: fallback error is FuncArrow - self.one_of( - Func::Arrow, - vec![ - // Try to parse another pattern arg (Elm tries this first) - Box::new(|p: &mut Parser<'a>| { - let arg = p.specialize( - |bump, e, r, c| Func::Arg(bump.alloc(e), r, c), - |p| p.pattern_term(), - )?; - args.push(arg); - p.chomp_and_check_indent(Func::Space, Func::IndentArrow)?; - p.chomp_lambda_args(args) - }), - // Or parse the arrow to finish - Box::new(|p: &mut Parser<'a>| { - p.word2(b'-', b'>', Func::Arrow)?; - Ok(args_for_arrow) - }), - ], - ) + loop { + let next = self.one_of( + Func::Arrow, + vec![ + Box::new(|p: &mut Parser<'a>| { + let arg = p.specialize( + |bump, e, r, c| Func::Arg(bump.alloc(e), r, c), + |p| p.pattern_term(), + )?; + p.chomp_and_check_indent(Func::Space, Func::IndentArrow)?; + Ok(Some(arg)) + }), + Box::new(|p: &mut Parser<'a>| { + p.word2(b'-', b'>', Func::Arrow)?; + Ok(None) + }), + ], + )?; + match next { + Some(arg) => args.push(arg), + None => return Ok(args), + } + } } } diff --git a/crates/nash-parse/src/expression/let_.rs b/crates/nash-parse/src/expression/let_.rs index 8e0f9801..5cadcdd1 100644 --- a/crates/nash-parse/src/expression/let_.rs +++ b/crates/nash-parse/src/expression/let_.rs @@ -87,24 +87,24 @@ impl<'a> Parser<'a> { pub(crate) fn chomp_let_defs( &mut self, mut defs: Vec<&'a Located>>, - end: Position, + mut end: Position, ) -> Result<(Vec<&'a Located>>, Position), Let<'a>> { - let defs_for_fallback = defs.clone(); - - self.one_of_with_fallback( - vec![Box::new(|p: &mut Parser<'a>| { - // Check alignment for next definition - p.check_aligned(Let::DefAlignment)?; - - // Parse the next definition - let (def, new_end) = p.chomp_let_def()?; - defs.push(def); - - // Continue parsing more definitions - p.chomp_let_defs(defs, new_end) - })], - (defs_for_fallback, end), - ) + loop { + let next = self.one_of_with_fallback( + vec![Box::new(|p: &mut Parser<'a>| { + p.check_aligned(Let::DefAlignment)?; + p.chomp_let_def().map(Some) + })], + None, + )?; + match next { + Some((def, new_end)) => { + defs.push(def); + end = new_end; + } + None => return Ok((defs, end)), + } + } } /// Parse a single let definition (value or destructure). diff --git a/crates/nash-parse/src/expression/mod.rs b/crates/nash-parse/src/expression/mod.rs index 3aeae083..0fe20c95 100644 --- a/crates/nash-parse/src/expression/mod.rs +++ b/crates/nash-parse/src/expression/mod.rs @@ -45,6 +45,10 @@ 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( @@ -314,6 +318,10 @@ 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 1ff25fab..453c983e 100644 --- a/crates/nash-parse/src/lib.rs +++ b/crates/nash-parse/src/lib.rs @@ -51,6 +51,8 @@ pub struct Parser<'a> { row: Row, /// Current column (1-indexed) col: Col, + depth: usize, + depth_error: Option<(Row, Col)>, } impl<'a> Parser<'a> { @@ -73,9 +75,39 @@ 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 // ------------------------------------------------------------------------- @@ -235,7 +267,7 @@ impl<'a> Parser<'a> { Ok(value) => return Ok(value), Err(e) => { // Did we consume any input? - if self.pos != before.pos { + if self.pos != before.pos || self.depth_error.is_some() { // Committed - propagate error return Err(e); } @@ -271,7 +303,7 @@ impl<'a> Parser<'a> { Ok(value) => return Ok(value), Err(e) => { // Did we consume any input? - if self.pos != before.pos { + if self.pos != before.pos || self.depth_error.is_some() { // Committed - propagate error return Err(e); } @@ -477,6 +509,116 @@ 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() + .stack_size(2 * 1024 * 1024) + .spawn(|| { + let expressions = [ + format!("x{}", ".field".repeat(2_000)), + format!("\\{}-> x", "x ".repeat(2_000)), + format!("{}0", "if True then 1 else ".repeat(2_000)), + format!("let\n{}in x", " x = 1\n".repeat(2_000)), + format!("case x of\n{}", " _ -> 1\n".repeat(2_000)), + ]; + for source in expressions { + let source = crate::test_support::indent_fragment(&source); + let bump = Bump::new(); + let mut parser = Parser::new(&bump, &source); + parser.chomp(|_, _, _| ()).unwrap(); + parser.expression().unwrap(); + assert!(parser.is_eof()); + } + let bump = Bump::new(); + let source = format!("type Many = A{}", " | A".repeat(2_000)); + let mut parser = Parser::new(&bump, &source); + parser.declaration().unwrap(); + assert!(parser.is_eof()); + let source = format!("{}{}", "{-".repeat(65_536), "-}".repeat(65_536)); + let mut parser = Parser::new(&bump, &source); + parser.chomp(|_, _, _| ()).unwrap(); + assert!(parser.is_eof()); + }) + .unwrap() + .join() + .unwrap(); + } + #[test] fn coordinates_cover_large_sources() { let bump = Bump::new(); diff --git a/crates/nash-parse/src/pattern/mod.rs b/crates/nash-parse/src/pattern/mod.rs index 34fd8e49..270a5de7 100644 --- a/crates/nash-parse/src/pattern/mod.rs +++ b/crates/nash-parse/src/pattern/mod.rs @@ -33,6 +33,10 @@ 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( @@ -61,6 +65,12 @@ 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/space.rs b/crates/nash-parse/src/space.rs index b7fd7bb9..48c7e7d1 100644 --- a/crates/nash-parse/src/space.rs +++ b/crates/nash-parse/src/space.rs @@ -263,7 +263,7 @@ impl<'a> Parser<'a> { } /// Helper for eating multi-line comments with nesting. - fn eat_multi_comment_help(&mut self, open_comments: usize) -> SpaceStatus { + fn eat_multi_comment_help(&mut self, mut open_comments: usize) -> SpaceStatus { loop { match self.peek() { // Newline @@ -284,7 +284,7 @@ impl<'a> Parser<'a> { if open_comments == 1 { return SpaceStatus::Good; } else { - return self.eat_multi_comment_help(open_comments - 1); + open_comments -= 1; } } else { self.advance(); @@ -296,7 +296,7 @@ impl<'a> Parser<'a> { if self.peek_at(1) == Some(0x2D) { self.advance(); self.advance(); - return self.eat_multi_comment_help(open_comments + 1); + open_comments += 1; } else { self.advance(); } diff --git a/crates/nash-parse/src/type_.rs b/crates/nash-parse/src/type_.rs index 4195d871..9543d02e 100644 --- a/crates/nash-parse/src/type_.rs +++ b/crates/nash-parse/src/type_.rs @@ -42,6 +42,10 @@ 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 @@ -243,6 +247,10 @@ 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 20eb40ae..ab59152b 100644 --- a/crates/nash-report/src/syntax/mod.rs +++ b/crates/nash-report/src/syntax/mod.rs @@ -62,6 +62,13 @@ pub(crate) fn wide(mut report: Report, row: Row, col: Col) -> Report { pub(crate) fn to_space_report(_source: &Source<'_>, space: &Space, row: Row, col: Col) -> Report { match space { + Space::TooDeep => problem( + "EXCESSIVE NESTING", + row, + col, + "This expression, pattern, or type is nested too deeply.", + "Split it into smaller definitions or simplify its nesting.", + ), 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 new file mode 100644 index 00000000..ba4de2ff --- /dev/null +++ b/crates/nash-report/src/syntax/snapshots/nash_report__syntax__variants__variant_excessive_nesting.snap @@ -0,0 +1,12 @@ +--- +source: crates/nash-report/src/syntax/variants.rs +expression: "render_plain(&report, &source, \"src/Main.nash\")" +--- +EXCESSIVE NESTING + + ร— This expression, pattern, or type is nested too deeply. + โ•ญโ”€[src/Main.nash:1:7] + 1 โ”‚ f = (((1))) + ยท โ”€ + โ•ฐโ”€โ”€โ”€โ”€ + help: Split it into smaller definitions or simplify its nesting. diff --git a/crates/nash-report/src/syntax/variants.rs b/crates/nash-report/src/syntax/variants.rs index 8ee42ed9..1c6ae567 100644 --- a/crates/nash-report/src/syntax/variants.rs +++ b/crates/nash-report/src/syntax/variants.rs @@ -3,6 +3,14 @@ use super::*; use crate::render_plain; use nash_parse::error::*; +#[test] +fn variant_excessive_nesting() { + let source = Source::new("f = (((1)))"); + let error = Module::Space(Space::TooDeep, 1, 7); + let report = module::to_parse_error_report(&source, &error); + insta::assert_snapshot!(render_plain(&report, &source, "src/Main.nash")); +} + #[test] fn variant_module_space() { let source = Source::new("f = value"); diff --git a/docs/overview.md b/docs/overview.md index 182b0deb..64961abc 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -22,6 +22,12 @@ EOF fit without a separate parser input-size failure. This makes `Region` 32 bytes on a 64-bit host. Protocol boundaries such as LSP check their narrower coordinate limits explicitly; they must not truncate positions. +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. + ## Status Done (ported from the Elm compiler, Haskell -> Rust): diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index 0e45d3ca..765104d4 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -7,7 +7,7 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen - [x] Remove expression-parser accumulator copies. A successful attempt returns one new argument or operator; only then does the loop change its accumulators. Existing parsing and error snapshots stay unchanged. - [x] Require valid UTF-8 at the parser boundary and remove unchecked conversions. - [x] Widen coordinates with a safe source bound. -- [ ] Guard mutually recursive parsing with a measured nesting limit and remove flat-sequence recursion. +- [x] Guard mutually recursive parsing with a measured nesting limit and remove flat-sequence recursion. - [ ] Delete unused driver interface-cache machinery and orphaned dependencies. - [ ] Separate canonical module data from local scopes without cloning the whole environment. - [ ] Bound trait selection and evidence lookup using existing map ordering. @@ -25,6 +25,10 @@ Chunk 2 requires `Parser::new` source text to be `&str`; all callers are updated Chunk 3a uses usize coordinates and indentation, bounded by the source string allocation (at most isize::MAX bytes). This avoids a fallible constructor and removes coordinate casts; Region grows to 32 bytes on 64-bit hosts. LSP explicitly reports positions beyond its 32-bit range. Source bounds, arbitrary lookahead, and oversized Unicode escape widths are covered by regressions. The Unicode numeric accumulator saturates only as an invalid-code marker, preventing overflow while retaining full diagnostic width. Larger error payloads prompted removal of a trivial header adapter and arena storage of the rare irregular-recursion reference; no lint was suppressed. Formatting, check, clippy, all workspace tests, the positive scratch project, and all 441 parser tests plus doctests in release passed. CLI JSON negative scratch cases report the exact missing-name positions at line 65,539 and column 65,544. +Chunk 3b bounds combined recursive expression, pattern, and type entries at 64. Exhaustion remains committed across backtracking and the counter resets after returning. The original 512-parenthesis input aborted on a 2 MiB stack; guarded parsing reports excessive nesting. Fixed-stack regressions cover ordinary and negative parentheses, lists, lambdas, constructor patterns, type parentheses, and arrows. Flat access chains, lambda arguments, else-if branches, let definitions, case arms, and union variants now iterate without accumulator copies; 2,000-element cases and 65,536 nested comments use bounded stack. + +Chunk 3b verification passed: formatting, workspace check, clippy, full tests, all 444 parser tests plus doctests in release, positive scratch compilation, and a CLI JSON negative scratch case reporting EXCESSIVE NESTING. Existing snapshots are unchanged; the new nesting report was reviewed. + ## Direct inference adoption gates Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. From 7dab8416345ca7bd57292d82c4d358c9073457b2 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 15:59:22 -0400 Subject: [PATCH 05/11] refactor(driver): remove inactive interface cache Signed-off-by: microproofs --- .sampo/changesets/remove-interface-cache.md | 5 + Cargo.lock | 11 -- Cargo.toml | 1 - crates/nash-driver/Cargo.toml | 2 - crates/nash-driver/src/error.rs | 3 - crates/nash-driver/src/interface.rs | 198 ++------------------ crates/nash-driver/src/lib.rs | 2 +- docs/overview.md | 3 + plans/frontend-hardening.md | 6 +- 9 files changed, 28 insertions(+), 203 deletions(-) create mode 100644 .sampo/changesets/remove-interface-cache.md diff --git a/.sampo/changesets/remove-interface-cache.md b/.sampo/changesets/remove-interface-cache.md new file mode 100644 index 00000000..d69d9a05 --- /dev/null +++ b/.sampo/changesets/remove-interface-cache.md @@ -0,0 +1,5 @@ +--- +cargo/nash-driver: minor +--- + +Remove unused disk interface-cache APIs, serialization, and cache metadata. Preserve in-memory exports, kind contracts, and fingerprints returned by compilation. diff --git a/Cargo.lock b/Cargo.lock index 24171819..e724ba38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,15 +240,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bitcoin-consensus-encoding" version = "1.2.0" @@ -1962,7 +1953,6 @@ name = "nash-driver" version = "0.5.0" dependencies = [ "async-trait", - "bincode", "bumpalo", "glob", "indoc", @@ -1978,7 +1968,6 @@ dependencies = [ "nash-report", "nash-solve", "nash-source", - "serde", "thiserror 2.0.20", "tokio", "url", diff --git a/Cargo.toml b/Cargo.toml index d5eadb56..eaa5b778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ license = "Apache-2.0" [workspace.dependencies] async-trait = "0.1" -bincode = "1" bumpalo = { version = "3.19.1", features = ["collections"] } clap = { version = "4.5.60", features = ["derive"] } color-print = "0.3.7" diff --git a/crates/nash-driver/Cargo.toml b/crates/nash-driver/Cargo.toml index 3980aa07..722aab31 100644 --- a/crates/nash-driver/Cargo.toml +++ b/crates/nash-driver/Cargo.toml @@ -9,11 +9,9 @@ license.workspace = true [dependencies] async-trait.workspace = true -bincode.workspace = true bumpalo.workspace = true glob.workspace = true miette.workspace = true -serde.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["sync", "fs"] } url.workspace = true diff --git a/crates/nash-driver/src/error.rs b/crates/nash-driver/src/error.rs index 42ff1fdc..8dfd8670 100644 --- a/crates/nash-driver/src/error.rs +++ b/crates/nash-driver/src/error.rs @@ -49,9 +49,6 @@ pub enum DriverError { #[error("module not found: {module}")] ModuleNotFound { module: String }, - #[error("failed to serialize interface: {0}")] - SerializeError(#[from] bincode::Error), - #[error("invalid module path: {path}")] InvalidModulePath { path: PathBuf }, } diff --git a/crates/nash-driver/src/interface.rs b/crates/nash-driver/src/interface.rs index d657888c..7354c4a2 100644 --- a/crates/nash-driver/src/interface.rs +++ b/crates/nash-driver/src/interface.rs @@ -1,22 +1,13 @@ -//! Interface file serialization for incremental compilation. -//! -//! Interfaces capture the public API of a module, allowing downstream -//! modules to be skipped during recompilation if their dependencies' -//! interfaces haven't changed. +//! In-memory summaries of public exports and module contracts. -use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use crate::error::DriverError; /// Module interface for incremental compilation. /// /// Contains the public exports of a module and a fingerprint /// for change detection. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct Interface { /// Module name (e.g., "Json.Decode"). pub module_name: String, @@ -29,7 +20,7 @@ pub struct Interface { } /// An exported item from a module. -#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum Export { /// A value export (function or constant). Value { @@ -102,33 +93,6 @@ impl Interface { result } - /// Load an interface from a file. - pub fn load(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|source| DriverError::ReadError { - path: path.to_path_buf(), - source, - })?; - - bincode::deserialize(&bytes).map_err(DriverError::SerializeError) - } - - /// Save the interface to a file. - pub fn save(&self, path: &Path) -> Result<(), DriverError> { - // Create parent directories if needed - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|source| DriverError::WriteError { - path: parent.to_path_buf(), - source, - })?; - } - - let bytes = bincode::serialize(self)?; - std::fs::write(path, bytes).map_err(|source| DriverError::WriteError { - path: path.to_path_buf(), - source, - }) - } - /// Check if this interface differs from another. pub fn differs_from(&self, other: &Interface) -> bool { self.fingerprint != other.fingerprint @@ -142,99 +106,23 @@ fn compute_fingerprint(exports: &[Export]) -> u64 { hasher.finish() } -/// Metadata about a compiled module for caching decisions. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModuleMeta { - /// Source file modification time. - pub source_time: SystemTime, - - /// Build ID when this module was last compiled. - pub last_compile: u64, - - /// Hash of the generated interface. - pub interface_hash: u64, -} - -impl ModuleMeta { - /// Create metadata for a newly compiled module. - pub fn new(source_time: SystemTime, build_id: u64, interface_hash: u64) -> Self { - ModuleMeta { - source_time, - last_compile: build_id, - interface_hash, - } +fn export_name(export: &Export) -> &str { + match export { + Export::Value { name } | Export::Type { name, .. } => name, } } -/// Cache directory manager for interface files. -pub struct InterfaceCache { - /// Root directory for cached interfaces (e.g., `.nash/interfaces/`). - cache_dir: PathBuf, - - /// Current build ID (incremented each build). - build_id: u64, -} - -impl InterfaceCache { - /// Create a new interface cache in the given directory. - pub fn new(project_root: &Path) -> Self { - let cache_dir = project_root.join(".nash").join("interfaces"); - InterfaceCache { - cache_dir, - build_id: 0, - } - } - - /// Start a new build, incrementing the build ID. - pub fn start_build(&mut self) -> u64 { - self.build_id += 1; - self.build_id - } - - /// Get the cache path for a module. - pub fn cache_path(&self, module_name: &str) -> PathBuf { - // Convert module name to path: "Json.Decode" -> "Json/Decode.nashi" - let relative = module_name.replace('.', "/"); - self.cache_dir.join(format!("{}.nashi", relative)) - } - - /// Load a cached interface for a module. - pub fn load(&self, module_name: &str) -> Option { - let path = self.cache_path(module_name); - Interface::load(&path).ok() - } - - /// Save an interface to the cache. - pub fn save(&self, interface: &Interface) -> Result<(), DriverError> { - let path = self.cache_path(&interface.module_name); - interface.save(&path) - } - - /// Check if a module needs to be rebuilt. - /// - /// A module needs rebuilding if: - /// - Source file changed (mtime is newer) - /// - Any dependency's interface changed since last compile - pub fn needs_rebuild( - &self, - meta: &ModuleMeta, - current_source_time: SystemTime, - dep_metas: &[&ModuleMeta], - ) -> bool { - // Source file changed? - if current_source_time > meta.source_time { - return true; - } - - // Any dependency interface changed after our last compile? - for dep in dep_metas { - if dep.last_compile > meta.last_compile { - return true; +fn render_kind(kind: &nash_ast::Kind<'_>) -> String { + fn render(kind: &nash_ast::Kind<'_>, argument: bool) -> String { + match kind { + nash_ast::Kind::Type => "Type".into(), + nash_ast::Kind::Arrow(from, to) => { + let text = format!("{} -> {}", render(from, true), render(to, false)); + if argument { format!("({text})") } else { text } } } - - false } + render(kind, false) } #[cfg(test)] @@ -270,62 +158,4 @@ mod tests { assert!(!iface1.differs_from(&iface2)); } - - #[test] - fn test_cache_path() { - let cache = InterfaceCache::new(Path::new("/project")); - - assert_eq!( - cache.cache_path("Main"), - PathBuf::from("/project/.nash/interfaces/Main.nashi") - ); - - assert_eq!( - cache.cache_path("Json.Decode"), - PathBuf::from("/project/.nash/interfaces/Json/Decode.nashi") - ); - } -} - -#[cfg(test)] -mod kind_tests { - use super::*; - - #[test] - fn kind_interfaces_round_trip() { - let root = std::env::temp_dir().join(format!("nash-kind-interface-{}", std::process::id())); - let cache = InterfaceCache::new(&root); - let original = Interface::new( - "Kinds".into(), - vec![Export::Type { - name: "list".into(), - constructors_exposed: false, - kind: "Type -> Type".into(), - }], - ); - cache.save(&original).unwrap(); - let loaded = cache.load("Kinds").expect("saved interface loads"); - assert_eq!(loaded.fingerprint, original.fingerprint); - assert_eq!(loaded.exports, original.exports); - std::fs::remove_dir_all(root).unwrap(); - } -} - -fn export_name(export: &Export) -> &str { - match export { - Export::Value { name } | Export::Type { name, .. } => name, - } -} - -fn render_kind(kind: &nash_ast::Kind<'_>) -> String { - fn render(kind: &nash_ast::Kind<'_>, argument: bool) -> String { - match kind { - nash_ast::Kind::Type => "Type".into(), - nash_ast::Kind::Arrow(from, to) => { - let text = format!("{} -> {}", render(from, true), render(to, false)); - if argument { format!("({text})") } else { text } - } - } - } - render(kind, false) } diff --git a/crates/nash-driver/src/lib.rs b/crates/nash-driver/src/lib.rs index 739a5047..4b2517ae 100644 --- a/crates/nash-driver/src/lib.rs +++ b/crates/nash-driver/src/lib.rs @@ -54,6 +54,6 @@ pub use compile::{BuildResult, ModuleResult, build, build_graph}; pub use database::Database; pub use error::DriverError; pub use graph::DepGraph; -pub use interface::{Export, Interface, InterfaceCache, ModuleMeta}; +pub use interface::{Export, Interface}; pub use project::{ModuleOrigins, Project, ProjectMember}; pub use source::{FileSource, FileSystemSource, InMemorySource, OverlaySource}; diff --git a/docs/overview.md b/docs/overview.md index 64961abc..24d77ddc 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -22,6 +22,9 @@ EOF fit without a separate parser input-size failure. This makes `Region` 32 bytes on a 64-bit host. Protocol boundaries such as LSP check their narrower coordinate limits explicitly; they must not truncate positions. +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 diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index 765104d4..594bafa3 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -8,7 +8,7 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen - [x] Require valid UTF-8 at the parser boundary and remove unchecked conversions. - [x] Widen coordinates with a safe source bound. - [x] Guard mutually recursive parsing with a measured nesting limit and remove flat-sequence recursion. -- [ ] Delete unused driver interface-cache machinery and orphaned dependencies. +- [x] Delete unused driver interface-cache machinery and orphaned dependencies. - [ ] Separate canonical module data from local scopes without cloning the whole environment. - [ ] Bound trait selection and evidence lookup using existing map ordering. - [ ] Replace the constraint tree and intermediate inference Type with direct AST inference, subject to the adoption gates below. @@ -29,6 +29,10 @@ Chunk 3b bounds combined recursive expression, pattern, and type entries at 64. Chunk 3b verification passed: formatting, workspace check, clippy, full tests, all 444 parser tests plus doctests in release, positive scratch compilation, and a CLI JSON negative scratch case reporting EXCESSIVE NESTING. Existing snapshots are unchanged; the new nesting report was reviewed. +Chunk 4 removes InterfaceCache, ModuleMeta, interface load/save, the cache-only serialization error, serde derives, and two dedicated cache tests. No active build caller used these APIs. In-memory Interface/Export and fingerprint tests remain. The driver no longer depends on serde or bincode; bincode is removed from workspace dependencies and the lockfile. + +Chunk 4 verification passed: formatting, workspace check, clippy, full tests including interface-contract regressions, and positive scratch compilation. No snapshots changed and the lockfile removes only bincode and the two driver dependency edges. + ## Direct inference adoption gates Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. From d06f2c5b69164d527d1e0bc4eefe6dcc7117867c Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 16:02:01 -0400 Subject: [PATCH 06/11] refactor(can): borrow local scopes Signed-off-by: microproofs --- .sampo/changesets/borrow-local-scopes.md | 5 + crates/nash-can/src/environment.rs | 134 ++++++---- crates/nash-can/src/expression.rs | 197 ++++++++------- crates/nash-can/src/module.rs | 44 +++- ...ope_recovery_after_rejected_shadowing.snap | 51 ++++ ..._scope_recovery_between_case_branches.snap | 50 ++++ ...s__scope_siblings_reuse_binding_names.snap | 230 ++++++++++++++++++ docs/overview.md | 6 + plans/frontend-hardening.md | 6 +- 9 files changed, 586 insertions(+), 137 deletions(-) create mode 100644 .sampo/changesets/borrow-local-scopes.md create mode 100644 crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_after_rejected_shadowing.snap create mode 100644 crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_between_case_branches.snap create mode 100644 crates/nash-can/src/snapshots/nash_can__module__tests__scope_siblings_reuse_binding_names.snap diff --git a/.sampo/changesets/borrow-local-scopes.md b/.sampo/changesets/borrow-local-scopes.md new file mode 100644 index 00000000..e83595b3 --- /dev/null +++ b/.sampo/changesets/borrow-local-scopes.md @@ -0,0 +1,5 @@ +--- +cargo/nash-can: minor +--- + +Borrow module data and local binding maps during canonicalization instead of cloning the full environment at each scope. Preserve shadowing, diagnostics, and error recovery. diff --git a/crates/nash-can/src/environment.rs b/crates/nash-can/src/environment.rs index 5249410f..09a3cd0f 100644 --- a/crates/nash-can/src/environment.rs +++ b/crates/nash-can/src/environment.rs @@ -123,7 +123,6 @@ pub enum Var<'a> { annotation: &'a nash_ast::Annotation<'a>, local_region: Option, }, - Local(Region), TopLevel(Region), /// Imported from another module, like Elm's `Foreign home annotation`. /// The annotation comes from the defining module's (post-solve) @@ -259,11 +258,8 @@ pub struct Binop<'a> { pub precedence: Precedence, } -/// The canonicalization environment. -/// -/// Built from imports (foreign) then augmented with local definitions. -/// Consumed by type, pattern, and expression canonicalization. -#[derive(Clone)] +/// Module information for canonicalization, built from imports and top-level +/// definitions. Local expression bindings live in Scope and never mutate it. pub struct Env<'a> { pub kinds: crate::kinds::KindEnv<'a>, pub traits: Exposed<'a, &'a TraitInfo<'a>>, @@ -279,6 +275,86 @@ pub struct Env<'a> { pub q_ctors: Qualified<'a, Ctor<'a>>, } +/// Local bindings borrow module information and their parent scope. Scope exit, +/// including an early error return, cannot change a sibling or ancestor. +pub struct Scope<'scope, 'a> { + pub module: &'scope Env<'a>, + parent: Option<&'scope Scope<'scope, 'a>>, + bindings: &'scope BTreeMap<&'a str, Region>, +} + +impl<'scope, 'a> Scope<'scope, 'a> { + pub fn new( + module: &'scope Env<'a>, + parent: Option<&'scope Scope<'scope, 'a>>, + bindings: &'scope BTreeMap<&'a str, Region>, + ) -> Result>> { + let mut errors = Vec::new(); + for (&name, ®ion) in bindings { + let original = parent.and_then(|scope| scope.local(name)).or_else(|| { + match module.vars.get(name) { + Some(Var::TopLevel(original)) + | Some(Var::Method { + local_region: Some(original), + .. + }) => Some(*original), + _ => None, + } + }); + if let Some(original) = original { + errors.push(Error::Shadowing { + name, + original, + new: region, + }); + } + } + if errors.is_empty() { + Ok(Self { + module, + parent, + bindings, + }) + } else { + Err(errors) + } + } + + pub fn add_locals<'child>( + &'child self, + bindings: &'child BTreeMap<&'a str, Region>, + ) -> Result, Vec>> { + Scope::new(self.module, Some(self), bindings) + } + + pub fn local(&self, name: &str) -> Option { + let mut scope = Some(self); + while let Some(current) = scope { + if let Some(region) = current.bindings.get(name) { + return Some(*region); + } + scope = current.parent; + } + None + } + + pub fn possible_var_names(&self, bump: &'a Bump) -> crate::error::PossibleNames<'a> { + let mut names: std::collections::BTreeSet<_> = self.module.vars.keys().copied().collect(); + let mut scope = Some(self); + while let Some(current) = scope { + names.extend(current.bindings.keys().copied()); + scope = current.parent; + } + let locals = bump.alloc_slice_fill_iter(names); + let qualified = + bump.alloc_slice_fill_iter(self.module.q_vars.iter().map(|(prefix, inner)| { + let names = bump.alloc_slice_fill_iter(inner.keys().copied()); + (*prefix, names as &[&str]) + })); + crate::error::PossibleNames { locals, qualified } + } +} + impl<'a> Env<'a> { /// Compiler-generated calls use trait identity, independent of value names /// and import aliases in the source module. @@ -408,43 +484,6 @@ impl<'a> Env<'a> { } } - /// Extend env with local bindings (clone-on-scope-extension). - /// Shadows foreign imports silently. - /// Errors on re-shadowing a local/top-level. - pub fn add_locals( - &self, - bindings: &std::collections::BTreeMap<&'a str, Region>, - ) -> Result, Vec>> { - let mut new_env = self.clone(); - let mut errors = Vec::new(); - - for (&name, ®ion) in bindings { - match new_env.vars.get(name) { - Some(Var::Local(original)) - | Some(Var::TopLevel(original)) - | Some(Var::Method { - local_region: Some(original), - .. - }) => { - errors.push(Error::Shadowing { - name, - original: *original, - new: region, - }); - } - _ => { - new_env.vars.insert(name, Var::Local(region)); - } - } - } - - if errors.is_empty() { - Ok(new_env) - } else { - Err(errors) - } - } - /// Look up a binop by symbol. Mirrors Elm's `Env.findBinop`. pub fn find_binop( &self, @@ -472,15 +511,6 @@ impl<'a> Env<'a> { bump.alloc_slice_fill_iter(self.binops.keys().copied()) } - pub fn possible_var_names(&self, bump: &'a Bump) -> crate::error::PossibleNames<'a> { - let locals = bump.alloc_slice_fill_iter(self.vars.keys().copied()); - let qualified = bump.alloc_slice_fill_iter(self.q_vars.iter().map(|(prefix, inner)| { - let names = bump.alloc_slice_fill_iter(inner.keys().copied()); - (*prefix, names as &[&str]) - })); - crate::error::PossibleNames { locals, qualified } - } - pub fn possible_type_names(&self, bump: &'a Bump) -> crate::error::PossibleNames<'a> { let locals = bump.alloc_slice_fill_iter(self.types.keys().copied()); let qualified = bump.alloc_slice_fill_iter(self.q_types.iter().map(|(prefix, inner)| { diff --git a/crates/nash-can/src/expression.rs b/crates/nash-can/src/expression.rs index 790a9298..5559839e 100644 --- a/crates/nash-can/src/expression.rs +++ b/crates/nash-can/src/expression.rs @@ -14,7 +14,7 @@ use nash_source::{ }; use crate::Error; -use crate::environment::{self, Ctor as EnvCtor, Env, Info, Var}; +use crate::environment::{self, Ctor as EnvCtor, Env, Info, Scope, Var}; use crate::error::DuplicatePatternContext; use crate::pattern::{self, Bindings}; use crate::scc; @@ -88,7 +88,7 @@ pub fn verify_bindings<'a>( pub fn canonicalize_expr<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, expr: &'a Located>, free_locals: &mut FreeLocals<'a>, warnings: &mut Vec>, @@ -149,7 +149,7 @@ pub fn canonicalize_expr<'a>( kind: VarType::CapVar, name, } => { - let ctor = env.find_ctor(bump, region, name)?; + let ctor = env.module.find_ctor(bump, region, name)?; to_var_ctor(bump, env, name, &ctor)? } @@ -164,7 +164,7 @@ pub fn canonicalize_expr<'a>( module, name, } => { - let ctor = env.find_ctor_qual(bump, region, module, name)?; + let ctor = env.module.find_ctor_qual(bump, region, module, name)?; to_var_ctor(bump, env, name, &ctor)? } @@ -173,7 +173,7 @@ pub fn canonicalize_expr<'a>( } SourceExpr::Op(symbol) => { - let binop = env.find_binop(bump, region, symbol)?; + let binop = env.module.find_binop(bump, region, symbol)?; CanExpr::VarOperator { symbol, operator_home: binop.home, @@ -185,6 +185,7 @@ pub fn canonicalize_expr<'a>( SourceExpr::Negate(inner) => { let trait_ = nash_ast::primitives::num_trait(); let annotation = env + .module .method_annotation(trait_, "negate") .ok_or_else(|| vec![Error::NegateWithoutNum { region }])?; let function = bump.alloc(Located::at( @@ -226,9 +227,10 @@ pub fn canonicalize_expr<'a>( grouped: false, } = &argument.value { - env.ctors + env.module + .ctors .values() - .chain(env.q_ctors.values().flat_map(|ctors| ctors.values())) + .chain(env.module.q_ctors.values().flat_map(|ctors| ctors.values())) .find_map(|info| { let Info::Specific( _, @@ -352,7 +354,7 @@ enum SectionSide<'a> { fn canonicalize_section<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, side: SectionSide<'a>, operator: &'a str, region: Region, @@ -361,7 +363,7 @@ fn canonicalize_section<'a>( ) -> Result<&'a Located>, Vec>> { let mut generated = "$section"; let mut suffix = 0; - while env.vars.contains_key(generated) { + while env.local(generated).is_some() || env.module.vars.contains_key(generated) { suffix += 1; generated = bump.alloc_str(&format!("$section{suffix}")); } @@ -400,7 +402,7 @@ fn canonicalize_section<'a>( #[allow(clippy::too_many_arguments)] fn canonicalize_do<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, stmts: &'a [&'a Located>], last: &'a Located>, region: Region, @@ -430,11 +432,14 @@ fn canonicalize_do<'a>( ), }; let trait_ = nash_ast::primitives::monad_trait(); - let annotation = env.method_annotation(trait_, "bind").ok_or_else(|| { - vec![Error::DoWithoutMonad { - region: statement.region, - }] - })?; + let annotation = env + .module + .method_annotation(trait_, "bind") + .ok_or_else(|| { + vec![Error::DoWithoutMonad { + region: statement.region, + }] + })?; // The RHS cannot see its own pattern. The lambda canonicalizer adds the // pattern only for the remaining statements and accounts for delayed uses. let value = canonicalize_expr(bump, env, expression, free_locals, warnings)?; @@ -486,12 +491,16 @@ fn irrefutable(pattern: &SourcePattern<'_>) -> bool { fn find_var<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, region: Region, name: &'a str, free_locals: &mut FreeLocals<'a>, ) -> Result, Vec>> { - match env.vars.get(name) { + if env.local(name).is_some() { + log_var(free_locals, name); + return Ok(CanExpr::VarLocal(name)); + } + match env.module.vars.get(name) { Some(Var::Method { trait_, annotation, .. }) => Ok(CanExpr::VarMethod { @@ -499,14 +508,10 @@ fn find_var<'a>( method: name, annotation, }), - Some(Var::Local(_)) => { - log_var(free_locals, name); - Ok(CanExpr::VarLocal(name)) - } Some(Var::TopLevel(_)) => { log_var(free_locals, name); Ok(CanExpr::VarTopLevel(QualifiedName { - home: env.home, + home: env.module.home, name, })) } @@ -521,8 +526,8 @@ fn find_var<'a>( first_module: *first, other_modules: bump.alloc_slice_fill_iter(others.iter().copied()), }]), - None if env.ctors.contains_key(name) => { - let ctor = env.find_ctor(bump, region, name)?; + None if env.module.ctors.contains_key(name) => { + let ctor = env.module.find_ctor(bump, region, name)?; to_var_ctor(bump, env, name, &ctor) } None => Err(vec![Error::NotFoundVar { @@ -536,24 +541,27 @@ fn find_var<'a>( fn find_var_qual<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, region: Region, prefix: &'a str, name: &'a str, ) -> Result, Vec>> { if !env + .module .q_vars .get(prefix) .is_some_and(|values| values.contains_key(name)) && env + .module .q_ctors .get(prefix) .is_some_and(|ctors| ctors.contains_key(name)) { - let ctor = env.find_ctor_qual(bump, region, prefix, name)?; + let ctor = env.module.find_ctor_qual(bump, region, prefix, name)?; return to_var_ctor(bump, env, name, &ctor); } let info = env + .module .q_vars .get(prefix) .and_then(|m| m.get(name)) @@ -589,7 +597,7 @@ fn find_var_qual<'a>( fn to_var_ctor<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, name: &'a str, ctor: &EnvCtor<'a>, ) -> Result, Vec>> { @@ -637,7 +645,8 @@ fn to_var_ctor<'a>( typ, }); - let annotation = crate::kinds::check_annotation(bump, &env.kinds, name, annotation)?; + let annotation = + crate::kinds::check_annotation(bump, &env.module.kinds, name, annotation)?; CanExpr::VarConstructor { options: *options, reference: ConstructorName { @@ -694,7 +703,8 @@ fn to_var_ctor<'a>( free_vars, typ, }); - let annotation = crate::kinds::check_annotation(bump, &env.kinds, name, annotation)?; + let annotation = + crate::kinds::check_annotation(bump, &env.module.kinds, name, annotation)?; CanExpr::VarConstructor { options: CtorOpts::Normal, @@ -712,7 +722,7 @@ fn to_var_ctor<'a>( fn canonicalize_exprs<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, exprs: &[&'a Located>], free_locals: &mut FreeLocals<'a>, warnings: &mut Vec>, @@ -733,7 +743,7 @@ fn canonicalize_exprs<'a>( fn canonicalize_lambda<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, parameters: &'a [&'a Located>], body: &'a Located>, region: Region, @@ -742,8 +752,12 @@ fn canonicalize_lambda<'a>( ) -> Result<&'a Located>, Vec>> { // One duplicate-detection scope across ALL parameters, so `\x x -> x` // is rejected like in Elm. - let (can_params, all_bindings) = - pattern::verify_all(bump, env, DuplicatePatternContext::LambdaArgs, parameters)?; + let (can_params, all_bindings) = pattern::verify_all( + bump, + env.module, + DuplicatePatternContext::LambdaArgs, + parameters, + )?; let inner_env = env.add_locals(&all_bindings)?; let mut body_free_locals = FreeLocals::new(); @@ -768,7 +782,7 @@ fn canonicalize_lambda<'a>( fn canonicalize_case_branches<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, arms: &[&'a CaseArm<'a>], free_locals: &mut FreeLocals<'a>, warnings: &mut Vec>, @@ -789,13 +803,17 @@ fn canonicalize_case_branches<'a>( fn canonicalize_case_branch<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, arm: &'a CaseArm<'a>, free_locals: &mut FreeLocals<'a>, warnings: &mut Vec>, ) -> Result, Vec>> { - let (can_pattern, bindings) = - pattern::verify(bump, env, DuplicatePatternContext::CaseBranch, arm.pattern)?; + let (can_pattern, bindings) = pattern::verify( + bump, + env.module, + DuplicatePatternContext::CaseBranch, + arm.pattern, + )?; let inner_env = env.add_locals(&bindings)?; let mut body_free_locals = FreeLocals::new(); let can_body = canonicalize_expr(bump, &inner_env, arm.body, &mut body_free_locals, warnings)?; @@ -814,7 +832,7 @@ fn canonicalize_case_branch<'a>( fn canonicalize_if<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, branches: &[&'a SourceIfBranch<'a>], final_else: &'a Located>, free_locals: &mut FreeLocals<'a>, @@ -886,7 +904,7 @@ fn check_field_assigns<'a>( fn canonicalize_record<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, region: Region, fields: &[&'a FieldAssign<'a>], free_locals: &mut FreeLocals<'a>, @@ -895,9 +913,10 @@ fn canonicalize_record<'a>( let field_dict = check_field_assigns(fields)?; let mut candidates = BTreeMap::new(); for candidate in env + .module .ctors .values() - .chain(env.q_ctors.values().flat_map(|m| m.values())) + .chain(env.module.q_ctors.values().flat_map(|m| m.values())) { if let Info::Specific( _, @@ -978,7 +997,7 @@ fn canonicalize_record<'a>( fn canonicalize_update<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, record: &'a Located<&'a str>, fields: &[&'a FieldAssign<'a>], free_locals: &mut FreeLocals<'a>, @@ -1027,7 +1046,7 @@ struct ResolvedOp<'a> { fn canonicalize_binops<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, operands: &[&'a BinOpOperand<'a>], last: &'a Located>, overall_region: Region, @@ -1042,7 +1061,10 @@ fn canonicalize_binops<'a>( Ok(e) => can_exprs.push(e), Err(errs) => errors.extend(errs), } - match env.find_binop(bump, operand.op.region, operand.op.value) { + match env + .module + .find_binop(bump, operand.op.region, operand.op.value) + { Ok(binop) => ops.push(ResolvedOp { symbol: binop.symbol, home: binop.home, @@ -1143,7 +1165,7 @@ enum LetBinding<'a> { fn canonicalize_let<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, defs: &[&'a Located>], body: &'a Located>, region: Region, @@ -1373,7 +1395,7 @@ type LetDefResult<'a> = Result< fn canonicalize_let_def<'a>( bump: &'a Bump, - env: &Env<'a>, + env: &Scope<'_, 'a>, def: &SourceDef<'a>, let_bindings: &Bindings<'a>, warnings: &mut Vec>, @@ -1388,43 +1410,52 @@ fn canonicalize_let_def<'a>( // Mirrors Elm's `addDefNodes`: for typed defs the annotation is // resolved and matched against the arguments BEFORE the body is // canonicalized; either way one duplicate scope spans all args. - let (can_def_builder, arg_bindings): (DefBuilder<'a>, Bindings<'a>) = if let Some(ann) = - annotation - { - let annotation_val = types::to_annotation(bump, env, ann)?; - let annotation_val = - crate::kinds::check_annotation(bump, &env.kinds, name.value, annotation_val)?; - let mut bound: Vec<(&'a str, Region)> = Vec::new(); - let (typed_args, result_type) = - gather_typed_args(bump, env, name.value, args, annotation_val.typ, &mut bound)?; - let arg_bindings = pattern::detect_duplicates( - DuplicatePatternContext::FuncArgs(name.value), - bound, - )?; - ( - DefBuilder::Typed { - context: annotation_val.context, - annotation: annotation_val.typ, - free_vars: annotation_val.free_vars, - args: bump.alloc_slice_fill_iter(typed_args), - typ: result_type, - }, - arg_bindings, - ) - } else { - let (can_args, arg_bindings) = pattern::verify_all( - bump, - env, - DuplicatePatternContext::FuncArgs(name.value), - args, - )?; - ( - DefBuilder::Untyped { - args: bump.alloc_slice_fill_iter(can_args), - }, - arg_bindings, - ) - }; + let (can_def_builder, arg_bindings): (DefBuilder<'a>, Bindings<'a>) = + if let Some(ann) = annotation { + let annotation_val = types::to_annotation(bump, env.module, ann)?; + let annotation_val = crate::kinds::check_annotation( + bump, + &env.module.kinds, + name.value, + annotation_val, + )?; + let mut bound: Vec<(&'a str, Region)> = Vec::new(); + let (typed_args, result_type) = gather_typed_args( + bump, + env.module, + name.value, + args, + annotation_val.typ, + &mut bound, + )?; + let arg_bindings = pattern::detect_duplicates( + DuplicatePatternContext::FuncArgs(name.value), + bound, + )?; + ( + DefBuilder::Typed { + context: annotation_val.context, + annotation: annotation_val.typ, + free_vars: annotation_val.free_vars, + args: bump.alloc_slice_fill_iter(typed_args), + typ: result_type, + }, + arg_bindings, + ) + } else { + let (can_args, arg_bindings) = pattern::verify_all( + bump, + env.module, + DuplicatePatternContext::FuncArgs(name.value), + args, + )?; + ( + DefBuilder::Untyped { + args: bump.alloc_slice_fill_iter(can_args), + }, + arg_bindings, + ) + }; let body_env = env.add_locals(&arg_bindings)?; let mut body_free_locals = FreeLocals::new(); @@ -1476,7 +1507,7 @@ fn canonicalize_let_def<'a>( } SourceDef::Destruct { pattern, body } => { let (can_pattern, _) = - pattern::verify(bump, env, DuplicatePatternContext::Destruct, pattern)?; + pattern::verify(bump, env.module, DuplicatePatternContext::Destruct, pattern)?; let mut body_free_locals = FreeLocals::new(); let can_body = canonicalize_expr(bump, env, body, &mut body_free_locals, warnings)?; let deps: Vec<&'a str> = body_free_locals diff --git a/crates/nash-can/src/module.rs b/crates/nash-can/src/module.rs index 240e6ab2..09cbf440 100644 --- a/crates/nash-can/src/module.rs +++ b/crates/nash-can/src/module.rs @@ -359,7 +359,7 @@ fn to_node_one<'a>( ) }; - let body_env = env.add_locals(&arg_bindings)?; + let body_env = crate::environment::Scope::new(env, None, &arg_bindings)?; let mut free_locals = expression::FreeLocals::new(); let can_body = expression::canonicalize_expr(bump, &body_env, src.body, &mut free_locals, warnings)?; @@ -2878,6 +2878,48 @@ mod tests { ); } + #[test] + fn scope_recovery_between_case_branches() { + assert_module_error_snapshot!( + r#" + module Main exposing (..) + + f outer = + case outer of + first -> missing + second -> first + "# + ); + } + + #[test] + fn scope_recovery_after_rejected_shadowing() { + assert_module_error_snapshot!( + r#" + module Main exposing (..) + + f outer = + case outer of + outer -> outer + next -> unknown + "# + ); + } + + #[test] + fn scope_siblings_reuse_binding_names() { + assert_module_snapshot!( + r#" + module Main exposing (..) + + f outer = + case outer of + (first, value) -> value + (second, value) -> value + "# + ); + } + #[test] fn shadowing_local() { assert_module_error_snapshot!( diff --git a/crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_after_rejected_shadowing.snap b/crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_after_rejected_shadowing.snap new file mode 100644 index 00000000..85402dad --- /dev/null +++ b/crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_after_rejected_shadowing.snap @@ -0,0 +1,51 @@ +--- +source: crates/nash-can/src/module.rs +description: "Code:\n\nmodule Main exposing (..)\n\nf outer =\n case outer of\n outer -> outer\n next -> unknown\n" +--- +[ + Shadowing { + name: "outer", + original: Region { + start: Position { + line: 3, + column: 3, + }, + end: Position { + line: 3, + column: 8, + }, + }, + new: Region { + start: Position { + line: 5, + column: 9, + }, + end: Position { + line: 5, + column: 14, + }, + }, + }, + NotFoundVar { + region: Region { + start: Position { + line: 6, + column: 17, + }, + end: Position { + line: 6, + column: 24, + }, + }, + prefix: None, + name: "unknown", + suggestions: PossibleNames { + locals: [ + "f", + "next", + "outer", + ], + qualified: [], + }, + }, +] diff --git a/crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_between_case_branches.snap b/crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_between_case_branches.snap new file mode 100644 index 00000000..7da82df3 --- /dev/null +++ b/crates/nash-can/src/snapshots/nash_can__module__tests__scope_recovery_between_case_branches.snap @@ -0,0 +1,50 @@ +--- +source: crates/nash-can/src/module.rs +description: "Code:\n\nmodule Main exposing (..)\n\nf outer =\n case outer of\n first -> missing\n second -> first\n" +--- +[ + NotFoundVar { + region: Region { + start: Position { + line: 5, + column: 18, + }, + end: Position { + line: 5, + column: 25, + }, + }, + prefix: None, + name: "missing", + suggestions: PossibleNames { + locals: [ + "f", + "first", + "outer", + ], + qualified: [], + }, + }, + NotFoundVar { + region: Region { + start: Position { + line: 6, + column: 19, + }, + end: Position { + line: 6, + column: 24, + }, + }, + prefix: None, + name: "first", + suggestions: PossibleNames { + locals: [ + "f", + "outer", + "second", + ], + qualified: [], + }, + }, +] diff --git a/crates/nash-can/src/snapshots/nash_can__module__tests__scope_siblings_reuse_binding_names.snap b/crates/nash-can/src/snapshots/nash_can__module__tests__scope_siblings_reuse_binding_names.snap new file mode 100644 index 00000000..6d8e62c6 --- /dev/null +++ b/crates/nash-can/src/snapshots/nash_can__module__tests__scope_siblings_reuse_binding_names.snap @@ -0,0 +1,230 @@ +--- +source: crates/nash-can/src/module.rs +description: "Code:\n\nmodule Main exposing (..)\n\nf outer =\n case outer of\n (first, value) -> value\n (second, value) -> value\n" +--- +Module { + traits: [], + impls: [], + kind: Normal, + name: ModuleName { + package: None, + name: "Main", + }, + exports: Everything( + Region { + start: Position { + line: 1, + column: 22, + }, + end: Position { + line: 1, + column: 26, + }, + }, + ), + docs: NoDocs( + Region { + start: Position { + line: 1, + column: 1, + }, + end: Position { + line: 7, + column: 1, + }, + }, + ), + decls: Declare { + definition: Def { + name: Located { + region: Region { + start: Position { + line: 3, + column: 1, + }, + end: Position { + line: 3, + column: 2, + }, + }, + value: "f", + }, + args: [ + Located { + region: Region { + start: Position { + line: 3, + column: 3, + }, + end: Position { + line: 3, + column: 8, + }, + }, + value: Var( + "outer", + ), + }, + ], + body: Located { + region: Region { + start: Position { + line: 4, + column: 5, + }, + end: Position { + line: 7, + column: 1, + }, + }, + value: Case { + scrutinee: Located { + region: Region { + start: Position { + line: 4, + column: 10, + }, + end: Position { + line: 4, + column: 15, + }, + }, + value: VarLocal( + "outer", + ), + }, + branches: [ + CaseBranch { + pattern: Located { + region: Region { + start: Position { + line: 5, + column: 9, + }, + end: Position { + line: 5, + column: 23, + }, + }, + value: Tuple { + first: Located { + region: Region { + start: Position { + line: 5, + column: 10, + }, + end: Position { + line: 5, + column: 15, + }, + }, + value: Var( + "first", + ), + }, + second: Located { + region: Region { + start: Position { + line: 5, + column: 17, + }, + end: Position { + line: 5, + column: 22, + }, + }, + value: Var( + "value", + ), + }, + rest: [], + }, + }, + body: Located { + region: Region { + start: Position { + line: 5, + column: 27, + }, + end: Position { + line: 5, + column: 32, + }, + }, + value: VarLocal( + "value", + ), + }, + }, + CaseBranch { + pattern: Located { + region: Region { + start: Position { + line: 6, + column: 9, + }, + end: Position { + line: 6, + column: 24, + }, + }, + value: Tuple { + first: Located { + region: Region { + start: Position { + line: 6, + column: 10, + }, + end: Position { + line: 6, + column: 16, + }, + }, + value: Var( + "second", + ), + }, + second: Located { + region: Region { + start: Position { + line: 6, + column: 18, + }, + end: Position { + line: 6, + column: 23, + }, + }, + value: Var( + "value", + ), + }, + rest: [], + }, + }, + body: Located { + region: Region { + start: Position { + line: 6, + column: 28, + }, + end: Position { + line: 6, + column: 33, + }, + }, + value: VarLocal( + "value", + ), + }, + }, + ], + }, + }, + }, + next: Empty, + }, + unions: [], + aliases: [], + binops: [], +} diff --git a/docs/overview.md b/docs/overview.md index 24d77ddc..7f686015 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -22,6 +22,12 @@ EOF fit without a separate parser input-size failure. This makes `Region` 32 bytes on a 64-bit host. Protocol boundaries such as LSP check their narrower coordinate limits explicitly; they must not truncate positions. +Canonicalization keeps module information separate from local bindings. Each +local scope borrows its bindings and parent; creating or leaving a scope does +not copy or mutate module maps. Locals may shadow imports, but cannot shadow +ancestor locals, top-level definitions, or methods declared in the same module. +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. diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index 594bafa3..e8cb1107 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -9,7 +9,7 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen - [x] Widen coordinates with a safe source bound. - [x] Guard mutually recursive parsing with a measured nesting limit and remove flat-sequence recursion. - [x] Delete unused driver interface-cache machinery and orphaned dependencies. -- [ ] Separate canonical module data from local scopes without cloning the whole environment. +- [x] Separate canonical module data from local scopes without cloning the whole environment. - [ ] Bound trait selection and evidence lookup using existing map ordering. - [ ] Replace the constraint tree and intermediate inference Type with direct AST inference, subject to the adoption gates below. @@ -33,6 +33,10 @@ Chunk 4 removes InterfaceCache, ModuleMeta, interface load/save, the cache-only Chunk 4 verification passed: formatting, workspace check, clippy, full tests including interface-contract regressions, and positive scratch compilation. No snapshots changed and the lockfile removes only bincode and the two driver dependency edges. +Chunk 5 removes Env cloning and the local-variable variant from module lookup. Five scope entry points now borrow module data and binding maps through a parent chain. Shadowing checks, let-group visibility, generated section names, free-variable bookkeeping, and sorted suggestions preserve their prior behavior. Three new snapshots captured against the original implementation verify sibling reuse, recovery after missing names, and recovery after rejected shadowing; all canonicalizer tests pass unchanged after replacement. + +Chunk 5 verification passed: formatting, workspace check, clippy, full tests, positive scratch compilation, and a negative CLI JSON case retaining both independent missing-name diagnostics after scope exit. The three new baseline snapshots were reviewed individually; existing snapshots stayed unchanged. + ## Direct inference adoption gates Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. From e22bfb5783668dc1256eba8a2294e28d679a4816 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 16:05:38 -0400 Subject: [PATCH 07/11] perf(types): bound trait candidate traversal Signed-off-by: microproofs --- .sampo/changesets/bounded-trait-lookup.md | 6 ++ crates/nash-can/src/entailment.rs | 4 +- crates/nash-can/src/environment.rs | 91 ++++++++++++++++++++++- crates/nash-solve/src/evidence.rs | 7 +- crates/nash-solve/src/resolve.rs | 2 +- crates/nash-solve/src/solve.rs | 6 +- docs/traits.md | 5 ++ plans/frontend-hardening.md | 6 +- 8 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 .sampo/changesets/bounded-trait-lookup.md diff --git a/.sampo/changesets/bounded-trait-lookup.md b/.sampo/changesets/bounded-trait-lookup.md new file mode 100644 index 00000000..58ad172d --- /dev/null +++ b/.sampo/changesets/bounded-trait-lookup.md @@ -0,0 +1,6 @@ +--- +cargo/nash-can: patch +cargo/nash-solve: patch +--- + +Use ordered map ranges for trait candidate lookup, evidence construction, entailment, and missing-implementation suggestions. Preserve candidate order without a second index. diff --git a/crates/nash-can/src/entailment.rs b/crates/nash-can/src/entailment.rs index 5063c8e5..3d759d0e 100644 --- a/crates/nash-can/src/entailment.rs +++ b/crates/nash-can/src/entailment.rs @@ -350,9 +350,7 @@ impl<'a> Resolver<'_, 'a> { let mut selected = None; for (key, info) in self .tables - .impls - .iter() - .filter(|(key, _)| Some(key.trait_) == wanted.trait_) + .impls_for(wanted.trait_.ok_or(Failure::Missing)?) { if let nash_ast::head::Match::Yes(arguments) = nash_ast::head::matches( &mut nash_ast::head::Canonical, diff --git a/crates/nash-can/src/environment.rs b/crates/nash-can/src/environment.rs index 09a3cd0f..8df40fb7 100644 --- a/crates/nash-can/src/environment.rs +++ b/crates/nash-can/src/environment.rs @@ -104,7 +104,18 @@ pub fn visible_fields<'a>( fields } -impl Tables<'_> { +impl<'a> Tables<'a> { + /// ImplKey orders the trait before its head slice. The empty slice is the + /// first possible head key, so lookup visits only this trait's candidates. + pub fn impls_for( + &self, + trait_: nash_ast::QualifiedName<'a>, + ) -> impl Iterator, &&'a ImplInfo<'a>)> { + self.impls + .range(nash_ast::ImplKey { trait_, heads: &[] }..) + .take_while(move |(key, _)| key.trait_ == trait_) + } + pub fn has_structural_eq(&self) -> bool { self.traits.contains_key(&nash_ast::primitives::eq_trait()) } @@ -595,3 +606,81 @@ pub fn merge_qualified<'a, T: Clone>( let inner = table.entry(prefix).or_default(); merge_exposed(inner, name, home, value); } + +#[cfg(test)] +mod tests { + use super::*; + use nash_ast::{Head, ImplKey, PackageName, QualifiedName}; + + #[test] + fn trait_candidates_match_full_map_filter_in_order() { + let bump = Bump::new(); + let mut tables = Tables::default(); + let mut traits = Vec::new(); + for package in [ + None, + Some(PackageName { + author: "a", + project: "b", + }), + ] { + for module in ["A", "AA", "B"] { + for name in ["A", "AA", "B"] { + let trait_ = QualifiedName { + home: ModuleName { + package, + name: module, + }, + name, + }; + traits.push(trait_); + for heads in [ + vec![], + vec![Head::Var(0)], + vec![Head::Var(1)], + vec![Head::Var(0), Head::Var(1)], + ] { + let key = ImplKey { + trait_, + heads: bump.alloc_slice_copy(&heads), + }; + let info = bump.alloc(ImplInfo { + variables: &[], + home: trait_.home, + region: Region::zero(), + trait_, + context: &[], + heads: &[], + methods: &[], + }); + tables.impls.insert(key, info); + } + } + } + } + for module in ["", "AB", "Z"] { + traits.push(QualifiedName { + home: ModuleName { + package: None, + name: module, + }, + name: "Missing", + }); + } + for trait_ in traits { + let expected: Vec<_> = tables + .impls + .iter() + .filter(|(key, _)| key.trait_ == trait_) + .collect(); + let actual: Vec<_> = tables.impls_for(trait_).collect(); + assert_eq!( + actual.iter().map(|(key, _)| *key).collect::>(), + expected.iter().map(|(key, _)| *key).collect::>() + ); + for ((_, actual), (_, expected)) in actual.into_iter().zip(expected) { + assert!(std::ptr::eq(*actual, *expected)); + } + } + } +} diff --git a/crates/nash-solve/src/evidence.rs b/crates/nash-solve/src/evidence.rs index 3c5f8c85..cb80a5ea 100644 --- a/crates/nash-solve/src/evidence.rs +++ b/crates/nash-solve/src/evidence.rs @@ -302,12 +302,7 @@ impl<'a> Resolver<'_, 'a> { })); } let mut selected = None; - for (key, info) in self - .tables - .impls - .iter() - .filter(|(key, _)| key.trait_ == trait_) - { + for (key, info) in self.tables.impls_for(trait_) { if let nash_ast::head::Match::Yes(arguments) = nash_ast::head::matches( &mut nash_ast::head::Canonical, key.heads, diff --git a/crates/nash-solve/src/resolve.rs b/crates/nash-solve/src/resolve.rs index 7cf90386..96700438 100644 --- a/crates/nash-solve/src/resolve.rs +++ b/crates/nash-solve/src/resolve.rs @@ -202,7 +202,7 @@ pub(crate) fn select<'a>( let mut remaining = 16_384; let mut selected = None; let mut deferred = false; - for (key, info) in tables.impls.iter().filter(|(key, _)| key.trait_ == trait_) { + for (key, info) in tables.impls_for(trait_) { match matches( &mut types, key.heads, diff --git a/crates/nash-solve/src/solve.rs b/crates/nash-solve/src/solve.rs index 2a516de3..6b7e2fb7 100644 --- a/crates/nash-solve/src/solve.rs +++ b/crates/nash-solve/src/solve.rs @@ -1402,10 +1402,8 @@ impl<'a> Solver<'a, '_> { .collect(); let available: Vec<_> = self .tables - .impls - .keys() - .filter(|key| key.trait_ == trait_) - .map(|key| key.heads) + .impls_for(trait_) + .map(|(key, _)| key.heads) .collect(); self.failed_predicates.insert(id); state.errors.push(Error::MissingImpl { diff --git a/docs/traits.md b/docs/traits.md index 1329f79d..3bf629cd 100644 --- a/docs/traits.md +++ b/docs/traits.md @@ -822,3 +822,8 @@ for it. `Mode::Strict`, the default, is everything above. own `infix` declarations do not enter its env (Elm rule); operators bound to local methods therefore need the method called by name in the defining module. `nash/core` is written that way. + +Implementation lookup uses the existing `ImplKey` map ordering: start at the +requested trait with an empty head slice and stop when the trait changes. +Selection, evidence, entailment, and missing-impl suggestions share this traversal, +preserving candidate order without a separate index. diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index e8cb1107..5f1246ae 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -10,7 +10,7 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen - [x] Guard mutually recursive parsing with a measured nesting limit and remove flat-sequence recursion. - [x] Delete unused driver interface-cache machinery and orphaned dependencies. - [x] Separate canonical module data from local scopes without cloning the whole environment. -- [ ] Bound trait selection and evidence lookup using existing map ordering. +- [x] Bound trait selection and evidence lookup using existing map ordering. - [ ] Replace the constraint tree and intermediate inference Type with direct AST inference, subject to the adoption gates below. ## Verification and commits @@ -37,6 +37,10 @@ Chunk 5 removes Env cloning and the local-variable variant from module lookup. F Chunk 5 verification passed: formatting, workspace check, clippy, full tests, positive scratch compilation, and a negative CLI JSON case retaining both independent missing-name diagnostics after scope exit. The three new baseline snapshots were reviewed individually; existing snapshots stayed unchanged. +Chunk 6 replaces full-map trait filters in selection, evidence, canonical entailment, and missing-impl suggestions with a single ordered range helper. ImplKey compares trait identity before its head slice; the empty slice is its lower bound. An equivalence regression compares every candidate key and payload identity against the old filter across 18 package/module/name combinations, prefix-adjacent trait names, multiple head keys, empty heads, and missing traits. + +Chunk 6 verification passed: formatting, workspace check, clippy, full tests (including ordered candidate equivalence and existing matching/evidence/ambiguity regressions), and scratch compilation. Existing snapshots are unchanged. + ## Direct inference adoption gates Retain the existing union-find and predicate engines. Preserve ranks, generalization, recursive groups, annotations, aliases, higher-kinded applications, representation predicates, deferred fields, complete diagnostics in order, independent-error recovery, and SolvedTypes/evidence contracts. Keep an external baseline for differential tests; compare successful outputs and complete diagnostics with incidental allocation IDs normalized. From 0ed0c75a0ac421a193a420c116cd2284ba922a25 Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 16:18:52 -0400 Subject: [PATCH 08/11] refactor(types): infer directly from AST Signed-off-by: microproofs --- .sampo/changesets/direct-ast-inference.md | 8 + SPEC.md | 11 +- crates/nash-constrain/Cargo.toml | 5 +- crates/nash-constrain/src/expression.rs | 1534 ----------------- crates/nash-constrain/src/instantiate.rs | 131 +- crates/nash-constrain/src/lib.rs | 14 +- crates/nash-constrain/src/module.rs | 72 - crates/nash-constrain/src/pattern.rs | 309 ---- crates/nash-constrain/src/type_.rs | 180 +- .../tests/predicate_instantiation.rs | 51 - crates/nash-driver/src/compile.rs | 7 +- .../src/compile/nitpick_source_tests.rs | 4 +- crates/nash-report/src/pattern.rs | 4 +- crates/nash-report/src/type_/tests.rs | 4 +- crates/nash-solve/src/lib.rs | 6 +- crates/nash-solve/src/solve.rs | 965 +++-------- crates/nash-solve/src/solve/expressions.rs | 771 +++++++++ crates/nash-solve/src/solve/infer.rs | 1184 +++++++++++++ crates/nash-solve/src/solve/patterns.rs | 328 ++++ crates/nash-solve/tests/evidence.rs | 4 +- crates/nash-solve/tests/inference.rs | 362 ++-- .../tests/representation_predicates.rs | 163 +- ...y_direct_alias_bool_keeps_header_type.snap | 118 ++ ...y_direct_alias_ctor_keeps_header_type.snap | 118 ++ ...direct_alias_nested_keeps_header_type.snap | 120 ++ ..._annotated_case_keeps_both_mismatches.snap | 90 + ...ct_annotated_if_keeps_both_mismatches.snap | 90 + ..._cons_tail_keeps_independent_mismatch.snap | 158 ++ ...ursive_occurs_precedes_generalization.snap | 66 + docs/frontend-hardening-verification.md | 131 ++ docs/overview.md | 8 +- docs/traits.md | 24 +- plans/frontend-hardening.md | 12 +- 33 files changed, 3748 insertions(+), 3304 deletions(-) create mode 100644 .sampo/changesets/direct-ast-inference.md delete mode 100644 crates/nash-constrain/src/expression.rs delete mode 100644 crates/nash-constrain/src/module.rs delete mode 100644 crates/nash-constrain/src/pattern.rs delete mode 100644 crates/nash-constrain/tests/predicate_instantiation.rs create mode 100644 crates/nash-solve/src/solve/expressions.rs create mode 100644 crates/nash-solve/src/solve/infer.rs create mode 100644 crates/nash-solve/src/solve/patterns.rs create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_bool_keeps_header_type.snap create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_ctor_keeps_header_type.snap create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_nested_keeps_header_type.snap create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_case_keeps_both_mismatches.snap create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_if_keeps_both_mismatches.snap create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_cons_tail_keeps_independent_mismatch.snap create mode 100644 crates/nash-solve/tests/snapshots/inference__recovery_direct_recursive_occurs_precedes_generalization.snap create mode 100644 docs/frontend-hardening-verification.md diff --git a/.sampo/changesets/direct-ast-inference.md b/.sampo/changesets/direct-ast-inference.md new file mode 100644 index 00000000..134f2807 --- /dev/null +++ b/.sampo/changesets/direct-ast-inference.md @@ -0,0 +1,8 @@ +--- +cargo/nash-constrain: minor +cargo/nash-solve: minor +cargo/nash-driver: patch +cargo/nash-report: patch +--- + +Infer directly from the canonical AST into the existing union-find and predicate engine. Remove the allocated constraint tree and intermediate inference Type, preserving schemes, evidence, rank ownership, recursive-group sequencing, and complete diagnostics. Pass canonical modules directly to the solver. diff --git a/SPEC.md b/SPEC.md index c2743163..a73db8b4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,7 +8,7 @@ component specs in [`docs/`](docs/); chunked implementation plans in ## Pipeline ``` -parse -> canonicalize -> kinds -> constrain/solve (+ traits) -> nitpick +parse -> canonicalize -> kinds -> direct inference (+ traits) -> nitpick -> macro expansion (loop) -> Core IR -> optimize -> UPLC ``` @@ -24,8 +24,8 @@ produce UPLC programs; all dependencies inline into each program. | `nash-parse` | parser + Elm error hierarchy | extend ([plans/01](plans/01-syntax.md)) | | `nash-ast` | canonical AST | extend | | `nash-can` | canonicalization, interfaces | extend | -| `nash-constrain` | constraint generation, kinds | extend | -| `nash-solve` | solver, traits, defaulting | extend | +| `nash-constrain` | union-find types, canonical instantiation, type errors | done | +| `nash-solve` | direct AST inference, traits, defaulting | extend | | `nash-nitpick` | exhaustiveness and redundancy | done ([plans/05](plans/05-nitpick.md)) | | `nash-report` | diagnostics (Elm prose -> miette) | done ([plans/06](plans/06-diagnostics.md)) | | `nash-ir` | Core IR + passes | new ([plans/07](plans/07-codegen.md), [08](plans/08-optimizer.md)) | @@ -46,7 +46,8 @@ Done: - [x] Parser (Elm `Parse/*` port, full syntax error hierarchy) - [x] Canonicalization (Elm `Canonicalize/*` port, SCC, interfaces) -- [x] Type inference (Elm `Type/*` port: constraints, rank-based solver, records, aliases) +- [x] Type inference (direct AST inference, rank-based solver, records, aliases) +- [x] Frontend hardening and direct-inference parity ([verification](docs/frontend-hardening-verification.md)) - [x] Project config, driver, dependency-ordered builds, interface cache - [x] `nash check` - [x] UPLC runtime (`nash-plutus`): conformance suite passes @@ -80,7 +81,7 @@ Later: LSP features, web playground, package registry (pubgrub), TypeScript code | `AST/Source.hs` | `crates/nash-source/src/lib.rs` | | `AST/Canonical.hs` | `crates/nash-ast/src/lib.rs` | | `Canonicalize/*` | `crates/nash-can/src/*` | -| `Type/Type.hs`, `Type/Constrain/*` | `crates/nash-constrain/src/*` | +| `Type/Type.hs`, `Type/Constrain/*` | `crates/nash-constrain/src/*`, `crates/nash-solve/src/solve/*` | | `Type/{Solve,Unify,Occurs}.hs` | `crates/nash-solve/src/*` | | `Nitpick/PatternMatches.hs` | `crates/nash-nitpick` | | `Reporting/{Doc,Report,Render,Suggest}.hs`, `Reporting/Error/*` | `crates/nash-report` | diff --git a/crates/nash-constrain/Cargo.toml b/crates/nash-constrain/Cargo.toml index fdf08591..026ea38d 100644 --- a/crates/nash-constrain/Cargo.toml +++ b/crates/nash-constrain/Cargo.toml @@ -3,9 +3,8 @@ name = "nash-constrain" version = "0.4.1" edition.workspace = true description = """ -Responsible for building the set of constraints that are used \ -during type inference of a program, and for gathering context \ -needed for pleasant error messages when a type error occurs. +Shared union-find types, canonical type instantiation, and diagnostics \ +for direct type inference. """ homepage.workspace = true repository.workspace = true diff --git a/crates/nash-constrain/src/expression.rs b/crates/nash-constrain/src/expression.rs deleted file mode 100644 index 280762dc..00000000 --- a/crates/nash-constrain/src/expression.rs +++ /dev/null @@ -1,1534 +0,0 @@ -//! Port of Elm's `Type.Constrain.Expression`: turn canonical expressions -//! into constraints. -//! -//! Deviations from Elm, all because `nash-ast` has no such expressions: -//! no `Float`/`Chr` literals, no `Shader`, no `VarKernel`/`VarDebug`. - -use bumpalo::Bump; -use nash_ast::{ - CaseBranch, Def as CanDef, Expr as CanExpr, FieldUpdate, FieldValue, IfBranch, NodeId, - TypedPattern, -}; -use nash_region::{Located, Region}; - -use crate::error::{Category, Context, Expected, MaybeName, PContext, PExpected, SubContext}; -use crate::instantiate; -use crate::pattern; -use crate::type_::{self, Constraint, Definition, Type, exists, mk_flex_var, name_to_rigid}; -use crate::union_find::{UnionFind, Variable}; - -/// Elm's `RTV`: rigid type variables introduced by enclosing type -/// annotations, shared with nested annotations. -pub type Rtv<'a> = instantiate::FreeVars<'a>; - -type Exp<'a> = Expected<'a, &'a Type<'a>>; - -pub fn constrain<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - expr: &Located>, - expected: Exp<'a>, -) -> Constraint<'a> { - let region = expr.region; - let node = NodeId::expr(expr); - match &expr.value { - CanExpr::VarLocal(name) => Constraint::Local(region, node, name, expected), - - CanExpr::VarTopLevel(reference) => { - Constraint::Local(region, node, reference.name, expected) - } - - CanExpr::VarForeign { - reference, - annotation, - } => Constraint::Foreign(region, node, reference.name, annotation, expected), - - CanExpr::VarMethod { - method, annotation, .. - } => Constraint::Foreign(region, node, method, annotation, expected), - - CanExpr::VarConstructor { - reference, - annotation, - .. - } => Constraint::Foreign(region, node, reference.name, annotation, expected), - - CanExpr::VarOperator { - symbol, annotation, .. - } => Constraint::Foreign(region, node, symbol, annotation, expected), - - CanExpr::Str(_) => Constraint::Foreign( - region, - node, - "fromString", - type_::literal_annotation(bump, &[type_::literal_trait("FromString")]), - expected, - ), - CanExpr::Bytes(_) => Constraint::Foreign( - region, - node, - "fromBytes", - type_::literal_annotation(bump, &[type_::literal_trait("FromBytes")]), - expected, - ), - CanExpr::Int(_) => Constraint::Foreign( - region, - node, - "fromInt", - type_::literal_annotation(bump, &[type_::literal_trait("FromInt")]), - expected, - ), - - CanExpr::List(elements) => constrain_list(bump, uf, rtv, region, elements, expected), - - CanExpr::Binop { - symbol, - annotation, - left, - right, - .. - } => constrain_binop( - bump, uf, rtv, region, node, symbol, annotation, left, right, expected, - ), - - CanExpr::Lambda { parameters, body } => { - constrain_lambda(bump, uf, rtv, region, parameters, body, expected) - } - - CanExpr::Call { - function, - arguments, - } => constrain_call(bump, uf, rtv, region, function, arguments, expected), - - CanExpr::If { - branches, - final_else, - } => constrain_if(bump, uf, rtv, region, branches, final_else, expected), - - CanExpr::Case { - scrutinee, - branches, - } => constrain_case(bump, uf, rtv, region, scrutinee, branches, expected), - - CanExpr::Let { definition, body } => { - let body_con = constrain(bump, uf, rtv, body, expected); - constrain_def(bump, uf, rtv, definition, body_con) - } - - CanExpr::LetRec { definitions, body } => { - let body_con = constrain(bump, uf, rtv, body, expected); - constrain_recursive_defs(bump, uf, rtv, definitions, body_con) - } - - CanExpr::LetDestruct { - pattern, - value, - body, - } => { - let body_con = constrain(bump, uf, rtv, body, expected); - constrain_destruct(bump, uf, rtv, region, pattern, value, body_con) - } - - CanExpr::Accessor(field) => { - let record_var = mk_flex_var(uf); - let field_var = mk_flex_var(uf); - let record_type: &'a Type<'a> = bump.alloc(Type::VarN(record_var)); - let field_type: &'a Type<'a> = bump.alloc(Type::VarN(field_var)); - exists( - bump, - bump.alloc_slice_copy(&[record_var, field_var]), - c_and( - bump, - vec![ - Constraint::Field { - region, - context: type_::FieldContext::Accessor, - record: record_type, - field, - field_type, - }, - Constraint::Equal( - region, - Category::Accessor(field), - bump.alloc(Type::FunN(record_type, field_type)), - expected, - ), - ], - ), - ) - } - - CanExpr::Access { record, field } => { - let record_var = mk_flex_var(uf); - let field_var = mk_flex_var(uf); - let record_type: &'a Type<'a> = bump.alloc(Type::VarN(record_var)); - let field_type: &'a Type<'a> = bump.alloc(Type::VarN(field_var)); - let record_con = constrain(bump, uf, rtv, record, Expected::NoExpectation(record_type)); - exists( - bump, - bump.alloc_slice_copy(&[record_var, field_var]), - c_and( - bump, - vec![ - record_con, - Constraint::Field { - region, - context: type_::FieldContext::Access { - record_region: record.region, - maybe_name: get_access_name(record), - }, - record: record_type, - field: field.value, - field_type, - }, - Constraint::Equal( - region, - Category::Access(field.value), - field_type, - expected, - ), - ], - ), - ) - } - - CanExpr::Update { - record, - base, - fields, - } => constrain_update(bump, uf, rtv, region, record, base, fields, expected), - - CanExpr::Record { - alias, - annotation, - fields, - } => constrain_record( - bump, uf, rtv, region, node, *alias, annotation, fields, expected, - ), - - CanExpr::Unit => Constraint::Equal( - region, - Category::Unit, - bump.alloc(crate::type_::unit()), - expected, - ), - - CanExpr::Tuple { - first, - second, - rest, - } => constrain_tuple(bump, uf, rtv, region, first, second, rest, expected), - } -} - -// HELPERS - -fn c_and<'a>(bump: &'a Bump, cons: Vec>) -> Constraint<'a> { - Constraint::And(bump.alloc_slice_fill_iter(cons)) -} - -fn singleton_header<'a>( - bump: &'a Bump, - name: &'a str, - region: Region, - tipe: &'a Type<'a>, -) -> &'a [(&'a str, Located<&'a Type<'a>>)] { - bump.alloc_slice_copy(&[(name, Located::at(region, tipe))]) -} - -fn header_slice<'a>( - bump: &'a Bump, - headers: pattern::Header<'a>, -) -> &'a [(&'a str, Located<&'a Type<'a>>)] { - bump.alloc_slice_fill_iter(headers) -} - -fn reversed_and<'a>(bump: &'a Bump, mut rev_cons: Vec>) -> Constraint<'a> { - rev_cons.reverse(); - c_and(bump, rev_cons) -} - -// CONSTRAIN LAMBDA - -fn constrain_lambda<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - args: &[&Located>], - body: &Located>, - expected: Exp<'a>, -) -> Constraint<'a> { - let Args { - vars, - tipe, - result_type, - state, - } = constrain_args(bump, uf, args); - - let body_con = constrain(bump, uf, rtv, body, Expected::NoExpectation(result_type)); - - exists( - bump, - bump.alloc_slice_fill_iter(vars), - c_and( - bump, - vec![ - Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(state.vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(reversed_and(bump, state.rev_cons)), - body_con: bump.alloc(body_con), - }, - Constraint::Equal(region, Category::Lambda, tipe, expected), - ], - ), - ) -} - -// CONSTRAIN CALL - -fn constrain_call<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - func: &Located>, - args: &[&Located>], - expected: Exp<'a>, -) -> Constraint<'a> { - let maybe_name = get_name(func); - let func_region = func.region; - - let func_var = mk_flex_var(uf); - let result_var = mk_flex_var(uf); - let func_type: &'a Type<'a> = bump.alloc(Type::VarN(func_var)); - let result_type: &'a Type<'a> = bump.alloc(Type::VarN(result_var)); - - let func_con = constrain(bump, uf, rtv, func, Expected::NoExpectation(func_type)); - - let mut arg_vars = Vec::with_capacity(args.len()); - let mut arg_types = Vec::with_capacity(args.len()); - let mut arg_cons = Vec::with_capacity(args.len()); - for (index, arg) in args.iter().enumerate() { - let arg_var = mk_flex_var(uf); - let arg_type: &'a Type<'a> = bump.alloc(Type::VarN(arg_var)); - let arg_con = constrain( - bump, - uf, - rtv, - arg, - Expected::FromContext(region, Context::CallArg(maybe_name, index), arg_type), - ); - arg_vars.push(arg_var); - arg_types.push(arg_type); - arg_cons.push(arg_con); - } - - let arity_type = arg_types.iter().rev().fold(result_type, |acc, arg_type| { - bump.alloc(Type::FunN(arg_type, acc)) - }); - let category = Category::CallResult(maybe_name); - - let mut vars = vec![func_var, result_var]; - vars.extend(arg_vars); - - exists( - bump, - bump.alloc_slice_fill_iter(vars), - c_and( - bump, - vec![ - func_con, - Constraint::Equal( - func_region, - category, - func_type, - Expected::FromContext( - region, - Context::CallArity(maybe_name, args.len()), - arity_type, - ), - ), - c_and(bump, arg_cons), - Constraint::Equal(region, category, result_type, expected), - ], - ), - ) -} - -fn get_name<'a>(func: &Located>) -> MaybeName<'a> { - match &func.value { - CanExpr::VarMethod { method, .. } => MaybeName::FuncName(method), - CanExpr::VarLocal(name) => MaybeName::FuncName(name), - CanExpr::VarTopLevel(reference) => MaybeName::FuncName(reference.name), - CanExpr::VarForeign { reference, .. } => MaybeName::FuncName(reference.name), - CanExpr::VarConstructor { reference, .. } => MaybeName::CtorName(reference.name), - CanExpr::VarOperator { symbol, .. } => MaybeName::OpName(symbol), - _ => MaybeName::NoName, - } -} - -fn get_access_name<'a>(record: &Located>) -> Option<&'a str> { - match &record.value { - CanExpr::VarLocal(name) => Some(name), - CanExpr::VarTopLevel(reference) => Some(reference.name), - CanExpr::VarForeign { reference, .. } => Some(reference.name), - _ => None, - } -} - -// CONSTRAIN BINOP - -#[allow(clippy::too_many_arguments)] -fn constrain_binop<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - node: NodeId, - op: &'a str, - annotation: &'a nash_ast::Annotation<'a>, - left_expr: &Located>, - right_expr: &Located>, - expected: Exp<'a>, -) -> Constraint<'a> { - let left_var = mk_flex_var(uf); - let right_var = mk_flex_var(uf); - let answer_var = mk_flex_var(uf); - let left_type: &'a Type<'a> = bump.alloc(Type::VarN(left_var)); - let right_type: &'a Type<'a> = bump.alloc(Type::VarN(right_var)); - let answer_type: &'a Type<'a> = bump.alloc(Type::VarN(answer_var)); - let binop_type: &'a Type<'a> = bump.alloc(Type::FunN( - left_type, - bump.alloc(Type::FunN(right_type, answer_type)), - )); - - let op_con = Constraint::Foreign( - region, - node, - op, - annotation, - Expected::NoExpectation(binop_type), - ); - - let left_con = constrain( - bump, - uf, - rtv, - left_expr, - Expected::FromContext(region, Context::OpLeft(op), left_type), - ); - let right_con = constrain( - bump, - uf, - rtv, - right_expr, - Expected::FromContext(region, Context::OpRight(op), right_type), - ); - - exists( - bump, - bump.alloc_slice_copy(&[left_var, right_var, answer_var]), - c_and( - bump, - vec![ - op_con, - left_con, - right_con, - Constraint::Equal( - region, - Category::CallResult(MaybeName::OpName(op)), - answer_type, - expected, - ), - ], - ), - ) -} - -// CONSTRAIN LISTS - -fn constrain_list<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - entries: &[&Located>], - expected: Exp<'a>, -) -> Constraint<'a> { - let entry_var = mk_flex_var(uf); - let entry_type: &'a Type<'a> = bump.alloc(Type::VarN(entry_var)); - let list_type: &'a Type<'a> = bump.alloc(type_::list(bump, entry_type)); - - let entry_cons = entries - .iter() - .enumerate() - .map(|(index, entry)| { - constrain( - bump, - uf, - rtv, - entry, - Expected::FromContext(region, Context::ListEntry(index), entry_type), - ) - }) - .collect(); - - exists( - bump, - bump.alloc_slice_copy(&[entry_var]), - c_and( - bump, - vec![ - c_and(bump, entry_cons), - Constraint::Equal(region, Category::List, list_type, expected), - ], - ), - ) -} - -// CONSTRAIN IF EXPRESSIONS - -fn constrain_if<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - branches: &[IfBranch<'a>], - final_else: &Located>, - expected: Exp<'a>, -) -> Constraint<'a> { - let bool_type: &'a Type<'a> = bump.alloc(type_::bool()); - let cond_cons: Vec> = branches - .iter() - .map(|branch| { - constrain( - bump, - uf, - rtv, - branch.condition, - Expected::FromContext(region, Context::IfCondition, bool_type), - ) - }) - .collect(); - - let exprs: Vec<&Located>> = branches - .iter() - .map(|branch| branch.then_branch) - .chain(std::iter::once(final_else)) - .collect(); - - match expected { - Expected::FromAnnotation(name, arity, _, tipe) => { - let branch_cons = exprs - .iter() - .enumerate() - .map(|(index, branch_expr)| { - constrain( - bump, - uf, - rtv, - branch_expr, - Expected::FromAnnotation( - name, - arity, - SubContext::TypedIfBranch(index), - tipe, - ), - ) - }) - .collect(); - c_and(bump, vec![c_and(bump, cond_cons), c_and(bump, branch_cons)]) - } - - _ => { - let branch_var = mk_flex_var(uf); - let branch_type: &'a Type<'a> = bump.alloc(Type::VarN(branch_var)); - - let branch_cons = exprs - .iter() - .enumerate() - .map(|(index, branch_expr)| { - constrain( - bump, - uf, - rtv, - branch_expr, - Expected::FromContext(region, Context::IfBranch(index), branch_type), - ) - }) - .collect(); - - exists( - bump, - bump.alloc_slice_copy(&[branch_var]), - c_and( - bump, - vec![ - c_and(bump, cond_cons), - c_and(bump, branch_cons), - Constraint::Equal(region, Category::If, branch_type, expected), - ], - ), - ) - } - } -} - -// CONSTRAIN CASE EXPRESSIONS - -fn constrain_case<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - expr: &Located>, - branches: &[CaseBranch<'a>], - expected: Exp<'a>, -) -> Constraint<'a> { - let ptrn_var = mk_flex_var(uf); - let ptrn_type: &'a Type<'a> = bump.alloc(Type::VarN(ptrn_var)); - let expr_con = constrain(bump, uf, rtv, expr, Expected::NoExpectation(ptrn_type)); - - match expected { - Expected::FromAnnotation(name, arity, _, tipe) => { - let mut cons = vec![expr_con]; - for (index, branch) in branches.iter().enumerate() { - cons.push(constrain_case_branch( - bump, - uf, - rtv, - branch, - PExpected::FromContext(region, PContext::CaseMatch(index), ptrn_type), - Expected::FromAnnotation(name, arity, SubContext::TypedCaseBranch(index), tipe), - )); - } - exists(bump, bump.alloc_slice_copy(&[ptrn_var]), c_and(bump, cons)) - } - - _ => { - let branch_var = mk_flex_var(uf); - let branch_type: &'a Type<'a> = bump.alloc(Type::VarN(branch_var)); - - let mut branch_cons = Vec::with_capacity(branches.len()); - for (index, branch) in branches.iter().enumerate() { - branch_cons.push(constrain_case_branch( - bump, - uf, - rtv, - branch, - PExpected::FromContext(region, PContext::CaseMatch(index), ptrn_type), - Expected::FromContext(region, Context::CaseBranch(index), branch_type), - )); - } - - exists( - bump, - bump.alloc_slice_copy(&[ptrn_var, branch_var]), - c_and( - bump, - vec![ - expr_con, - c_and(bump, branch_cons), - Constraint::Equal(region, Category::Case, branch_type, expected), - ], - ), - ) - } - } -} - -fn constrain_case_branch<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - branch: &CaseBranch<'a>, - p_expect: PExpected<'a, &'a Type<'a>>, - b_expect: Exp<'a>, -) -> Constraint<'a> { - let state = pattern::add(bump, uf, branch.pattern, p_expect, pattern::empty_state()); - - let body_con = constrain(bump, uf, rtv, branch.body, b_expect); - - Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(state.vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(reversed_and(bump, state.rev_cons)), - body_con: bump.alloc(body_con), - } -} - -// CONSTRAIN RECORD - -#[allow(clippy::too_many_arguments)] -fn constrain_record<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - node: NodeId, - alias: nash_ast::QualifiedName<'a>, - annotation: &'a nash_ast::Annotation<'a>, - fields: &[FieldValue<'a>], - expected: Exp<'a>, -) -> Constraint<'a> { - let mut vars = Vec::with_capacity(fields.len() + 1); - let mut cons = Vec::with_capacity(fields.len() + 2); - let mut args = Vec::with_capacity(fields.len()); - for field in fields { - let var = mk_flex_var(uf); - let typ: &'a Type<'a> = bump.alloc(Type::VarN(var)); - vars.push(var); - args.push(typ); - cons.push(constrain( - bump, - uf, - rtv, - field.value, - Expected::FromContext( - region, - Context::RecordField(alias.name, field.field.value), - typ, - ), - )); - } - let result = mk_flex_var(uf); - vars.push(result); - let result_type: &'a Type<'a> = bump.alloc(Type::VarN(result)); - let ctor_type = args.into_iter().rev().fold(result_type, |result, arg| { - &*bump.alloc(Type::FunN(arg, result)) - }); - cons.push(Constraint::Foreign( - region, - node, - alias.name, - annotation, - Expected::NoExpectation(ctor_type), - )); - cons.push(Constraint::Equal( - region, - Category::Record, - result_type, - expected, - )); - exists(bump, bump.alloc_slice_fill_iter(vars), c_and(bump, cons)) -} - -// CONSTRAIN RECORD UPDATE - -#[allow(clippy::too_many_arguments)] -fn constrain_update<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - name: &'a str, - expr: &Located>, - fields: &'a [FieldUpdate<'a>], - expected: Exp<'a>, -) -> Constraint<'a> { - let record_var = mk_flex_var(uf); - let record_type: &'a Type<'a> = bump.alloc(Type::VarN(record_var)); - let mut vars = vec![record_var]; - let mut cons = vec![constrain( - bump, - uf, - rtv, - expr, - Expected::FromContext(region, Context::RecordUpdateKeys(name, fields), record_type), - )]; - if fields.is_empty() { - cons.push(Constraint::Record { - region, - context: type_::FieldContext::Update { record: name }, - record: record_type, - }); - } - for field in fields { - let var = mk_flex_var(uf); - vars.push(var); - let field_type: &'a Type<'a> = bump.alloc(Type::VarN(var)); - cons.push(constrain( - bump, - uf, - rtv, - field.value, - Expected::FromContext( - region, - Context::RecordUpdateValue(field.field.value), - field_type, - ), - )); - cons.push(Constraint::Field { - region: field.field.region, - context: type_::FieldContext::Update { record: name }, - record: record_type, - field: field.field.value, - field_type, - }); - } - cons.push(Constraint::Equal( - region, - Category::Record, - record_type, - expected, - )); - exists(bump, bump.alloc_slice_fill_iter(vars), c_and(bump, cons)) -} - -// CONSTRAIN TUPLE - -#[allow(clippy::too_many_arguments)] -fn constrain_tuple<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - a: &Located>, - b: &Located>, - rest: &[&Located>], - expected: Exp<'a>, -) -> Constraint<'a> { - let a_var = mk_flex_var(uf); - let b_var = mk_flex_var(uf); - let a_type: &'a Type<'a> = bump.alloc(Type::VarN(a_var)); - let b_type: &'a Type<'a> = bump.alloc(Type::VarN(b_var)); - - let a_con = constrain(bump, uf, rtv, a, Expected::NoExpectation(a_type)); - let b_con = constrain(bump, uf, rtv, b, Expected::NoExpectation(b_type)); - - let mut vars = vec![a_var, b_var]; - let mut cons = vec![a_con, b_con]; - let mut types = Vec::with_capacity(rest.len()); - for item in rest { - let var = mk_flex_var(uf); - let tipe: &'a Type<'a> = bump.alloc(Type::VarN(var)); - vars.push(var); - types.push(tipe); - cons.push(constrain( - bump, - uf, - rtv, - item, - Expected::NoExpectation(tipe), - )); - } - let tuple_type = bump.alloc(Type::TupleN(a_type, b_type, bump.alloc_slice_copy(&types))); - cons.push(Constraint::Equal( - region, - Category::Tuple, - tuple_type, - expected, - )); - exists(bump, bump.alloc_slice_copy(&vars), c_and(bump, cons)) -} - -// CONSTRAIN DESTRUCTURES - -fn constrain_destruct<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - region: Region, - pattern_ast: &Located>, - expr: &Located>, - body_con: Constraint<'a>, -) -> Constraint<'a> { - let pattern_var = mk_flex_var(uf); - let pattern_type: &'a Type<'a> = bump.alloc(Type::VarN(pattern_var)); - - let mut state = pattern::add( - bump, - uf, - pattern_ast, - PExpected::NoExpectation(pattern_type), - pattern::empty_state(), - ); - - let expr_con = constrain( - bump, - uf, - rtv, - expr, - Expected::FromContext(region, Context::Destructure, pattern_type), - ); - - let mut flex_vars = vec![pattern_var]; - flex_vars.append(&mut state.vars); - - // Elm: `CAnd (reverse (exprCon:revCons))` โ€” exprCon runs last. - let mut cons = state.rev_cons; - cons.reverse(); - cons.push(expr_con); - - let binder = type_::Binder::Pattern { - node: NodeId::pattern(pattern_ast), - name: bump.alloc(Located::at(region, "")), - }; - Constraint::Let { - declarations: &[], - given: &[], - binder: Some(binder), - definitions: bump.alloc_slice_copy(&[Definition { - site: binder, - typ: pattern_type, - context: None, - }]), - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(flex_vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(c_and(bump, cons)), - body_con: bump.alloc(body_con), - } -} - -// CONSTRAIN DEF - -pub fn constrain_def<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - def: &CanDef<'a>, - body_con: Constraint<'a>, -) -> Constraint<'a> { - constrain_definition(bump, uf, rtv, def, body_con, true) -} - -/// Check a method body in the module environment without introducing its -/// name as a top-level value. Its annotation is already specialized for -/// the enclosing trait or impl by canonicalization. -pub fn constrain_method<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - def: &CanDef<'a>, -) -> Constraint<'a> { - assert!(matches!(def, CanDef::TypedDef { .. })); - constrain_definition(bump, uf, &Rtv::new(), def, Constraint::True, false) -} - -fn constrain_definition<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - def: &CanDef<'a>, - body_con: Constraint<'a>, - bind_name: bool, -) -> Constraint<'a> { - match def { - CanDef::Def { name, args, body } => { - let Args { - vars, - tipe, - result_type, - state, - } = constrain_args(bump, uf, args); - - let expr_con = constrain(bump, uf, rtv, body, Expected::NoExpectation(result_type)); - - Constraint::Let { - declarations: &[], - given: &[], - binder: Some(type_::Binder::Named(name)), - definitions: bump.alloc_slice_copy(&[Definition { - site: type_::Binder::Named(name), - typ: tipe, - context: None, - }]), - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(vars), - header: if bind_name { - singleton_header(bump, name.value, name.region, tipe) - } else { - &[] - }, - header_con: bump.alloc(Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(state.vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(reversed_and(bump, state.rev_cons)), - body_con: bump.alloc(expr_con), - }), - body_con: bump.alloc(body_con), - } - } - - CanDef::TypedDef { - name, - free_vars, - context, - args, - body, - typ: src_result_type, - .. - } => { - let (new_rigids, new_rtv) = make_rigids(bump, uf, rtv, free_vars); - - let TypedArgs { - tipe, - result_type, - state, - } = constrain_typed_args(bump, uf, &new_rtv, name.value, args, src_result_type); - - let expected = Expected::FromAnnotation( - name.value, - args.len(), - SubContext::TypedBody, - result_type, - ); - let expr_con = constrain(bump, uf, &new_rtv, body, expected); - let given = instantiate::from_src_context(bump, &new_rtv, context); - - Constraint::Let { - declarations: &[], - given, - binder: Some(type_::Binder::Named(name)), - definitions: bump.alloc_slice_copy(&[Definition { - site: type_::Binder::Named(name), - typ: tipe, - context: Some(given), - }]), - rigid_vars: bump.alloc_slice_fill_iter(new_rigids.iter().map(|(_, var)| *var)), - flex_vars: &[], - header: if bind_name { - singleton_header(bump, name.value, name.region, tipe) - } else { - &[] - }, - header_con: bump.alloc(Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(state.vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(reversed_and(bump, state.rev_cons)), - body_con: bump.alloc(expr_con), - }), - body_con: bump.alloc(body_con), - } - } - } -} - -/// Elm: `newNames = Map.difference freeVars rtv` then `nameToRigid` per -/// name in `Map` (name-sorted) order. -fn make_rigids<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - free_vars: &[&'a str], -) -> (Vec<(&'a str, Variable)>, Rtv<'a>) { - let mut new_names: Vec<&'a str> = free_vars - .iter() - .filter(|name| !rtv.contains_key(*name)) - .copied() - .collect(); - new_names.sort_unstable(); - - let new_rigids: Vec<(&'a str, Variable)> = new_names - .into_iter() - .map(|name| (name, name_to_rigid(uf, name))) - .collect(); - - let mut new_rtv = rtv.clone(); - for (name, var) in &new_rigids { - new_rtv.insert(name, bump.alloc(Type::VarN(*var))); - } - - (new_rigids, new_rtv) -} - -// CONSTRAIN RECURSIVE DEFS - -struct Info<'a> { - definitions: Vec>, - vars: Vec, - cons: Vec>, - headers: pattern::Header<'a>, -} - -impl<'a> Info<'a> { - fn empty() -> Info<'a> { - Info { - definitions: Vec::new(), - vars: Vec::new(), - cons: Vec::new(), - headers: pattern::Header::new(), - } - } -} - -pub fn constrain_recursive_defs<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - defs: &[&CanDef<'a>], - body_con: Constraint<'a>, -) -> Constraint<'a> { - let mut rigid_info = Info::empty(); - let mut flex_info = Info::empty(); - - for def in defs { - match def { - CanDef::Def { name, args, body } => { - let Args { - vars: new_flex_vars, - tipe, - result_type, - state, - } = constrain_args(bump, uf, args); - - let expr_con = constrain(bump, uf, rtv, body, Expected::NoExpectation(result_type)); - - let def_con = Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(state.vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(reversed_and(bump, state.rev_cons)), - body_con: bump.alloc(expr_con), - }; - - // All recursive headers share one rank until every body has - // been checked. Introducing an earlier header's variables in a - // later definition's pattern scope can generalize them early. - flex_info.vars.extend(new_flex_vars); - flex_info.cons.push(def_con); - flex_info.definitions.push(Definition { - site: type_::Binder::Named(name), - typ: tipe, - context: None, - }); - flex_info - .headers - .insert(name.value, Located::at(name.region, tipe)); - } - - CanDef::TypedDef { - name, - free_vars, - context, - args, - body, - typ: src_result_type, - .. - } => { - let (new_rigids, new_rtv) = make_rigids(bump, uf, rtv, free_vars); - - let TypedArgs { - tipe, - result_type, - state, - } = constrain_typed_args(bump, uf, &new_rtv, name.value, args, src_result_type); - - let expr_con = constrain( - bump, - uf, - &new_rtv, - body, - Expected::FromAnnotation( - name.value, - args.len(), - SubContext::TypedBody, - result_type, - ), - ); - - let def_con = Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(state.vars), - header: header_slice(bump, state.headers), - header_con: bump.alloc(reversed_and(bump, state.rev_cons)), - body_con: bump.alloc(expr_con), - }; - - let given = instantiate::from_src_context(bump, &new_rtv, context); - - // Elm prepends each def's rigids: latest def first, names - // sorted within a def. - let mut vars: Vec = new_rigids.iter().map(|(_, var)| *var).collect(); - vars.append(&mut rigid_info.vars); - rigid_info.vars = vars; - rigid_info.definitions.push(Definition { - site: type_::Binder::Named(name), - typ: tipe, - context: Some(given), - }); - rigid_info.cons.push(Constraint::Let { - declarations: &[], - given, - binder: Some(type_::Binder::Named(name)), - definitions: bump.alloc_slice_copy(&[Definition { - site: type_::Binder::Named(name), - typ: tipe, - context: Some(given), - }]), - rigid_vars: bump.alloc_slice_fill_iter(new_rigids.iter().map(|(_, var)| *var)), - flex_vars: &[], - header: &[], - header_con: bump.alloc(def_con), - body_con: bump.alloc(Constraint::True), - }); - rigid_info - .headers - .insert(name.value, Located::at(name.region, tipe)); - } - } - } - - // Elm builds the cons lists by prepending, so they end up latest-first. - rigid_info.cons.reverse(); - flex_info.cons.reverse(); - - let flex_headers = header_slice(bump, flex_info.headers); - let flex_definitions = bump.alloc_slice_fill_iter(flex_info.definitions); - Constraint::Let { - declarations: bump.alloc_slice_fill_iter(rigid_info.definitions), - given: &[], - binder: None, - definitions: &[], - rigid_vars: bump.alloc_slice_fill_iter(rigid_info.vars), - flex_vars: &[], - header: header_slice(bump, rigid_info.headers), - header_con: bump.alloc(Constraint::True), - body_con: bump.alloc(Constraint::Let { - declarations: &[], - given: &[], - binder: flex_definitions.first().map(|def| def.site), - definitions: flex_definitions, - rigid_vars: &[], - flex_vars: bump.alloc_slice_fill_iter(flex_info.vars), - header: flex_headers, - header_con: bump.alloc(Constraint::Let { - declarations: flex_definitions, - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars: &[], - header: flex_headers, - header_con: bump.alloc(Constraint::True), - body_con: bump.alloc(c_and(bump, flex_info.cons)), - }), - body_con: bump.alloc(c_and(bump, vec![c_and(bump, rigid_info.cons), body_con])), - }), - } -} - -// CONSTRAIN ARGS - -struct Args<'a> { - vars: Vec, - tipe: &'a Type<'a>, - result_type: &'a Type<'a>, - state: pattern::State<'a>, -} - -fn constrain_args<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - args: &[&Located>], -) -> Args<'a> { - args_help(bump, uf, args, pattern::empty_state()) -} - -fn args_help<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - args: &[&Located>], - state: pattern::State<'a>, -) -> Args<'a> { - let mut arg_vars = Vec::with_capacity(args.len()); - let mut arg_types: Vec<&'a Type<'a>> = Vec::with_capacity(args.len()); - - let mut state = state; - for arg_pattern in args { - let arg_var = mk_flex_var(uf); - let arg_type: &'a Type<'a> = bump.alloc(Type::VarN(arg_var)); - state = pattern::add( - bump, - uf, - arg_pattern, - PExpected::NoExpectation(arg_type), - state, - ); - arg_vars.push(arg_var); - arg_types.push(arg_type); - } - - let result_var = mk_flex_var(uf); - let result_type: &'a Type<'a> = bump.alloc(Type::VarN(result_var)); - - let tipe = arg_types.iter().rev().fold(result_type, |acc, arg_type| { - bump.alloc(Type::FunN(arg_type, acc)) - }); - - let mut vars = arg_vars; - vars.push(result_var); - - Args { - vars, - tipe, - result_type, - state, - } -} - -// CONSTRAIN TYPED ARGS - -struct TypedArgs<'a> { - tipe: &'a Type<'a>, - result_type: &'a Type<'a>, - state: pattern::State<'a>, -} - -fn constrain_typed_args<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - rtv: &Rtv<'a>, - name: &'a str, - args: &[TypedPattern<'a>], - src_result_type: &Located>, -) -> TypedArgs<'a> { - let mut state = pattern::empty_state(); - let mut arg_types: Vec<&'a Type<'a>> = Vec::with_capacity(args.len()); - - for (index, arg) in args.iter().enumerate() { - let arg_type = instantiate::from_src_type(bump, rtv, arg.typ); - let expected = PExpected::FromContext( - arg.pattern.region, - PContext::TypedArg(name, index), - arg_type, - ); - state = pattern::add(bump, uf, arg.pattern, expected, state); - arg_types.push(arg_type); - } - - let result_type = instantiate::from_src_type(bump, rtv, src_result_type); - - let tipe = arg_types.iter().rev().fold(result_type, |acc, arg_type| { - bump.alloc(Type::FunN(arg_type, acc)) - }); - - TypedArgs { - tipe, - result_type, - state, - } -} - -#[cfg(test)] -mod node_tests { - use super::*; - use nash_ast::{Annotation, ModuleName, QualifiedName}; - - #[test] - fn predicate_instantiation_preserves_order_and_captured_variables() { - let bump = Bump::new(); - let mut uf = UnionFind::new(); - let captured = name_to_rigid(&mut uf, "captured"); - let rtv = Rtv::from([("captured", &*bump.alloc(Type::VarN(captured)))]); - let names = ["z", "captured", "a"]; - let (rigids, scope) = make_rigids(&bump, &mut uf, &rtv, &names); - assert_eq!( - rigids.iter().map(|(name, _)| *name).collect::>(), - ["a", "z"] - ); - let variables = names.map(|name| &*bump.alloc(Located::at_zero(nash_ast::Type::Var(name)))); - let context = [nash_ast::Pred::Apply { - head: variables[0], - args: bump.alloc_slice_copy(&variables[1..]), - }]; - let extract = |scope: &Rtv<'_>| { - instantiate::from_src_context(&bump, scope, &context)[0] - .types() - .map(|typ| { - let Type::VarN(variable) = typ else { - panic!("predicate variable") - }; - *variable - }) - .collect::>() - }; - let signature = extract(&scope); - assert_eq!(signature, [rigids[1].1, captured, rigids[0].1]); - let (_, other_scope) = make_rigids(&bump, &mut uf, &rtv, &names); - let other = extract(&other_scope); - assert_eq!(other[1], captured); - assert_ne!(other[0], signature[0]); - assert_ne!(other[2], signature[2]); - } - - #[test] - fn literals_and_patterns_keep_original_nodes_and_ordered_predicates() { - let bump = Bump::new(); - for (expr, pattern, trait_name) in [ - (CanExpr::Int(7), nash_ast::Pattern::Int(7), "FromInt"), - ( - CanExpr::Bytes(&[0, 255]), - nash_ast::Pattern::Bytes(&[0, 255]), - "FromBytes", - ), - ( - CanExpr::Str("nash"), - nash_ast::Pattern::Str("nash"), - "FromString", - ), - ] { - let expr = bump.alloc(Located::at_zero(expr)); - let pattern = bump.alloc(Located::at_zero(pattern)); - let mut uf = UnionFind::new(); - let expected = bump.alloc(Type::VarN(mk_flex_var(&mut uf))); - let constraint = constrain( - &bump, - &mut uf, - &Rtv::new(), - expr, - Expected::NoExpectation(expected), - ); - let Constraint::Foreign(_, node, _, annotation, _) = constraint else { - panic!("literal scheme") - }; - assert_eq!(node, NodeId::expr(expr)); - assert_eq!(annotation.context.len(), 1); - assert_eq!(annotation.context[0].trait_ref().unwrap().name, trait_name); - assert_eq!( - annotation.context[0].trait_ref().unwrap().home.package, - Some(nash_ast::primitives::CORE) - ); - let state = pattern::add( - &bump, - &mut uf, - pattern, - PExpected::NoExpectation(expected), - pattern::empty_state(), - ); - let annotations: Vec<_> = state - .rev_cons - .iter() - .filter_map(|c| match c { - Constraint::Foreign(_, node, _, annotation, _) => { - assert_eq!(*node, NodeId::pattern(pattern)); - Some(annotation) - } - _ => None, - }) - .collect(); - assert_eq!(annotations.len(), 1, "one evidence instance per pattern"); - assert_eq!( - annotations[0] - .context - .iter() - .map(|p| p.trait_ref().unwrap().name) - .collect::>(), - [trait_name, "Eq"] - ); - assert!(std::ptr::eq( - annotations[0].context[0].args()[0], - annotations[0].context[1].args()[0] - )); - } - } - - fn uses(constraint: &Constraint<'_>, nodes: &mut Vec) { - match constraint { - Constraint::Local(_, node, ..) | Constraint::Foreign(_, node, ..) => nodes.push(*node), - Constraint::And(constraints) => { - for constraint in *constraints { - uses(constraint, nodes); - } - } - Constraint::Let { - header_con, - body_con, - .. - } => { - uses(header_con, nodes); - uses(body_con, nodes); - } - _ => {} - } - } - - #[test] - fn operator_and_method_uses_keep_distinct_node_identity_at_the_same_region() { - let bump = Bump::new(); - let region = Region::zero(); - let reference = QualifiedName { - home: ModuleName { - package: None, - name: "Main", - }, - name: "Combine", - }; - let annotation = bump.alloc(Annotation { - context: &[], - free_vars: &[], - typ: bump.alloc(Located::at(region, nash_ast::Type::unit())), - }); - let local = bump.alloc(Located::at(region, CanExpr::VarLocal("x"))); - let method = bump.alloc(Located::at( - region, - CanExpr::VarMethod { - trait_: reference, - method: "combine", - annotation, - }, - )); - let operator = bump.alloc(Located::at( - region, - CanExpr::Binop { - symbol: "+", - operator_home: reference.home, - reference, - annotation, - left: local, - right: method, - }, - )); - let mut uf = UnionFind::new(); - let constraint = constrain( - &bump, - &mut uf, - &Rtv::new(), - operator, - Expected::NoExpectation(bump.alloc(crate::type_::unit())), - ); - let mut nodes = Vec::new(); - uses(&constraint, &mut nodes); - assert_eq!(nodes.len(), 3); - for expression in [operator as &Located>, local, method] { - assert_eq!( - nodes - .iter() - .filter(|node| **node == NodeId::expr(expression)) - .count(), - 1 - ); - } - } -} diff --git a/crates/nash-constrain/src/instantiate.rs b/crates/nash-constrain/src/instantiate.rs index 18540a23..dc2e8929 100644 --- a/crates/nash-constrain/src/instantiate.rs +++ b/crates/nash-constrain/src/instantiate.rs @@ -1,137 +1,12 @@ -//! Port of Elm's `Type.Instantiate`: turn a canonical type into an -//! inference `Type`, substituting free type variables. +//! Instantiate canonical types directly into union-find variables. use std::collections::BTreeMap; +#[cfg(test)] use bumpalo::Bump; -use nash_ast::{AliasType as CanAliasType, Type as CanType}; +use nash_ast::Type as CanType; use nash_region::Located; -use crate::type_::Type; - -pub type FreeVars<'a> = BTreeMap<&'a str, &'a Type<'a>>; - -/// Instantiate the complete predicate context with the same lexical map as its type. -pub fn from_src_context<'a>( - bump: &'a Bump, - rtv: &FreeVars<'a>, - context: &[nash_ast::Pred<'a>], -) -> &'a [crate::type_::Pred<'a>] { - bump.alloc_slice_fill_iter(context.iter().map(|pred| { - let args = - bump.alloc_slice_fill_iter(pred.args().iter().map(|arg| from_src_type(bump, rtv, arg))); - match *pred { - nash_ast::Pred::Trait { trait_, .. } => crate::type_::Pred::Trait { - trait_, - args, - hidden: false, - }, - nash_ast::Pred::Implied { trait_, .. } => crate::type_::Pred::Trait { - trait_, - args, - hidden: true, - }, - nash_ast::Pred::Apply { head, .. } => crate::type_::Pred::Apply { - head: from_src_type(bump, rtv, head), - args, - }, - } - })) -} - -pub fn from_src_type<'a>( - bump: &'a Bump, - free_vars: &FreeVars<'a>, - src_type: &Located>, -) -> &'a Type<'a> { - match &src_type.value { - CanType::App { head, args } => bump.alloc(Type::AppVarN( - from_src_type(bump, free_vars, head), - bump.alloc_slice_fill_iter(args.iter().map(|arg| from_src_type(bump, free_vars, arg))), - )), - CanType::Lambda { from, to } => bump.alloc(Type::FunN( - from_src_type(bump, free_vars, from), - from_src_type(bump, free_vars, to), - )), - - CanType::Var(name) => free_vars - .get(name) - .expect("canonical types only mention their free variables"), - - CanType::Named { reference, args } => bump.alloc(Type::AppN { - home: reference.home, - name: reference.name, - args: bump - .alloc_slice_fill_iter(args.iter().map(|arg| from_src_type(bump, free_vars, arg))), - }), - - CanType::Alias { - reference, - arguments, - remaining, - target, - } => { - let targs = bump.alloc_slice_fill_iter( - arguments - .iter() - .map(|arg| (arg.name, from_src_type(bump, free_vars, arg.typ))), - ); - let body = match target { - CanAliasType::Open(body) | CanAliasType::Filled { body, .. } => *body, - }; - if !remaining.is_empty() { - assert!( - matches!(target, CanAliasType::Open(_)), - "partial alias body must be closed" - ); - return bump.alloc(Type::PartialAliasN { - home: reference.home, - name: reference.name, - args: targs, - remaining, - body, - }); - } - let real = match target { - CanAliasType::Filled { typ: real_type, .. } => { - from_src_type(bump, free_vars, real_type) - } - CanAliasType::Open(real_type) => { - let arg_vars: FreeVars<'a> = targs.iter().copied().collect(); - from_src_type(bump, &arg_vars, real_type) - } - }; - bump.alloc(Type::AliasN { - home: reference.home, - name: reference.name, - args: targs, - real, - body, - }) - } - - CanType::Tuple { - first, - second, - rest, - } => bump.alloc(Type::TupleN( - from_src_type(bump, free_vars, first), - from_src_type(bump, free_vars, second), - bump.alloc_slice_fill_iter( - rest.iter().map(|item| from_src_type(bump, free_vars, item)), - ), - )), - - CanType::Record { fields } => bump.alloc(Type::RecordN { - fields: bump.alloc_slice_fill_iter( - fields - .iter() - .map(|field| (field.field, from_src_type(bump, free_vars, field.typ))), - ), - }), - } -} - use crate::{Content, FlatType, UnionFind, Variable}; /// A nominal alias application inspected without allocating inference variables. diff --git a/crates/nash-constrain/src/lib.rs b/crates/nash-constrain/src/lib.rs index dd248e4d..619a27ed 100644 --- a/crates/nash-constrain/src/lib.rs +++ b/crates/nash-constrain/src/lib.rs @@ -1,25 +1,15 @@ -//! Constraint generation for nash type inference: a port of Elm's -//! `Type.Constrain.*` plus the shared vocabulary from `Type.Type`, -//! `Type.UnionFind`, `Type.Error`, and `Reporting.Error.Type`. -//! -//! Where Elm creates unification variables in ambient `IO`, nash threads an -//! explicit [`UnionFind`] store: `constrain` fills it with fresh variables -//! and `nash-solve` mutates it while solving the returned [`Constraint`]. +//! Shared union-find types, canonical instantiation, and inference diagnostics. pub mod error; pub mod error_type; pub mod instantiate; -pub mod pattern; pub mod type_; -mod expression; -mod module; mod union_find; pub use crate::error::{ Category, Context, Error, Expected, MaybeName, PCategory, PContext, PExpected, SubContext, }; pub use crate::error_type::ErrorType; -pub use crate::module::constrain; -pub use crate::type_::{Constraint, Content, Descriptor, FlatType, Mark, Type}; +pub use crate::type_::{Content, Descriptor, FlatType, Mark}; pub use crate::union_find::{UnionFind, Variable}; diff --git a/crates/nash-constrain/src/module.rs b/crates/nash-constrain/src/module.rs deleted file mode 100644 index 77f42615..00000000 --- a/crates/nash-constrain/src/module.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Port of Elm's `Type.Constrain.Module`. -//! -//! Nash has no ports or effect managers, so this is just the declaration -//! walk terminated by `CSaveTheEnvironment`. - -use bumpalo::Bump; -use nash_ast::{Decls, Module as CanModule}; - -use crate::expression; -use crate::type_::Constraint; -use crate::union_find::UnionFind; - -pub fn constrain<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - module: &CanModule<'a>, -) -> Constraint<'a> { - let definitions = module - .traits - .iter() - .flat_map(|trait_| { - trait_ - .value - .methods - .iter() - .filter_map(|method| method.default) - }) - .chain( - module - .impls - .iter() - .flat_map(|impl_| impl_.value.methods.iter().copied()), - ); - let mut methods: Vec<_> = definitions - .map(|definition| expression::constrain_method(bump, uf, definition)) - .collect(); - methods.push(Constraint::SaveTheEnvironment); - constrain_decls( - bump, - uf, - module.decls, - Constraint::And(bump.alloc_slice_fill_iter(methods)), - ) -} - -fn constrain_decls<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - decls: &Decls<'a>, - final_constraint: Constraint<'a>, -) -> Constraint<'a> { - match decls { - Decls::Declare { definition, next } => { - let next_con = constrain_decls(bump, uf, next, final_constraint); - expression::constrain_def(bump, uf, &expression::Rtv::new(), definition, next_con) - } - - Decls::DeclareRec { - definition, - following, - next, - } => { - let next_con = constrain_decls(bump, uf, next, final_constraint); - let mut defs = Vec::with_capacity(1 + following.len()); - defs.push(*definition); - defs.extend(following.iter().copied()); - expression::constrain_recursive_defs(bump, uf, &expression::Rtv::new(), &defs, next_con) - } - - Decls::Empty => final_constraint, - } -} diff --git a/crates/nash-constrain/src/pattern.rs b/crates/nash-constrain/src/pattern.rs deleted file mode 100644 index 5682f73e..00000000 --- a/crates/nash-constrain/src/pattern.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Port of Elm's `Type.Constrain.Pattern`: turn a canonical pattern into -//! binding headers plus the constraints its structure implies. - -use std::collections::BTreeMap; - -use bumpalo::Bump; -use nash_ast::{Pattern as CanPattern, PatternCtor}; -use nash_region::{Located, Region}; - -use crate::error::{Expected, PCategory, PContext, PExpected}; -use crate::instantiate; -use crate::type_::{self, Constraint, Type, mk_flex_var, name_to_flex}; -use crate::union_find::{UnionFind, Variable}; - -/// Elm's `Pattern.State`. Constraints are stored in reverse order so that -/// adding one is O(1); callers reverse when building the final `CLet`. -pub struct State<'a> { - pub headers: Header<'a>, - pub vars: Vec, - pub rev_cons: Vec>, -} - -pub type Header<'a> = BTreeMap<&'a str, Located<&'a Type<'a>>>; - -pub fn empty_state<'a>() -> State<'a> { - State { - headers: BTreeMap::new(), - vars: Vec::new(), - rev_cons: Vec::new(), - } -} - -pub fn add<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - pattern: &Located>, - expectation: PExpected<'a, &'a Type<'a>>, - state: State<'a>, -) -> State<'a> { - let region = pattern.region; - match &pattern.value { - CanPattern::Anything => state, - - CanPattern::Var(name) => add_to_headers(region, name, expectation, state), - - CanPattern::Alias { - pattern: real_pattern, - name, - } => { - let state = add_to_headers(region, name, expectation, state); - add(bump, uf, real_pattern, expectation, state) - } - - CanPattern::Unit => { - let mut state = state; - let unit_con = Constraint::Pattern( - region, - PCategory::Unit, - bump.alloc(crate::type_::unit()), - expectation, - ); - state.rev_cons.push(unit_con); - state - } - - CanPattern::Tuple { - first, - second, - rest, - } => add_tuple(bump, uf, region, first, second, rest, expectation, state), - - CanPattern::Constructor(ctor) => add_ctor(bump, uf, region, ctor, expectation, state), - - CanPattern::List(patterns) => { - let entry_var = mk_flex_var(uf); - let entry_type: &'a Type<'a> = bump.alloc(Type::VarN(entry_var)); - let list_type: &'a Type<'a> = bump.alloc(type_::list(bump, entry_type)); - - let mut state = - patterns - .iter() - .enumerate() - .fold(state, |state, (index, entry_pattern)| { - let expectation = - PExpected::FromContext(region, PContext::ListEntry(index), entry_type); - add(bump, uf, entry_pattern, expectation, state) - }); - - let list_con = Constraint::Pattern(region, PCategory::List, list_type, expectation); - state.vars.push(entry_var); - state.rev_cons.push(list_con); - state - } - - CanPattern::Cons { head, tail } => { - let entry_var = mk_flex_var(uf); - let entry_type: &'a Type<'a> = bump.alloc(Type::VarN(entry_var)); - let list_type: &'a Type<'a> = bump.alloc(type_::list(bump, entry_type)); - - let head_expectation = PExpected::NoExpectation(entry_type); - let tail_expectation = PExpected::FromContext(region, PContext::Tail, list_type); - - let state = add(bump, uf, tail, tail_expectation, state); - let mut state = add(bump, uf, head, head_expectation, state); - - let list_con = Constraint::Pattern(region, PCategory::List, list_type, expectation); - state.vars.push(entry_var); - state.rev_cons.push(list_con); - state - } - - CanPattern::Record(fields) => { - let mut state = state; - let record_var = mk_flex_var(uf); - let record_type: &'a Type<'a> = bump.alloc(Type::VarN(record_var)); - state.vars.push(record_var); - state.rev_cons.push(Constraint::Pattern( - region, - PCategory::Record, - record_type, - expectation, - )); - if fields.is_empty() { - state.rev_cons.push(Constraint::Record { - region, - context: type_::FieldContext::Pattern, - record: record_type, - }); - } - for field in *fields { - let var = mk_flex_var(uf); - let field_type: &'a Type<'a> = bump.alloc(Type::VarN(var)); - state.vars.push(var); - state - .headers - .entry(field) - .or_insert_with(|| Located::at(region, field_type)); - state.rev_cons.push(Constraint::Field { - region, - context: type_::FieldContext::Pattern, - record: record_type, - field, - field_type, - }); - } - state - } - - CanPattern::Int(_) | CanPattern::Str(_) | CanPattern::Bytes(_) => { - let (trait_name, category) = match pattern.value { - CanPattern::Int(_) => ("FromInt", PCategory::Int), - CanPattern::Bytes(_) => ("FromBytes", PCategory::Bytes), - _ => ("FromString", PCategory::Str), - }; - let mut state = state; - let var = mk_flex_var(uf); - let typ: &'a Type<'a> = bump.alloc(Type::VarN(var)); - state.vars.push(var); - state - .rev_cons - .push(Constraint::Pattern(region, category, typ, expectation)); - state.rev_cons.push(Constraint::Foreign( - region, - nash_ast::NodeId::pattern(pattern), - "literal", - type_::literal_annotation( - bump, - &[type_::literal_trait(trait_name), type_::eq_trait()], - ), - Expected::NoExpectation(typ), - )); - state - } - - CanPattern::Bool { .. } => { - let mut state = state; - let bool_con = Constraint::Pattern( - region, - PCategory::Bool, - bump.alloc(type_::bool()), - expectation, - ); - state.rev_cons.push(bool_con); - state - } - } -} - -// STATE HELPERS - -fn add_to_headers<'a>( - region: Region, - name: &'a str, - expectation: PExpected<'a, &'a Type<'a>>, - mut state: State<'a>, -) -> State<'a> { - let tipe = get_type(expectation); - state.headers.insert(name, Located::at(region, tipe)); - state -} - -fn get_type<'a>(expectation: PExpected<'a, &'a Type<'a>>) -> &'a Type<'a> { - match expectation { - PExpected::NoExpectation(tipe) => tipe, - PExpected::FromContext(_, _, tipe) => tipe, - } -} - -// CONSTRAIN TUPLE - -#[allow(clippy::too_many_arguments)] -fn add_tuple<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - region: Region, - a: &Located>, - b: &Located>, - rest: &[&Located>], - expectation: PExpected<'a, &'a Type<'a>>, - state: State<'a>, -) -> State<'a> { - let a_var = mk_flex_var(uf); - let b_var = mk_flex_var(uf); - let a_type: &'a Type<'a> = bump.alloc(Type::VarN(a_var)); - let b_type: &'a Type<'a> = bump.alloc(Type::VarN(b_var)); - - let state = simple_add(bump, uf, a, a_type, state); - let mut state = simple_add(bump, uf, b, b_type, state); - state.vars.extend([a_var, b_var]); - let mut types = Vec::with_capacity(rest.len()); - for item in rest { - let var = mk_flex_var(uf); - let tipe: &'a Type<'a> = bump.alloc(Type::VarN(var)); - state = simple_add(bump, uf, item, tipe, state); - state.vars.push(var); - types.push(tipe); - } - state.rev_cons.push(Constraint::Pattern( - region, - PCategory::Tuple, - bump.alloc(Type::TupleN(a_type, b_type, bump.alloc_slice_copy(&types))), - expectation, - )); - state -} - -fn simple_add<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - pattern: &Located>, - pattern_type: &'a Type<'a>, - state: State<'a>, -) -> State<'a> { - add( - bump, - uf, - pattern, - PExpected::NoExpectation(pattern_type), - state, - ) -} - -// CONSTRAIN CONSTRUCTORS - -fn add_ctor<'a>( - bump: &'a Bump, - uf: &mut UnionFind<'a>, - region: Region, - ctor: &PatternCtor<'a>, - expectation: PExpected<'a, &'a Type<'a>>, - state: State<'a>, -) -> State<'a> { - let home = ctor.reference.home; - let type_name = ctor.reference.union; - let ctor_name = ctor.reference.name; - - let var_pairs: Vec<(&'a str, Variable)> = ctor - .union - .parameters - .iter() - .map(|var| (*var, name_to_flex(uf, var))) - .collect(); - let type_pairs: Vec<(&'a str, &'a Type<'a>)> = var_pairs - .iter() - .map(|(name, var)| (*name, &*bump.alloc(Type::VarN(*var)))) - .collect(); - let free_var_dict: instantiate::FreeVars<'a> = type_pairs.iter().copied().collect(); - - let mut state = ctor.arguments.iter().fold(state, |state, arg| { - let tipe = instantiate::from_src_type(bump, &free_var_dict, arg.typ); - let arg_expectation = PExpected::FromContext( - region, - PContext::CtorArg(ctor_name, arg.index as usize), - tipe, - ); - add(bump, uf, arg.pattern, arg_expectation, state) - }); - - let ctor_type: &'a Type<'a> = bump.alloc(Type::AppN { - home, - name: type_name, - args: bump.alloc_slice_fill_iter(type_pairs.iter().map(|(_, typ)| *typ)), - }); - let ctor_con = Constraint::Pattern(region, PCategory::Ctor(ctor_name), ctor_type, expectation); - - state.vars.extend(var_pairs.iter().map(|(_, var)| *var)); - state.rev_cons.push(ctor_con); - state -} diff --git a/crates/nash-constrain/src/type_.rs b/crates/nash-constrain/src/type_.rs index 353d4958..ec983f5b 100644 --- a/crates/nash-constrain/src/type_.rs +++ b/crates/nash-constrain/src/type_.rs @@ -1,5 +1,4 @@ -//! Port of the data half of Elm's `Type.Type`: constraints, the inference -//! `Type` language, and unification variable descriptors. +//! Union-find type descriptors, scheme identities, and literal annotations. //! //! `toAnnotation` and `toErrorType` live in `nash-solve` (they are only //! called by the solver and need `nash-can`'s canonical-type utilities). @@ -9,35 +8,10 @@ use std::collections::BTreeMap; use nash_ast::{Annotation, ModuleName, NodeId, QualifiedName}; use nash_region::{Located, Region}; -use crate::error::{Category, Expected, PCategory, PExpected}; use crate::union_find::{UnionFind, Variable}; // CONSTRAINTS -/// An annotation predicate instantiated over the definition's rigid variables. -#[derive(Clone, Copy, Debug)] -pub enum Pred<'a> { - Trait { - trait_: QualifiedName<'a>, - args: &'a [&'a Type<'a>], - hidden: bool, - }, - Apply { - head: &'a Type<'a>, - args: &'a [&'a Type<'a>], - }, -} - -impl<'a> Pred<'a> { - pub fn types(self) -> impl Iterator> { - let (head, args) = match self { - Self::Trait { args, .. } => (None, args), - Self::Apply { head, args } => (Some(head), args), - }; - head.into_iter().chain(args.iter().copied()) - } -} - /// Scheme identity is an original definition name or a destructuring pattern. #[derive(Clone, Copy, Debug)] pub enum Binder<'a> { @@ -63,92 +37,6 @@ impl<'a> Binder<'a> { } } -/// Preserve the original scheme identity and full type independently of lexical scope. -#[derive(Clone, Copy, Debug)] -pub struct Definition<'a> { - pub site: Binder<'a>, - pub typ: &'a Type<'a>, - /// `Some`, including an empty slice, distinguishes a declared scheme. - pub context: Option<&'a [Pred<'a>]>, -} - -/// Elm's `Type.Constraint`. Allocated in a bump arena, so collections are -/// slices, not owned containers. -#[derive(Debug)] -pub enum Constraint<'a> { - Record { - region: Region, - context: FieldContext<'a>, - record: &'a Type<'a>, - }, - Field { - region: Region, - context: FieldContext<'a>, - record: &'a Type<'a>, - field: &'a str, - field_type: &'a Type<'a>, - }, - True, - SaveTheEnvironment, - Equal( - Region, - Category<'a>, - &'a Type<'a>, - Expected<'a, &'a Type<'a>>, - ), - Local(Region, NodeId, &'a str, Expected<'a, &'a Type<'a>>), - Foreign( - Region, - NodeId, - &'a str, - &'a Annotation<'a>, - Expected<'a, &'a Type<'a>>, - ), - Pattern( - Region, - PCategory<'a>, - &'a Type<'a>, - PExpected<'a, &'a Type<'a>>, - ), - And(&'a [Constraint<'a>]), - Let { - /// Recursive binding identities published before checking group bodies. - /// Annotated declarations also supply their final contexts immediately. - declarations: &'a [Definition<'a>], - /// Assumed while checking the definition body, over its rigid variables. - given: &'a [Pred<'a>], - /// Evidence owner; the first untyped member for a recursive group. - binder: Option>, - /// All definitions generalized here, even when no lexical name is bound. - definitions: &'a [Definition<'a>], - rigid_vars: &'a [Variable], - flex_vars: &'a [Variable], - /// Name-sorted, mirroring Elm's `Map.Map Name (A.Located Type)`. - header: &'a [(&'a str, Located<&'a Type<'a>>)], - header_con: &'a Constraint<'a>, - body_con: &'a Constraint<'a>, - }, -} - -/// Elm's `exists`: a `CLet` binding only flex variables. -pub fn exists<'a>( - bump: &'a bumpalo::Bump, - flex_vars: &'a [Variable], - constraint: Constraint<'a>, -) -> Constraint<'a> { - Constraint::Let { - declarations: &[], - given: &[], - binder: None, - definitions: &[], - rigid_vars: &[], - flex_vars, - header: &[], - header_con: bump.alloc(constraint), - body_con: bump.alloc(Constraint::True), - } -} - // TYPE PRIMITIVES #[derive(Clone, Copy, Debug)] @@ -175,38 +63,6 @@ pub enum FlatType<'a> { Tuple1(Variable, Variable, Vec), } -/// Elm's `Type.Type`: the language the constraint generator writes types in. -#[derive(Clone, Copy, Debug)] -pub enum Type<'a> { - PartialAliasN { - home: ModuleName<'a>, - name: &'a str, - args: &'a [(&'a str, &'a Type<'a>)], - remaining: &'a [&'a str], - body: &'a Located>, - }, - AppVarN(&'a Type<'a>, &'a [&'a Type<'a>]), - AliasN { - home: ModuleName<'a>, - name: &'a str, - args: &'a [(&'a str, &'a Type<'a>)], - real: &'a Type<'a>, - body: &'a Located>, - }, - VarN(Variable), - AppN { - home: ModuleName<'a>, - name: &'a str, - args: &'a [&'a Type<'a>], - }, - FunN(&'a Type<'a>, &'a Type<'a>), - /// Name-sorted, mirroring Elm's `Map.Map Name Type`. - RecordN { - fields: &'a [(&'a str, &'a Type<'a>)], - }, - TupleN(&'a Type<'a>, &'a Type<'a>, &'a [&'a Type<'a>]), -} - /// Flatten application spines whose heads inference has already determined. /// This does not bind unknown heads or expand aliases. pub fn normalize_application<'a>(uf: &mut UnionFind<'a>, term: FlatType<'a>) -> FlatType<'a> { @@ -339,7 +195,7 @@ pub fn literal_annotation<'a>( } /// Only the compiler-known literal traits select a little default type. -pub fn literal_default(trait_: nash_ast::QualifiedName<'_>) -> Option> { +pub fn literal_default(trait_: nash_ast::QualifiedName<'_>) -> Option> { if trait_.home.package != Some(nash_ast::primitives::CORE) || trait_.home.name != "Literal" { return None; } @@ -349,35 +205,11 @@ pub fn literal_default(trait_: nash_ast::QualifiedName<'_>) -> Option "bytes", _ => return None, }; - Some(Type::AppN { - home: nash_ast::primitives::builtin_home(), + Some(FlatType::App1( + nash_ast::primitives::builtin_home(), name, - args: &[], - }) -} - -pub fn list<'a>(bump: &'a bumpalo::Bump, element: &'a Type<'a>) -> Type<'a> { - Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "list", - args: bump.alloc_slice_copy(&[element]), - } -} - -pub const fn unit<'a>() -> Type<'a> { - Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "unit", - args: &[], - } -} - -pub const fn bool<'a>() -> Type<'a> { - Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "bool", - args: &[], - } + Vec::new(), + )) } // MAKE FLEX VARIABLES diff --git a/crates/nash-constrain/tests/predicate_instantiation.rs b/crates/nash-constrain/tests/predicate_instantiation.rs deleted file mode 100644 index 6b74326f..00000000 --- a/crates/nash-constrain/tests/predicate_instantiation.rs +++ /dev/null @@ -1,51 +0,0 @@ -use bumpalo::Bump; -use nash_ast::{Pred, Type as Canonical, primitives::ReprTrait}; -use nash_constrain::{Type, UnionFind, instantiate, type_}; -use nash_region::Located; -use std::collections::BTreeMap; - -#[test] -fn apply_head_and_arguments_share_the_signature_substitution() { - let bump = Bump::new(); - let mut uf = UnionFind::new(); - let f = type_::mk_flex_var(&mut uf); - let a = type_::mk_flex_var(&mut uf); - let f_type = &*bump.alloc(Type::VarN(f)); - let a_type = &*bump.alloc(Type::VarN(a)); - let scope = BTreeMap::from([("f", f_type), ("a", a_type)]); - let head = &*bump.alloc(Located::at_zero(Canonical::Var("f"))); - let arg = &*bump.alloc(Located::at_zero(Canonical::Var("a"))); - let args = bump.alloc_slice_copy(&[arg]); - let context = [ - Pred::Apply { head, args }, - Pred::Implied { - trait_: ReprTrait::Big.qualified(), - args, - }, - ]; - let lowered = instantiate::from_src_context(&bump, &scope, &context); - let type_::Pred::Apply { - head: actual_head, - args: actual_args, - } = lowered[0] - else { - panic!("Apply preserved") - }; - assert!(std::ptr::eq(actual_head, f_type)); - assert!(std::ptr::eq(actual_args[0], a_type)); - let type_::Pred::Trait { hidden, args, .. } = lowered[1] else { - panic!("representation predicate preserved") - }; - assert!(hidden); - assert!(std::ptr::eq(args[0], a_type)); - let signature = Located::at_zero(Canonical::App { - head, - args: bump.alloc_slice_copy(&[arg]), - }); - let Type::AppVarN(type_head, type_args) = instantiate::from_src_type(&bump, &scope, &signature) - else { - panic!("type application preserved") - }; - assert!(std::ptr::eq(*type_head, actual_head)); - assert!(std::ptr::eq(type_args[0], actual_args[0])); -} diff --git a/crates/nash-driver/src/compile.rs b/crates/nash-driver/src/compile.rs index 617da8d5..e022a342 100644 --- a/crates/nash-driver/src/compile.rs +++ b/crates/nash-driver/src/compile.rs @@ -1,7 +1,7 @@ //! Module compilation orchestration. //! //! Each module runs Elm's full pipeline: parse -> canonicalize -> -//! constrain -> solve -> nitpick -> `Interface::from_module` with the solver's +//! direct inference -> nitpick -> `Interface::from_module` with the solver's //! annotations. Modules compile in dependency order, and each solved //! module and solved evidence remain in the build scope. Canonical nodes //! live in a shared arena, so interfaces borrow them without moving the @@ -345,9 +345,8 @@ fn compile_module<'s>( )] }; let mut uf = nash_constrain::UnionFind::new(); - let constraint = nash_constrain::constrain(bump, &mut uf, &can_result.module); - let (annotations, types) = match nash_solve::run(bump, &mut uf, &constraint, &can_result.tables) - { + let module = &can_result.module; + let (annotations, types) = match nash_solve::run(bump, &mut uf, module, &can_result.tables) { Ok(solved) => solved, Err(errors) => { return failed( diff --git a/crates/nash-driver/src/compile/nitpick_source_tests.rs b/crates/nash-driver/src/compile/nitpick_source_tests.rs index d04fdf8d..704ff36a 100644 --- a/crates/nash-driver/src/compile/nitpick_source_tests.rs +++ b/crates/nash-driver/src/compile/nitpick_source_tests.rs @@ -21,8 +21,8 @@ fn solve_source<'a>( ) .expect("source must canonicalize"); let mut uf = nash_constrain::UnionFind::new(); - let constraint = nash_constrain::constrain(bump, &mut uf, &can.module); - let (annotations, _) = nash_solve::run(bump, &mut uf, &constraint, &can.tables) + let module = &can.module; + let (annotations, _) = nash_solve::run(bump, &mut uf, module, &can.tables) .expect("source must type check before nitpick"); (bump.alloc(can.module), annotations) } diff --git a/crates/nash-report/src/pattern.rs b/crates/nash-report/src/pattern.rs index 881c39d6..c18cc3fb 100644 --- a/crates/nash-report/src/pattern.rs +++ b/crates/nash-report/src/pattern.rs @@ -108,8 +108,8 @@ mod tests { ) .expect("canonicalize"); let mut uf = nash_constrain::UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &can.module); - nash_solve::run(&bump, &mut uf, &constraint, &can.tables) + let module = &can.module; + nash_solve::run(&bump, &mut uf, module, &can.tables) .expect("solve before checking coverage"); nash_nitpick::check(&bump, &can.module) .expect_err("expected pattern errors") diff --git a/crates/nash-report/src/type_/tests.rs b/crates/nash-report/src/type_/tests.rs index 846c62bd..98bfcf65 100644 --- a/crates/nash-report/src/type_/tests.rs +++ b/crates/nash-report/src/type_/tests.rs @@ -567,8 +567,8 @@ fn type_error_reports(input: &str) -> String { let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &module) .expect("canonicalize fixture"); let mut uf = nash_constrain::UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let errors = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables) + let module = &canonical.module; + let errors = nash_solve::run(&bump, &mut uf, module, &canonical.tables) .expect_err("fixture must type-fail"); assert!(!errors.is_empty()); errors diff --git a/crates/nash-solve/src/lib.rs b/crates/nash-solve/src/lib.rs index 0b69cde8..2c6d3d5e 100644 --- a/crates/nash-solve/src/lib.rs +++ b/crates/nash-solve/src/lib.rs @@ -2,9 +2,9 @@ //! `Type.Unify`, and `Type.Occurs`, plus the `toAnnotation`/`toErrorType` //! half of `Type.Type`. //! -//! The driver runs `nash_constrain::constrain` to build a constraint tree -//! (filling a `UnionFind` store with fresh variables), then [`run`] to solve -//! it. Success returns the annotations used by `nash_can::from_module` +//! The driver passes the canonical module to [`run`], which infers directly +//! into the existing union-find and predicate stores. Success returns the +//! annotations used by `nash_can::from_module` //! together with definition schemes and use-site instances in [`SolvedTypes`]. mod annotation; diff --git a/crates/nash-solve/src/solve.rs b/crates/nash-solve/src/solve.rs index 6b7e2fb7..f3f32cea 100644 --- a/crates/nash-solve/src/solve.rs +++ b/crates/nash-solve/src/solve.rs @@ -1,5 +1,5 @@ -//! Port of Elm's `Type.Solve`: solve a constraint tree with rank-based -//! generalization, producing an annotation per top-level value. +//! Direct canonical AST inference with Elm's rank-based generalization, +//! producing annotations, definition schemes, and use-site evidence. use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -8,10 +8,15 @@ use nash_ast::Type as CanType; use nash_can::Annotations; use nash_constrain::error::{Category, Error, Expected, PExpected}; use nash_constrain::type_::{ - self, Constraint, Content, Descriptor, FlatType, Mark, NO_MARK, NO_RANK, OUTERMOST_RANK, Type, + self, Content, Descriptor, FlatType, Mark, NO_MARK, NO_RANK, OUTERMOST_RANK, }; use nash_constrain::{UnionFind, Variable}; -use nash_region::Located; +use nash_region::{Located, Region}; + +mod expressions; +mod infer; +mod patterns; +use infer::Definition; use crate::annotation::to_error_type; use crate::occurs; @@ -23,57 +28,22 @@ use crate::unify; pub fn run<'a>( bump: &'a Bump, uf: &mut UnionFind<'a>, - constraint: &Constraint<'a>, + module: &nash_ast::Module<'a>, tables: &nash_can::environment::Tables<'a>, ) -> Result<(Annotations<'a>, crate::SolvedTypes<'a>), Vec>> { - let mut solver = Solver { - bump, - tables, - pools: vec![Vec::new(); 8], - copied: Vec::new(), - predicates: Store::default(), - wanted: Vec::new(), - givens: Vec::new(), - schemes: Vec::new(), - recursive_uses: Vec::new(), - uses: Vec::new(), - owners: Vec::new(), - resolution_work: std::collections::HashMap::new(), - has_poison: false, - dependencies: crate::recovery::Dependencies::default(), - failed_lineages: BTreeSet::new(), - failed_predicates: BTreeSet::new(), - failed_definitions: std::collections::HashSet::new(), - value_roots: Vec::new(), - kind_contracts: Vec::new(), - kind_errors: Vec::new(), - fields: Vec::new(), - }; + let mut solver = Solver::new(bump, tables); - let mut state = solver.solve( + let state = solver.infer_module( uf, - &Env::new(), - OUTERMOST_RANK, + module, State { env: Env::new(), mark: NO_MARK.next(), errors: Vec::new(), }, - constraint, ); - solver.retry_fields(uf, OUTERMOST_RANK, &mut state.errors); - solver.finish_fields(uf, OUTERMOST_RANK, &mut state.errors); - state.errors.append(&mut solver.kind_errors); - if state.errors.is_empty() { - solver.finish(uf, &state.env) - } else { - // Elm accumulates errors by prepending; match its final order. - let mut errors = state.errors; - errors.extend(solver.final_errors(uf)); - errors.reverse(); - Err(errors) - } + solver.finish_state(uf, state) } // SOLVER @@ -174,6 +144,53 @@ struct Given<'a> { path: Vec, } +impl<'a, 'tables> Solver<'a, 'tables> { + fn new(bump: &'a Bump, tables: &'tables nash_can::environment::Tables<'a>) -> Self { + Self { + bump, + tables, + pools: vec![Vec::new(); 8], + copied: Vec::new(), + predicates: Store::default(), + wanted: Vec::new(), + givens: Vec::new(), + schemes: Vec::new(), + recursive_uses: Vec::new(), + uses: Vec::new(), + owners: Vec::new(), + resolution_work: std::collections::HashMap::new(), + has_poison: false, + dependencies: crate::recovery::Dependencies::default(), + failed_lineages: BTreeSet::new(), + failed_predicates: BTreeSet::new(), + failed_definitions: std::collections::HashSet::new(), + value_roots: Vec::new(), + kind_contracts: Vec::new(), + kind_errors: Vec::new(), + fields: Vec::new(), + } + } + + fn finish_state( + &mut self, + uf: &mut UnionFind<'a>, + mut state: State<'a>, + ) -> Result<(Annotations<'a>, crate::SolvedTypes<'a>), Vec>> { + self.retry_fields(uf, OUTERMOST_RANK, &mut state.errors); + self.finish_fields(uf, OUTERMOST_RANK, &mut state.errors); + state.errors.append(&mut self.kind_errors); + if state.errors.is_empty() { + self.finish(uf, &state.env) + } else { + // Elm accumulates errors by prepending; match its final order. + let mut errors = state.errors; + errors.extend(self.final_errors(uf)); + errors.reverse(); + Err(errors) + } + } +} + impl<'a> Solver<'a, '_> { fn unify( &mut self, @@ -964,35 +981,6 @@ impl<'a> Solver<'a, '_> { } } - fn constraint_predicate( - &mut self, - uf: &mut UnionFind<'a>, - rank: usize, - predicate: type_::Pred<'a>, - ) -> Body<'a> { - match predicate { - type_::Pred::Trait { - trait_, - args, - hidden, - } => Body::Trait { - trait_, - hidden, - args: args - .iter() - .map(|arg| self.type_to_variable(uf, rank, arg)) - .collect(), - }, - type_::Pred::Apply { head, args } => Body::Apply { - head: self.type_to_variable(uf, rank, head), - args: args - .iter() - .map(|arg| self.type_to_variable(uf, rank, arg)) - .collect(), - }, - } - } - fn expand_givens( &mut self, uf: &mut UnionFind<'a>, @@ -1035,53 +1023,16 @@ impl<'a> Solver<'a, '_> { } } - #[allow(clippy::too_many_arguments)] - fn solve_header( - &mut self, - uf: &mut UnionFind<'a>, - env: &Env<'a>, - rank: usize, - state: State<'a>, - constraint: &Constraint<'a>, - given: &[type_::Pred<'a>], - binder: Option>, - annotated: bool, - ) -> State<'a> { - // Attribute new failures to their owning binding. This count does not - // gate any independent constraint or reset the collected diagnostics. - let errors_before = state.errors.len(); - let depth = self.enter_givens(uf, rank, given, binder); - let start = self.wanted.len(); - let owner_depth = self.owners.len(); - if let Some(binder) = binder { - self.owners.push(binder.node()); - } - let mut state = self.solve(uf, env, rank, state, constraint); - self.retry_fields(uf, rank, &mut state.errors); - state = self.resolve_wanted(uf, rank, state, start, binder, annotated); - if state.errors.len() > errors_before - && let Some(binder) = binder - { - self.fail_definition(uf, binder.node()); - } - self.givens.truncate(depth); - self.owners.truncate(owner_depth); - state - } - fn enter_givens( &mut self, uf: &mut UnionFind<'a>, rank: usize, - given: &[type_::Pred<'a>], + given: &[Body<'a>], binder: Option>, ) -> usize { let depth = self.givens.len(); if let Some(binder) = binder.filter(|_| !given.is_empty()) { - let bodies: Vec<_> = given - .iter() - .map(|pred| self.constraint_predicate(uf, rank, *pred)) - .collect(); + let bodies = given.to_vec(); let slots = crate::preds::ContextSlots::new(bodies.iter()); let mut predicates: Vec<_> = bodies .into_iter() @@ -1550,430 +1501,6 @@ impl<'a> Solver<'a, '_> { } } - fn solve( - &mut self, - uf: &mut UnionFind<'a>, - env: &Env<'a>, - rank: usize, - state: State<'a>, - constraint: &Constraint<'a>, - ) -> State<'a> { - match constraint { - Constraint::Record { - region, - context, - record, - } => { - let record = self.type_to_variable(uf, rank, record); - self.fields.push(DeferredField { - region: *region, - context: *context, - record, - field: None, - }); - let mut state = state; - self.retry_fields(uf, rank, &mut state.errors); - state - } - Constraint::Field { - region, - context, - record, - field, - field_type, - } => { - let record = self.type_to_variable(uf, rank, record); - let field_type = self.type_to_variable(uf, rank, field_type); - self.dependencies.field(field_type, record); - self.fields.push(DeferredField { - region: *region, - context: *context, - record, - field: Some((field, field_type)), - }); - let mut state = state; - self.retry_fields(uf, rank, &mut state.errors); - state - } - Constraint::True => state, - - Constraint::SaveTheEnvironment => State { - env: env.clone(), - ..state - }, - - Constraint::Equal(region, category, tipe, expectation) => { - let actual = self.type_to_variable(uf, rank, tipe); - let expected = self.expected_to_variable(uf, rank, expectation); - if let Expected::FromContext( - _, - nash_constrain::error::Context::CallArity(_, arity), - _, - ) = expectation - { - let mut result = expected; - let mut inputs = vec![actual]; - for _ in 0..*arity { - let Content::Structure(FlatType::Fun1(argument, output)) = - uf.get(result).content - else { - break; - }; - inputs.push(argument); - result = output; - } - self.dependencies.computation(result, inputs); - } - self.formed_at(uf, rank, &[actual, expected], *region); - match self.unify(uf, actual, expected) { - unify::Answer::Ok(vars) => { - self.introduce(uf, rank, &vars); - state - } - unify::Answer::Err(vars, actual_type, expected_type) => { - self.introduce(uf, rank, &vars); - add_error( - state, - Error::BadExpr( - *region, - *category, - actual_type, - expectation.type_replace(expected_type), - ), - ) - } - } - } - - Constraint::Local(region, node, name, expectation) => { - let binding = *env - .get(name) - .expect("constraint generator only references bound locals"); - let actual = self.instantiate_binding( - uf, - rank, - binding, - UseSite { - node: *node, - region: *region, - name, - }, - ); - let expected = self.expected_to_variable(uf, rank, expectation); - self.formed_at(uf, rank, &[actual, expected], *region); - match self.unify(uf, actual, expected) { - unify::Answer::Ok(vars) => { - self.introduce(uf, rank, &vars); - state - } - unify::Answer::Err(vars, actual_type, expected_type) => { - self.introduce(uf, rank, &vars); - add_error( - state, - Error::BadExpr( - *region, - Category::Local(name), - actual_type, - expectation.type_replace(expected_type), - ), - ) - } - } - } - - Constraint::Foreign(region, node, name, annotation, expectation) => { - let actual = self.src_type_to_variable( - uf, - rank, - UseSite { - node: *node, - region: *region, - name, - }, - annotation, - ); - let expected = self.expected_to_variable(uf, rank, expectation); - self.formed_at(uf, rank, &[actual, expected], *region); - match self.unify(uf, actual, expected) { - unify::Answer::Ok(vars) => { - self.introduce(uf, rank, &vars); - state - } - unify::Answer::Err(vars, actual_type, expected_type) => { - self.introduce(uf, rank, &vars); - add_error( - state, - Error::BadExpr( - *region, - Category::Foreign(name), - actual_type, - expectation.type_replace(expected_type), - ), - ) - } - } - } - - Constraint::Pattern(region, category, tipe, expectation) => { - let actual = self.type_to_variable(uf, rank, tipe); - let expected = self.pattern_expectation_to_variable(uf, rank, expectation); - self.formed_at(uf, rank, &[actual, expected], *region); - match self.unify(uf, actual, expected) { - unify::Answer::Ok(vars) => { - self.introduce(uf, rank, &vars); - state - } - unify::Answer::Err(vars, actual_type, expected_type) => { - self.introduce(uf, rank, &vars); - add_error( - state, - Error::BadPattern( - *region, - *category, - actual_type, - expectation.type_replace(expected_type), - ), - ) - } - } - } - - Constraint::And(constraints) => constraints - .iter() - .fold(state, |state, sub| self.solve(uf, env, rank, state, sub)), - - Constraint::Let { - declarations, - given, - binder, - definitions, - rigid_vars, - flex_vars, - header, - header_con, - body_con, - } => { - let errors_before = state.errors.len(); - let wanted_start = self.wanted.len(); - let annotated = definitions.iter().any(|def| def.context.is_some()); - if definitions.is_empty() - && rigid_vars.is_empty() - && matches!(body_con, Constraint::True) - { - self.introduce(uf, rank, flex_vars); - let declared = self.declared_contexts(uf, rank, definitions, declarations); - let state1 = self - .solve_header(uf, env, rank, state, header_con, given, *binder, annotated); - self.record_definitions(uf, rank, definitions, &declared, &[], *binder); - state1 - } else if definitions.is_empty() && rigid_vars.is_empty() && flex_vars.is_empty() { - let declared = self.declared_contexts(uf, rank, definitions, declarations); - let state1 = self - .solve_header(uf, env, rank, state, header_con, given, *binder, annotated); - self.record_definitions(uf, rank, definitions, &declared, &[], *binder); - let locals: Vec<(&'a str, Located)> = header - .iter() - .map(|(name, loc_type)| { - let var = self.type_to_variable(uf, rank, loc_type.value); - (*name, Located::at(loc_type.region, var)) - }) - .collect(); - let mut new_env = env.clone(); - for (name, loc) in &locals { - new_env.entry(name).or_insert(Binding { - declared_quantifiers: &[], - variable: loc.value, - context: declared.get(name).copied().unwrap_or(&[]), - context_is_final: !declarations - .iter() - .any(|def| def.site.name().value == *name && def.context.is_none()), - definition: definitions - .iter() - .chain(declarations.iter()) - .find(|def| def.site.name().value == *name) - .map(|def| def.site.node()) - .or_else(|| { - binder - .filter(|b| matches!(b, type_::Binder::Pattern { .. })) - .map(type_::Binder::node) - }), - }); - } - let state2 = self.solve(uf, &new_env, rank, state1, body_con); - locals.into_iter().fold(state2, |state, (name, loc)| { - self.check_occurs(uf, state, name, loc) - }) - } else { - // work in the next pool to localize header - let next_rank = rank + 1; - if next_rank >= self.pools.len() { - let pools_length = self.pools.len(); - self.pools.resize(pools_length * 2, Vec::new()); - } - - // introduce variables - let vars: Vec = - rigid_vars.iter().chain(flex_vars.iter()).copied().collect(); - for var in &vars { - uf.modify(*var, |desc| desc.rank = next_rank); - } - self.pools[next_rank] = vars; - - // run solver in next pool - let locals: Vec<(&'a str, Located)> = header - .iter() - .map(|(name, loc_type)| { - let var = self.type_to_variable(uf, next_rank, loc_type.value); - (*name, Located::at(loc_type.region, var)) - }) - .collect(); - let declared = self.declared_contexts(uf, next_rank, definitions, declarations); - let mut state1 = self.solve_header( - uf, env, next_rank, state, header_con, given, *binder, annotated, - ); - - let young_mark = state1.mark; - let visit_mark = young_mark.next(); - let final_mark = visit_mark.next(); - - self.retry_fields(uf, next_rank, &mut state1.errors); - self.finish_fields(uf, next_rank, &mut state1.errors); - - // pop pool - self.generalize(uf, young_mark, visit_mark, next_rank); - self.pools[next_rank] = Vec::new(); - - // An unrelated error must not suppress an escaping annotation. - for rigid in rigid_vars.iter() { - if uf.get(*rigid).rank != NO_RANK - && !crate::recovery::is_poisoned(uf, [*rigid]) - { - let owner = binder - .map(|name| (name.name().region, name.name().value)) - .or_else(|| header.first().map(|(name, typ)| (typ.region, *name))); - state1.errors.push(Error::AnnotationVariableEscapes { - region: owner - .map_or_else(nash_region::Region::zero, |(region, _)| region), - name: owner.map(|(_, name)| name), - variable: to_error_type(self.bump, uf, *rigid), - }); - } - } - - if let Some(binder) = *binder { - let depth = self.enter_givens(uf, rank, given, Some(binder)); - loop { - let (errors, defaulted) = self.check_ambiguity( - uf, - rank, - wanted_start, - definitions, - binder.name(), - ); - state1.errors.extend(errors); - if !defaulted { - break; - } - state1 = self.resolve_wanted( - uf, - next_rank, - state1, - wanted_start, - Some(binder), - annotated, - ); - } - self.givens.truncate(depth); - } - let context = if !definitions.is_empty() - && definitions.iter().all(|def| def.context.is_none()) - { - self.retain_wanted( - uf, - rank, - wanted_start, - binder.expect("inferred definition binder").node(), - ) - } else { - &[] - }; - - let mut new_env = env.clone(); - if state1.errors.len() > errors_before - && let Some(binder) = *binder - { - self.fail_definition(uf, binder.node()); - } - self.record_definitions(uf, rank, definitions, &declared, context, *binder); - for (name, loc) in &locals { - new_env.entry(name).or_insert(Binding { - declared_quantifiers: if declarations - .iter() - .any(|def| def.site.name().value == *name && def.context.is_some()) - { - rigid_vars - } else { - &[] - }, - variable: loc.value, - context: declared.get(name).copied().unwrap_or(context), - context_is_final: true, - definition: definitions - .iter() - .chain(declarations.iter()) - .find(|def| def.site.name().value == *name) - .map(|def| def.site.node()) - .or_else(|| { - binder - .filter(|b| matches!(b, type_::Binder::Pattern { .. })) - .map(type_::Binder::node) - }), - }); - } - let temp_state = State { - env: state1.env, - mark: final_mark, - errors: state1.errors, - }; - let new_state = self.solve(uf, &new_env, rank, temp_state, body_con); - - locals.into_iter().fold(new_state, |state, (name, loc)| { - self.check_occurs(uf, state, name, loc) - }) - } - } - } - } - - // EXPECTATIONS TO VARIABLE - - fn expected_to_variable( - &mut self, - uf: &mut UnionFind<'a>, - rank: usize, - expectation: &Expected<'a, &'a Type<'a>>, - ) -> Variable { - let tipe = match expectation { - Expected::NoExpectation(tipe) => tipe, - Expected::FromContext(_, _, tipe) => tipe, - Expected::FromAnnotation(_, _, _, tipe) => tipe, - }; - self.type_to_variable(uf, rank, tipe) - } - - fn pattern_expectation_to_variable( - &mut self, - uf: &mut UnionFind<'a>, - rank: usize, - expectation: &PExpected<'a, &'a Type<'a>>, - ) -> Variable { - let tipe = match expectation { - PExpected::NoExpectation(tipe) => tipe, - PExpected::FromContext(_, _, tipe) => tipe, - }; - self.type_to_variable(uf, rank, tipe) - } - // OCCURS CHECK fn check_occurs( @@ -2059,118 +1586,6 @@ impl<'a> Solver<'a, '_> { } } - // TYPE TO VARIABLE - - fn type_to_variable( - &mut self, - uf: &mut UnionFind<'a>, - rank: usize, - tipe: &Type<'a>, - ) -> Variable { - match tipe { - Type::PartialAliasN { - home, - name, - args, - remaining, - body, - } => { - let args = args - .iter() - .map(|(name, typ)| (*name, self.type_to_variable(uf, rank, typ))) - .collect(); - self.register( - uf, - rank, - Content::PartialAlias { - home: *home, - name, - args, - remaining: remaining.to_vec(), - body, - }, - ) - } - Type::VarN(var) => *var, - - Type::AppVarN(head, args) => { - let head = self.type_to_variable(uf, rank, head); - let args = args - .iter() - .map(|arg| self.type_to_variable(uf, rank, arg)) - .collect(); - self.register(uf, rank, Content::Structure(FlatType::AppV1(head, args))) - } - - Type::AppN { home, name, args } => { - let arg_vars: Vec = args - .iter() - .map(|arg| self.type_to_variable(uf, rank, arg)) - .collect(); - self.register( - uf, - rank, - Content::Structure(FlatType::App1(*home, name, arg_vars)), - ) - } - - Type::FunN(a, b) => { - let a_var = self.type_to_variable(uf, rank, a); - let b_var = self.type_to_variable(uf, rank, b); - self.register(uf, rank, Content::Structure(FlatType::Fun1(a_var, b_var))) - } - - Type::AliasN { - home, - name, - args, - real, - body, - } => { - let arg_vars: Vec<(&'a str, Variable)> = args - .iter() - .map(|(arg_name, arg_type)| { - (*arg_name, self.type_to_variable(uf, rank, arg_type)) - }) - .collect(); - let alias_var = self.type_to_variable(uf, rank, real); - self.register( - uf, - rank, - Content::Alias { - home: *home, - name, - args: arg_vars, - real: alias_var, - body, - }, - ) - } - - Type::RecordN { fields } => { - let field_vars: BTreeMap<&'a str, Variable> = fields - .iter() - .map(|(name, field_type)| (*name, self.type_to_variable(uf, rank, field_type))) - .collect(); - self.register(uf, rank, Content::Structure(FlatType::Record1(field_vars))) - } - - Type::TupleN(a, b, rest) => { - let a_var = self.type_to_variable(uf, rank, a); - let b_var = self.type_to_variable(uf, rank, b); - let c_var = rest - .iter() - .map(|c| self.type_to_variable(uf, rank, c)) - .collect(); - self.register( - uf, - rank, - Content::Structure(FlatType::Tuple1(a_var, b_var, c_var)), - ) - } - } - } - fn register(&mut self, uf: &mut UnionFind<'a>, rank: usize, content: Content<'a>) -> Variable { let var = uf.fresh(Descriptor { preds: Vec::new(), @@ -2326,8 +1741,8 @@ impl<'a> Solver<'a, '_> { fn record_definitions( &mut self, uf: &mut UnionFind<'a>, - rank: usize, - definitions: &[type_::Definition<'a>], + _rank: usize, + definitions: &[Definition<'a>], declared: &BTreeMap<&'a str, &'a [type_::PredId]>, inferred: &'a [type_::PredId], binder: Option>, @@ -2335,7 +1750,7 @@ impl<'a> Solver<'a, '_> { for definition in definitions { let binding = Binding { declared_quantifiers: &[], - variable: self.type_to_variable(uf, rank, definition.typ), + variable: definition.typ, context: declared .get(definition.site.name().value) .copied() @@ -2427,9 +1842,9 @@ impl<'a> Solver<'a, '_> { fn declared_contexts( &mut self, uf: &mut UnionFind<'a>, - rank: usize, - definitions: &[type_::Definition<'a>], - declarations: &[type_::Definition<'a>], + _rank: usize, + definitions: &[Definition<'a>], + declarations: &[Definition<'a>], ) -> BTreeMap<&'a str, &'a [type_::PredId]> { let mut contexts = BTreeMap::new(); for definition in definitions.iter().chain(declarations) { @@ -2438,7 +1853,7 @@ impl<'a> Solver<'a, '_> { }; let mut ids = Vec::new(); for (index, pred) in context.iter().enumerate() { - let body = self.constraint_predicate(uf, rank, *pred); + let body = pred.clone(); ids.push(self.predicates.push( uf, Predicate { @@ -2451,7 +1866,7 @@ impl<'a> Solver<'a, '_> { }, )); } - let root = self.type_to_variable(uf, rank, definition.typ); + let root = definition.typ; let mut roots = vec![root]; for id in &ids { roots.extend(self.predicates.get(*id).body.roots()); @@ -2537,7 +1952,7 @@ impl<'a> Solver<'a, '_> { uf: &mut UnionFind<'a>, rank: usize, start: usize, - definitions: &[type_::Definition<'a>], + definitions: &[Definition<'a>], binder: &'a Located<&'a str>, ) -> (Vec>, bool) { use nash_ast::primitives::ReprTrait; @@ -2589,10 +2004,7 @@ impl<'a> Solver<'a, '_> { } } self.propagate_poison(uf); - let roots: Vec<_> = definitions - .iter() - .map(|def| self.type_to_variable(uf, rank, def.typ)) - .collect(); + let roots: Vec<_> = definitions.iter().map(|def| def.typ).collect(); let reachable = Self::type_variables(uf, roots); let mut ambiguous: BTreeMap<_, Vec<_>> = BTreeMap::new(); for (_, id) in &self.wanted[start..] { @@ -2645,8 +2057,8 @@ impl<'a> Solver<'a, '_> { }) .collect(); if defaults.len() == 1 && matches!(uf.get(var).content, Content::FlexVar(_)) { - let typ = self.bump.alloc(defaults.into_values().next().unwrap()); - let target = self.type_to_variable(uf, rank, typ); + let typ = defaults.into_values().next().unwrap(); + let target = self.structure(uf, rank, typ); if matches!(self.unify(uf, var, target), unify::Answer::Ok(_)) { defaulted = true; continue; @@ -3187,6 +2599,190 @@ fn adjust_rank_content<'a>( #[cfg(test)] mod copy_tests { use super::*; + // Insert in solve.rs tests module; use Solver::new and finish_state. + fn primitive_state<'a>() -> State<'a> { + State { + env: Env::new(), + mark: NO_MARK.next(), + errors: Vec::new(), + } + } + + fn primitive_nullary<'a>( + solver: &mut Solver<'a, '_>, + uf: &mut UnionFind<'a>, + name: &'a str, + ) -> Variable { + solver.structure( + uf, + OUTERMOST_RANK, + FlatType::App1(nash_ast::primitives::builtin_home(), name, Vec::new()), + ) + } + + fn primitive_unit_function<'a>( + solver: &mut Solver<'a, '_>, + uf: &mut UnionFind<'a>, + ) -> Variable { + // Each occurrence of the old structural source type was converted afresh, + // including its two identical unit children. + let input = primitive_nullary(solver, uf, "unit"); + let output = primitive_nullary(solver, uf, "unit"); + solver.structure(uf, OUTERMOST_RANK, FlatType::Fun1(input, output)) + } + + #[test] + fn a_partial_constructor_cannot_be_a_value_type() { + let bump = Bump::new(); + let tables = nash_can::environment::Tables::default(); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let actual = primitive_nullary(&mut solver, &mut uf, "list"); + let expected = primitive_nullary(&mut solver, &mut uf, "list"); + let state = solver.equal( + &mut uf, + OUTERMOST_RANK, + primitive_state(), + nash_region::Region::zero(), + Category::List, + actual, + Expected::NoExpectation(expected), + ); + let errors = solver.finish_state(&mut uf, state).unwrap_err(); + assert!( + errors + .iter() + .any(|error| matches!(error, Error::BadKind { .. })), + "{errors:#?}" + ); + } + + #[test] + fn recovery_collects_final_kind_errors_after_independent_type_failures() { + let bump = Bump::new(); + let tables = nash_can::environment::Tables::default(); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let at = |line| nash_region::Region { + start: nash_region::Position { line, column: 1 }, + end: nash_region::Position { line, column: 2 }, + }; + let function = primitive_unit_function(&mut solver, &mut uf); + let unit = primitive_nullary(&mut solver, &mut uf, "unit"); + let mut state = solver.equal( + &mut uf, + OUTERMOST_RANK, + primitive_state(), + at(1), + Category::Lambda, + function, + Expected::NoExpectation(unit), + ); + for line in [2, 3] { + // Four separate list nodes across these two equalities: sharing the + // source description never meant sharing its materialized UF graph. + let actual = primitive_nullary(&mut solver, &mut uf, "list"); + let expected = primitive_nullary(&mut solver, &mut uf, "list"); + state = solver.equal( + &mut uf, + OUTERMOST_RANK, + state, + at(line), + Category::List, + actual, + Expected::NoExpectation(expected), + ); + } + let errors = solver.finish_state(&mut uf, state).unwrap_err(); + assert_eq!( + errors + .iter() + .filter(|error| matches!(error, Error::BadExpr(..))) + .count(), + 1, + "{errors:#?}" + ); + assert_eq!( + errors + .iter() + .filter(|error| matches!(error, Error::BadKind { .. })) + .count(), + 2, + "{errors:#?}" + ); + } + + #[test] + fn recovery_retains_shared_heads_removed_by_successful_normalization() { + let bump = Bump::new(); + let tables = nash_can::environment::Tables::default(); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let rank = OUTERMOST_RANK; + let head = solver.fresh(&mut uf, rank); + let result = solver.fresh(&mut uf, rank); + let region = nash_region::Region::zero(); + + let argument = primitive_nullary(&mut solver, &mut uf, "unit"); + let applied = solver.structure(&mut uf, rank, FlatType::AppV1(head, vec![argument])); + let mut state = solver.equal( + &mut uf, + rank, + primitive_state(), + region, + Category::List, + result, + Expected::NoExpectation(applied), + ); + + let argument = primitive_nullary(&mut solver, &mut uf, "unit"); + let list = solver.structure( + &mut uf, + rank, + FlatType::App1(nash_ast::primitives::builtin_home(), "list", vec![argument]), + ); + state = solver.equal( + &mut uf, + rank, + state, + region, + Category::List, + result, + Expected::NoExpectation(list), + ); + + let function = primitive_unit_function(&mut solver, &mut uf); + state = solver.equal( + &mut uf, + rank, + state, + region, + Category::List, + result, + Expected::NoExpectation(function), + ); + + // Rebuild this entire function graph after the previous failing equality. + // Only the explicit head/result variables are shared between operations. + let function = primitive_unit_function(&mut solver, &mut uf); + state = solver.equal( + &mut uf, + rank, + state, + region, + Category::List, + head, + Expected::NoExpectation(function), + ); + let errors = solver.finish_state(&mut uf, state).unwrap_err(); + assert_eq!( + errors.len(), + 1, + "normalization must not sever the dependency from the applied type to its shared head: {errors:#?}" + ); + assert!(matches!(errors[0], Error::BadExpr(..)), "{errors:#?}"); + } + use nash_constrain::type_::{PredId, make_descriptor}; fn evidence_name(name: nash_ast::QualifiedName<'_>) -> String { @@ -3342,14 +2938,10 @@ mod copy_tests { let binder = type_::Binder::Named(name); let mut ids = Vec::new(); for trait_name in ["Expanding", "Missing"] { - let variable = solver.type_to_variable( + let variable = solver.structure( &mut uf, OUTERMOST_RANK, - &Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "unit", - args: &[], - }, + FlatType::App1(nash_ast::primitives::builtin_home(), "unit", Vec::new()), ); let id = solver.predicates.push( &mut uf, @@ -3408,7 +3000,6 @@ mod copy_tests { let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); let mut solver = Solver { bump: &bump, tables: &canonical.tables, @@ -3432,16 +3023,14 @@ mod copy_tests { kind_errors: Vec::new(), fields: Vec::new(), }; - let result = solver.solve( + let result = solver.infer_module( &mut uf, - &Env::new(), - OUTERMOST_RANK, + &canonical.module, State { env: Env::new(), mark: NO_MARK.next(), errors: Vec::new(), }, - &constraint, ); assert!(result.errors.is_empty()); assert!( @@ -3480,7 +3069,6 @@ mod copy_tests { let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); let mut solver = Solver { bump: &bump, tables: &canonical.tables, @@ -3504,16 +3092,14 @@ mod copy_tests { kind_errors: Vec::new(), fields: Vec::new(), }; - let result = solver.solve( + let result = solver.infer_module( &mut uf, - &Env::new(), - OUTERMOST_RANK, + &canonical.module, State { env: Env::new(), mark: NO_MARK.next(), errors: Vec::new(), }, - &constraint, ); assert!( matches!(&result.errors[..], [Error::MissingImpl { region, .. }] if region.start.line == 8) @@ -3545,7 +3131,6 @@ mod copy_tests { let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); let mut solver = Solver { bump: &bump, tables: &canonical.tables, @@ -3569,16 +3154,14 @@ mod copy_tests { kind_errors: Vec::new(), fields: Vec::new(), }; - let result = solver.solve( + let result = solver.infer_module( &mut uf, - &Env::new(), - OUTERMOST_RANK, + &canonical.module, State { env: Env::new(), mark: NO_MARK.next(), errors: Vec::new(), }, - &constraint, ); assert!(result.errors.is_empty()); let local = solver @@ -3632,7 +3215,6 @@ mod copy_tests { let canonical = nash_can::canonicalize(&bump, nash_can::Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); let mut solver = Solver { bump: &bump, tables: &canonical.tables, @@ -3656,16 +3238,14 @@ mod copy_tests { kind_errors: Vec::new(), fields: Vec::new(), }; - let result = solver.solve( + let result = solver.infer_module( &mut uf, - &Env::new(), - OUTERMOST_RANK, + &canonical.module, State { env: Env::new(), mark: NO_MARK.next(), errors: Vec::new(), }, - &constraint, ); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!(result.env["value"].context.is_empty()); @@ -3769,7 +3349,6 @@ mod copy_tests { let group_binder = group_binder.unwrap(); let h_binder = h_binder.unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); let mut solver = Solver { bump: &bump, tables: &canonical.tables, @@ -3793,16 +3372,14 @@ mod copy_tests { kind_errors: Vec::new(), fields: Vec::new(), }; - let result = solver.solve( + let result = solver.infer_module( &mut uf, - &Env::new(), - OUTERMOST_RANK, + &canonical.module, State { env: Env::new(), mark: NO_MARK.next(), errors: Vec::new(), }, - &constraint, ); assert!(result.errors.is_empty(), "{:?}", result.errors); assert!(solver.wanted.is_empty()); diff --git a/crates/nash-solve/src/solve/expressions.rs b/crates/nash-solve/src/solve/expressions.rs new file mode 100644 index 00000000..2adeb27f --- /dev/null +++ b/crates/nash-solve/src/solve/expressions.rs @@ -0,0 +1,771 @@ +use super::*; +use nash_ast::{Expr as CanExpr, NodeId}; +use nash_constrain::error::{Context, MaybeName, PContext, SubContext}; + +impl<'a> Solver<'a, '_> { + #[allow(clippy::too_many_arguments)] + pub(super) fn infer_expr( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + rtv: &BTreeMap<&'a str, Variable>, + expr: &Located>, + expected: Expected<'a, Variable>, + ) -> State<'a> { + // These are exactly the old `exists` boundaries. Their wanted flush + // must remain even though variables are now introduced eagerly. + let existential = matches!( + &expr.value, + CanExpr::List(_) + | CanExpr::Binop { .. } + | CanExpr::Lambda { .. } + | CanExpr::Call { .. } + | CanExpr::Case { .. } + | CanExpr::Accessor(_) + | CanExpr::Access { .. } + | CanExpr::Update { .. } + | CanExpr::Record { .. } + | CanExpr::Tuple { .. } + | CanExpr::If { .. } + ); + let wanted_start = self.wanted.len(); + let mut state = self.infer_expr_inner(uf, env, rank, state, rtv, expr, expected); + if existential { + self.retry_fields(uf, rank, &mut state.errors); + state = self.resolve_wanted(uf, rank, state, wanted_start, None, false); + } + state + } + + #[allow(clippy::too_many_arguments)] + fn infer_expr_inner( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + mut state: State<'a>, + rtv: &BTreeMap<&'a str, Variable>, + expr: &Located>, + expected: Expected<'a, Variable>, + ) -> State<'a> { + let region = expr.region; + let node = NodeId::expr(expr); + match &expr.value { + CanExpr::VarLocal(name) => { + self.local(uf, env, rank, state, region, node, name, expected) + } + CanExpr::VarTopLevel(reference) => { + self.local(uf, env, rank, state, region, node, reference.name, expected) + } + CanExpr::VarForeign { + reference, + annotation, + } => self.foreign( + uf, + rank, + state, + region, + node, + reference.name, + annotation, + expected, + ), + CanExpr::VarConstructor { + reference, + annotation, + .. + } => self.foreign( + uf, + rank, + state, + region, + node, + reference.name, + annotation, + expected, + ), + CanExpr::VarMethod { + method, annotation, .. + } => self.foreign(uf, rank, state, region, node, method, annotation, expected), + CanExpr::VarOperator { + symbol, annotation, .. + } => self.foreign(uf, rank, state, region, node, symbol, annotation, expected), + CanExpr::Str(_) | CanExpr::Bytes(_) | CanExpr::Int(_) => { + let (name, trait_) = match &expr.value { + CanExpr::Str(_) => ("fromString", "FromString"), + CanExpr::Bytes(_) => ("fromBytes", "FromBytes"), + _ => ("fromInt", "FromInt"), + }; + let annotation = + type_::literal_annotation(self.bump, &[type_::literal_trait(trait_)]); + self.foreign(uf, rank, state, region, node, name, annotation, expected) + } + CanExpr::List(entries) => { + let element = self.fresh(uf, rank); + for (index, entry) in entries.iter().enumerate() { + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + entry, + Expected::FromContext(region, Context::ListEntry(index), element), + ); + } + let list = self.structure( + uf, + rank, + FlatType::App1(nash_ast::primitives::builtin_home(), "list", vec![element]), + ); + self.equal(uf, rank, state, region, Category::List, list, expected) + } + CanExpr::Binop { + symbol, + annotation, + left, + right, + .. + } => { + let left_var = self.fresh(uf, rank); + let right_var = self.fresh(uf, rank); + let result = self.fresh(uf, rank); + let tail = self.structure(uf, rank, FlatType::Fun1(right_var, result)); + let function = self.structure(uf, rank, FlatType::Fun1(left_var, tail)); + state = self.foreign( + uf, + rank, + state, + region, + node, + symbol, + annotation, + Expected::NoExpectation(function), + ); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + left, + Expected::FromContext(region, Context::OpLeft(symbol), left_var), + ); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + right, + Expected::FromContext(region, Context::OpRight(symbol), right_var), + ); + self.equal( + uf, + rank, + state, + region, + Category::CallResult(MaybeName::OpName(symbol)), + result, + expected, + ) + } + CanExpr::Lambda { parameters, body } => { + let args: Vec<_> = parameters.iter().map(|_| self.fresh(uf, rank)).collect(); + let result = self.fresh(uf, rank); + let patterns: Vec<_> = parameters + .iter() + .zip(&args) + .map(|(pattern, var)| (*pattern, PExpected::NoExpectation(*var))) + .collect(); + let scope = self.infer_patterns(uf, env, rank, state, &patterns); + state = self.infer_expr( + uf, + &scope.env, + rank, + scope.state, + rtv, + body, + Expected::NoExpectation(result), + ); + state = self.close_locals(uf, state, scope.locals); + let function = args.into_iter().rev().fold(result, |tail, arg| { + self.structure(uf, rank, FlatType::Fun1(arg, tail)) + }); + self.equal( + uf, + rank, + state, + region, + Category::Lambda, + function, + expected, + ) + } + CanExpr::Call { + function, + arguments, + } => { + let name = direct_get_name(function); + let function_var = self.fresh(uf, rank); + let result = self.fresh(uf, rank); + let args: Vec<_> = arguments.iter().map(|_| self.fresh(uf, rank)).collect(); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + function, + Expected::NoExpectation(function_var), + ); + let arity_type = args.iter().rev().fold(result, |tail, arg| { + self.structure(uf, rank, FlatType::Fun1(*arg, tail)) + }); + state = self.equal( + uf, + rank, + state, + function.region, + Category::CallResult(name), + function_var, + Expected::FromContext( + region, + Context::CallArity(name, arguments.len()), + arity_type, + ), + ); + for (index, (arg, var)) in arguments.iter().zip(args).enumerate() { + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + arg, + Expected::FromContext(region, Context::CallArg(name, index), var), + ); + } + self.equal( + uf, + rank, + state, + region, + Category::CallResult(name), + result, + expected, + ) + } + CanExpr::If { + branches, + final_else, + } => { + // All conditions precede all branch bodies, including else-if. + let branch_var = self.fresh(uf, rank); + for branch in *branches { + let boolean = self.structure( + uf, + rank, + FlatType::App1(nash_ast::primitives::builtin_home(), "bool", vec![]), + ); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + branch.condition, + Expected::FromContext(region, Context::IfCondition, boolean), + ); + } + for (index, branch) in branches + .iter() + .map(|branch| branch.then_branch) + .chain(std::iter::once(*final_else)) + .enumerate() + { + let branch_expected = + Expected::FromContext(region, Context::IfBranch(index), branch_var); + state = self.infer_expr(uf, env, rank, state, rtv, branch, branch_expected); + } + self.equal(uf, rank, state, region, Category::If, branch_var, expected) + } + CanExpr::Case { + scrutinee, + branches, + } => { + let matched = self.fresh(uf, rank); + let branch_var = self.fresh(uf, rank); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + scrutinee, + Expected::NoExpectation(matched), + ); + for (index, branch) in branches.iter().enumerate() { + let scope = self.infer_patterns( + uf, + env, + rank, + state, + &[( + branch.pattern, + PExpected::FromContext(region, PContext::CaseMatch(index), matched), + )], + ); + let branch_expected = + Expected::FromContext(region, Context::CaseBranch(index), branch_var); + state = self.infer_expr( + uf, + &scope.env, + rank, + scope.state, + rtv, + branch.body, + branch_expected, + ); + state = self.close_locals(uf, state, scope.locals); + } + self.equal( + uf, + rank, + state, + region, + Category::Case, + branch_var, + expected, + ) + } + CanExpr::Let { definition, body } => { + let scope = self.infer_definition(uf, env, rank, state, rtv, definition, true); + state = self.infer_expr(uf, &scope.env, rank, scope.state, rtv, body, expected); + self.close_locals(uf, state, scope.locals) + } + CanExpr::LetRec { definitions, body } => { + let scope = self.infer_group(uf, env, rank, state, rtv, definitions); + state = self.infer_expr(uf, &scope.env, rank, scope.state, rtv, body, expected); + self.close_locals(uf, state, scope.locals) + } + CanExpr::LetDestruct { + pattern, + value, + body, + } => { + let scope = self.infer_destruct(uf, env, rank, state, rtv, region, pattern, value); + state = self.infer_expr(uf, &scope.env, rank, scope.state, rtv, body, expected); + self.close_locals(uf, state, scope.locals) + } + CanExpr::Accessor(field) => { + let record = self.fresh(uf, rank); + let value = self.fresh(uf, rank); + state = self.field( + uf, + rank, + state, + DeferredField { + region, + context: type_::FieldContext::Accessor, + record, + field: Some((field, value)), + }, + ); + let function = self.structure(uf, rank, FlatType::Fun1(record, value)); + self.equal( + uf, + rank, + state, + region, + Category::Accessor(field), + function, + expected, + ) + } + CanExpr::Access { record, field } => { + let record_var = self.fresh(uf, rank); + let value = self.fresh(uf, rank); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + record, + Expected::NoExpectation(record_var), + ); + state = self.field( + uf, + rank, + state, + DeferredField { + region, + context: type_::FieldContext::Access { + record_region: record.region, + maybe_name: direct_get_access_name(record), + }, + record: record_var, + field: Some((field.value, value)), + }, + ); + self.equal( + uf, + rank, + state, + region, + Category::Access(field.value), + value, + expected, + ) + } + CanExpr::Update { + record, + base, + fields, + } => { + let record_var = self.fresh(uf, rank); + let values: Vec<_> = fields.iter().map(|_| self.fresh(uf, rank)).collect(); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + base, + Expected::FromContext( + region, + Context::RecordUpdateKeys(record, fields), + record_var, + ), + ); + if fields.is_empty() { + state = self.field( + uf, + rank, + state, + DeferredField { + region, + context: type_::FieldContext::Update { record }, + record: record_var, + field: None, + }, + ); + } + for (field, var) in fields.iter().zip(values) { + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + field.value, + Expected::FromContext( + region, + Context::RecordUpdateValue(field.field.value), + var, + ), + ); + state = self.field( + uf, + rank, + state, + DeferredField { + region: field.field.region, + context: type_::FieldContext::Update { record }, + record: record_var, + field: Some((field.field.value, var)), + }, + ); + } + self.equal( + uf, + rank, + state, + region, + Category::Record, + record_var, + expected, + ) + } + CanExpr::Record { + alias, + annotation, + fields, + } => { + let args: Vec<_> = fields.iter().map(|_| self.fresh(uf, rank)).collect(); + let result = self.fresh(uf, rank); + for (field, var) in fields.iter().zip(&args) { + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + field.value, + Expected::FromContext( + region, + Context::RecordField(alias.name, field.field.value), + *var, + ), + ); + } + let constructor = args.into_iter().rev().fold(result, |tail, arg| { + self.structure(uf, rank, FlatType::Fun1(arg, tail)) + }); + state = self.foreign( + uf, + rank, + state, + region, + node, + alias.name, + annotation, + Expected::NoExpectation(constructor), + ); + self.equal(uf, rank, state, region, Category::Record, result, expected) + } + CanExpr::Unit => { + let unit = self.structure( + uf, + rank, + FlatType::App1(nash_ast::primitives::builtin_home(), "unit", vec![]), + ); + self.equal(uf, rank, state, region, Category::Unit, unit, expected) + } + CanExpr::Tuple { + first, + second, + rest, + } => { + let first_var = self.fresh(uf, rank); + let second_var = self.fresh(uf, rank); + let tail: Vec<_> = rest.iter().map(|_| self.fresh(uf, rank)).collect(); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + first, + Expected::NoExpectation(first_var), + ); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + second, + Expected::NoExpectation(second_var), + ); + for (item, var) in rest.iter().zip(&tail) { + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + item, + Expected::NoExpectation(*var), + ); + } + let tuple = self.structure(uf, rank, FlatType::Tuple1(first_var, second_var, tail)); + self.equal(uf, rank, state, region, Category::Tuple, tuple, expected) + } + } + } +} + +fn direct_get_name<'a>(expr: &Located>) -> MaybeName<'a> { + match &expr.value { + CanExpr::VarMethod { method, .. } => MaybeName::FuncName(method), + CanExpr::VarLocal(name) => MaybeName::FuncName(name), + CanExpr::VarTopLevel(reference) | CanExpr::VarForeign { reference, .. } => { + MaybeName::FuncName(reference.name) + } + CanExpr::VarConstructor { reference, .. } => MaybeName::CtorName(reference.name), + CanExpr::VarOperator { symbol, .. } => MaybeName::OpName(symbol), + _ => MaybeName::NoName, + } +} + +fn direct_get_access_name<'a>(expr: &Located>) -> Option<&'a str> { + match &expr.value { + CanExpr::VarLocal(name) => Some(name), + CanExpr::VarTopLevel(reference) | CanExpr::VarForeign { reference, .. } => { + Some(reference.name) + } + _ => None, + } +} + +impl<'a> Solver<'a, '_> { + /// An annotation is canonical syntax, not a shared unification structure. + /// Reinstantiate it at each branch equation so one failed branch cannot + /// poison another branch's structural expectation. Its rigid variables + /// still come from the same lexical substitution. + #[allow(clippy::too_many_arguments)] + pub(super) fn infer_annotated_expr( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + mut state: State<'a>, + rtv: &BTreeMap<&'a str, Variable>, + expr: &Located>, + expected: Expected<'a, &'a Located>>, + ) -> State<'a> { + let Expected::FromAnnotation(name, arity, _, typ) = expected else { + unreachable!("annotated expression has its canonical expectation") + }; + let region = expr.region; + match &expr.value { + CanExpr::If { + branches, + final_else, + } => { + for branch in *branches { + let boolean = self.structure( + uf, + rank, + FlatType::App1(nash_ast::primitives::builtin_home(), "bool", Vec::new()), + ); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + branch.condition, + Expected::FromContext(region, Context::IfCondition, boolean), + ); + } + for (index, branch) in branches + .iter() + .map(|branch| branch.then_branch) + .chain(std::iter::once(*final_else)) + .enumerate() + { + state = self.infer_annotated_expr( + uf, + env, + rank, + state, + rtv, + branch, + Expected::FromAnnotation( + name, + arity, + SubContext::TypedIfBranch(index), + typ, + ), + ); + } + state + } + CanExpr::Case { + scrutinee, + branches, + } => { + let start = self.wanted.len(); + let matched = self.fresh(uf, rank); + state = self.infer_expr( + uf, + env, + rank, + state, + rtv, + scrutinee, + Expected::NoExpectation(matched), + ); + for (index, branch) in branches.iter().enumerate() { + let scope = self.infer_patterns( + uf, + env, + rank, + state, + &[( + branch.pattern, + PExpected::FromContext(region, PContext::CaseMatch(index), matched), + )], + ); + state = self.infer_annotated_expr( + uf, + &scope.env, + rank, + scope.state, + rtv, + branch.body, + Expected::FromAnnotation( + name, + arity, + SubContext::TypedCaseBranch(index), + typ, + ), + ); + state = self.close_locals(uf, state, scope.locals); + } + self.retry_fields(uf, rank, &mut state.errors); + self.resolve_wanted(uf, rank, state, start, None, false) + } + CanExpr::Let { definition, body } => { + let scope = self.infer_definition(uf, env, rank, state, rtv, definition, true); + state = self.infer_annotated_expr( + uf, + &scope.env, + rank, + scope.state, + rtv, + body, + expected, + ); + self.close_locals(uf, state, scope.locals) + } + CanExpr::LetRec { definitions, body } => { + let scope = self.infer_group(uf, env, rank, state, rtv, definitions); + state = self.infer_annotated_expr( + uf, + &scope.env, + rank, + scope.state, + rtv, + body, + expected, + ); + self.close_locals(uf, state, scope.locals) + } + CanExpr::LetDestruct { + pattern, + value, + body, + } => { + let scope = self.infer_destruct(uf, env, rank, state, rtv, region, pattern, value); + state = self.infer_annotated_expr( + uf, + &scope.env, + rank, + scope.state, + rtv, + body, + expected, + ); + self.close_locals(uf, state, scope.locals) + } + _ => { + let variable = self.src_type_to_var(uf, rank, rtv, typ); + self.infer_expr( + uf, + env, + rank, + state, + rtv, + expr, + expected.type_replace(variable), + ) + } + } + } +} diff --git a/crates/nash-solve/src/solve/infer.rs b/crates/nash-solve/src/solve/infer.rs new file mode 100644 index 00000000..da2c9671 --- /dev/null +++ b/crates/nash-solve/src/solve/infer.rs @@ -0,0 +1,1184 @@ +//! Infer directly from canonical definitions. Only union-find variables cross scopes. +use super::*; +use nash_ast::{Decls, Def, Expr, Module, Pattern}; +use nash_constrain::error::{Context, PCategory, PContext, SubContext}; +use type_::Binder; + +type Rtv<'a> = BTreeMap<&'a str, Variable>; +type Locals<'a> = Vec<(&'a str, Located)>; +type Arguments<'a> = Vec<(&'a Located>, PExpected<'a, Variable>)>; + +#[derive(Clone, Copy)] +pub(super) struct Definition<'a> { + pub site: Binder<'a>, + pub typ: Variable, + pub context: Option<&'a [Body<'a>]>, +} + +pub(super) struct ScopeResult<'a> { + pub env: Env<'a>, + pub state: State<'a>, + pub locals: Locals<'a>, +} + +struct PreparedDefinition<'a> { + definition: Definition<'a>, + arguments: Arguments<'a>, + source: &'a Def<'a>, + expected: Expected<'a, Variable>, + rtv: Rtv<'a>, + rigids: &'a [Variable], +} + +impl<'a> Solver<'a, '_> { + pub(super) fn fresh(&mut self, uf: &mut UnionFind<'a>, rank: usize) -> Variable { + self.register(uf, rank, Content::FlexVar(None)) + } + + pub(super) fn structure( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + typ: FlatType<'a>, + ) -> Variable { + let typ = type_::normalize_application(uf, typ); + self.register(uf, rank, Content::Structure(typ)) + } + + pub(super) fn field( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + mut state: State<'a>, + field: DeferredField<'a>, + ) -> State<'a> { + if let Some((_, typ)) = field.field { + self.dependencies.field(typ, field.record); + } + self.fields.push(field); + self.retry_fields(uf, rank, &mut state.errors); + state + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn equal( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + state: State<'a>, + region: Region, + category: Category<'a>, + actual: Variable, + expectation: Expected<'a, Variable>, + ) -> State<'a> { + let expected = expectation_type(expectation); + if let Expected::FromContext(_, Context::CallArity(_, arity), _) = expectation { + let mut result = expected; + let mut inputs = vec![actual]; + for _ in 0..arity { + let Content::Structure(FlatType::Fun1(argument, output)) = uf.get(result).content + else { + break; + }; + inputs.push(argument); + result = output; + } + self.dependencies.computation(result, inputs); + } + self.formed_at(uf, rank, &[actual, expected], region); + match self.unify(uf, actual, expected) { + unify::Answer::Ok(vars) => { + self.introduce(uf, rank, &vars); + state + } + unify::Answer::Err(vars, actual, expected) => { + self.introduce(uf, rank, &vars); + add_error( + state, + Error::BadExpr(region, category, actual, expectation.type_replace(expected)), + ) + } + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn pattern_equal( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + state: State<'a>, + region: Region, + category: PCategory<'a>, + actual: Variable, + expectation: PExpected<'a, Variable>, + ) -> State<'a> { + let expected = match expectation { + PExpected::NoExpectation(t) | PExpected::FromContext(_, _, t) => t, + }; + self.formed_at(uf, rank, &[actual, expected], region); + match self.unify(uf, actual, expected) { + unify::Answer::Ok(vars) => { + self.introduce(uf, rank, &vars); + state + } + unify::Answer::Err(vars, actual, expected) => { + self.introduce(uf, rank, &vars); + add_error( + state, + Error::BadPattern(region, category, actual, expectation.type_replace(expected)), + ) + } + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn local( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + region: Region, + node: nash_ast::NodeId, + name: &'a str, + expected: Expected<'a, Variable>, + ) -> State<'a> { + let binding = *env.get(name).expect("canonical local is bound"); + let actual = self.instantiate_binding(uf, rank, binding, UseSite { node, region, name }); + self.equal( + uf, + rank, + state, + region, + Category::Local(name), + actual, + expected, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn foreign( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + state: State<'a>, + region: Region, + node: nash_ast::NodeId, + name: &'a str, + annotation: &'a nash_ast::Annotation<'a>, + expected: Expected<'a, Variable>, + ) -> State<'a> { + let actual = + self.src_type_to_variable(uf, rank, UseSite { node, region, name }, annotation); + self.equal( + uf, + rank, + state, + region, + Category::Foreign(name), + actual, + expected, + ) + } + + fn young_pool(&mut self, rank: usize) -> usize { + let young = rank + 1; + if young >= self.pools.len() { + self.pools.resize(self.pools.len() * 2, Vec::new()); + } + assert!( + self.pools[young].is_empty(), + "young pool belongs to one active scope" + ); + young + } + + fn generalize_scope( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + mut state: State<'a>, + ) -> State<'a> { + self.retry_fields(uf, rank, &mut state.errors); + self.finish_fields(uf, rank, &mut state.errors); + self.generalize(uf, state.mark, state.mark.next(), rank); + self.pools[rank].clear(); + state.mark = state.mark.next().next(); + state + } + + pub(super) fn close_locals( + &mut self, + uf: &mut UnionFind<'a>, + state: State<'a>, + locals: Locals<'a>, + ) -> State<'a> { + locals.into_iter().fold(state, |state, (name, loc)| { + self.check_occurs(uf, state, name, loc) + }) + } + + pub(super) fn infer_patterns( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + mut state: State<'a>, + args: &[(&Located>, PExpected<'a, Variable>)], + ) -> ScopeResult<'a> { + let owns = args + .iter() + .any(|(pattern, _)| super::patterns::pattern_owns_vars(pattern)); + let pattern_rank = if owns { self.young_pool(rank) } else { rank }; + let start = self.wanted.len(); + let mut headers = BTreeMap::new(); + for (pattern, expected) in args.iter().rev() { + state = self.infer_pattern(uf, pattern_rank, state, pattern, *expected, &mut headers); + } + self.finish_pattern_scope(uf, env, rank, pattern_rank, state, start, headers) + } + + #[allow(clippy::too_many_arguments)] + fn finish_pattern_scope( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + pattern_rank: usize, + mut state: State<'a>, + start: usize, + headers: BTreeMap<&'a str, Located>, + ) -> ScopeResult<'a> { + self.retry_fields(uf, pattern_rank, &mut state.errors); + state = self.resolve_wanted(uf, pattern_rank, state, start, None, false); + if pattern_rank != rank { + state = self.generalize_scope(uf, pattern_rank, state); + } + let locals: Locals<'a> = headers.into_iter().collect(); + let mut env = env.clone(); + for (name, loc) in &locals { + env.entry(name).or_insert(Binding { + variable: loc.value, + context: &[], + definition: None, + context_is_final: true, + declared_quantifiers: &[], + }); + } + ScopeResult { env, state, locals } + } + + fn prepare_definition( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + rtv: &Rtv<'a>, + def: &'a Def<'a>, + ) -> PreparedDefinition<'a> { + let mut rtv = rtv.clone(); + let mut rigids = Vec::new(); + let (name, arguments, result, context) = match def { + Def::Def { name, args, .. } => { + let arguments: Arguments<'a> = args + .iter() + .map(|pattern| (*pattern, PExpected::NoExpectation(self.fresh(uf, rank)))) + .collect(); + (*name, arguments, self.fresh(uf, rank), None) + } + Def::TypedDef { + name, + free_vars, + context, + args, + typ, + .. + } => { + let mut names: Vec<_> = free_vars + .iter() + .copied() + .filter(|name| !rtv.contains_key(name)) + .collect(); + names.sort_unstable(); + for name in names { + let var = self.register(uf, rank, Content::RigidVar(name)); + rigids.push(var); + rtv.insert(name, var); + } + let arguments: Arguments<'a> = args + .iter() + .enumerate() + .map(|(index, arg)| { + let typ = self.src_type_to_var(uf, rank, &rtv, arg.typ); + ( + arg.pattern, + PExpected::FromContext( + arg.pattern.region, + PContext::TypedArg(name.value, index), + typ, + ), + ) + }) + .collect(); + let result = self.src_type_to_var(uf, rank, &rtv, typ); + let context: Vec<_> = context + .iter() + .map(|pred| self.canonical_predicate(uf, rank, &rtv, *pred)) + .collect(); + ( + *name, + arguments, + result, + Some(&*self.bump.alloc_slice_fill_iter(context)), + ) + } + }; + let typ = arguments + .iter() + .rev() + .fold(result, |result, (_, expected)| { + let arg = match expected { + PExpected::NoExpectation(t) | PExpected::FromContext(_, _, t) => *t, + }; + self.structure(uf, rank, FlatType::Fun1(arg, result)) + }); + let expected = if context.is_some() { + Expected::FromAnnotation(name.value, arguments.len(), SubContext::TypedBody, result) + } else { + Expected::NoExpectation(result) + }; + PreparedDefinition { + definition: Definition { + site: Binder::Named(name), + typ, + context, + }, + arguments, + source: def, + expected, + rtv, + rigids: self.bump.alloc_slice_copy(&rigids), + } + } + + fn infer_prepared_body( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + prepared: &PreparedDefinition<'a>, + ) -> State<'a> { + match prepared.source { + Def::Def { body, .. } => { + let scope = self.infer_patterns(uf, env, rank, state, &prepared.arguments); + let state = self.infer_expr( + uf, + &scope.env, + rank, + scope.state, + &prepared.rtv, + body, + prepared.expected, + ); + self.close_locals(uf, state, scope.locals) + } + Def::TypedDef { + name, + args, + body, + typ, + .. + } => { + let scope = self.infer_typed_patterns( + uf, + env, + rank, + state, + name.value, + args, + &prepared.rtv, + ); + let state = self.infer_annotated_expr( + uf, + &scope.env, + rank, + scope.state, + &prepared.rtv, + body, + prepared.expected.type_replace(*typ), + ); + self.close_locals(uf, state, scope.locals) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn infer_typed_patterns( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + mut state: State<'a>, + name: &'a str, + args: &[nash_ast::TypedPattern<'a>], + rtv: &Rtv<'a>, + ) -> ScopeResult<'a> { + let owns = args + .iter() + .any(|arg| super::patterns::pattern_owns_vars(arg.pattern)); + let pattern_rank = if owns { self.young_pool(rank) } else { rank }; + let start = self.wanted.len(); + let mut headers = BTreeMap::new(); + for (index, arg) in args.iter().enumerate().rev() { + state = self.infer_canonical_pattern( + uf, + pattern_rank, + state, + arg.pattern, + PExpected::FromContext( + arg.pattern.region, + PContext::TypedArg(name, index), + arg.typ, + ), + rtv, + &mut headers, + ); + } + self.finish_pattern_scope(uf, env, rank, pattern_rank, state, start, headers) + } + + #[allow(clippy::too_many_arguments)] + fn finish_bindings( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + mut state: State<'a>, + definitions: &[Definition<'a>], + declarations: &[Definition<'a>], + rigids: &'a [Variable], + locals: Locals<'a>, + declared: &BTreeMap<&'a str, &'a [type_::PredId]>, + binder: Option>, + given: &[Body<'a>], + wanted_start: usize, + errors_before: usize, + ) -> ScopeResult<'a> { + let young = rank + 1; + state = self.generalize_scope(uf, young, state); + for rigid in rigids { + if uf.get(*rigid).rank != NO_RANK && !crate::recovery::is_poisoned(uf, [*rigid]) { + let owner = binder + .map(|b| (b.name().region, b.name().value)) + .or_else(|| locals.first().map(|(name, loc)| (loc.region, *name))); + state.errors.push(Error::AnnotationVariableEscapes { + region: owner.map_or_else(Region::zero, |(r, _)| r), + name: owner.map(|(_, n)| n), + variable: to_error_type(self.bump, uf, *rigid), + }); + } + } + if let Some(binder) = binder { + let depth = self.enter_givens(uf, rank, given, Some(binder)); + loop { + let (errors, defaulted) = + self.check_ambiguity(uf, rank, wanted_start, definitions, binder.name()); + state.errors.extend(errors); + if !defaulted { + break; + } + state = self.resolve_wanted( + uf, + young, + state, + wanted_start, + Some(binder), + definitions.iter().any(|d| d.context.is_some()), + ); + } + self.givens.truncate(depth); + } + let context = if !definitions.is_empty() && definitions.iter().all(|d| d.context.is_none()) + { + self.retain_wanted( + uf, + rank, + wanted_start, + binder.expect("inferred binding owner").node(), + ) + } else { + &[] + }; + if state.errors.len() > errors_before + && let Some(binder) = binder + { + self.fail_definition(uf, binder.node()); + } + self.record_definitions(uf, rank, definitions, declared, context, binder); + let mut env = env.clone(); + for (name, loc) in &locals { + env.entry(name).or_insert(Binding { + variable: loc.value, + context: declared.get(name).copied().unwrap_or(context), + definition: definitions + .iter() + .chain(declarations) + .find(|d| d.site.name().value == *name) + .map(|d| d.site.node()) + .or_else(|| { + binder + .filter(|b| matches!(b, Binder::Pattern { .. })) + .map(Binder::node) + }), + context_is_final: true, + declared_quantifiers: if declarations + .iter() + .any(|d| d.site.name().value == *name && d.context.is_some()) + { + rigids + } else { + &[] + }, + }); + } + ScopeResult { env, state, locals } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn infer_definition( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + rtv: &Rtv<'a>, + def: &'a Def<'a>, + bind_name: bool, + ) -> ScopeResult<'a> { + let young = self.young_pool(rank); + let prepared = self.prepare_definition(uf, young, rtv, def); + self.check_prepared(uf, env, rank, state, prepared, bind_name) + } + + fn check_prepared( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + prepared: PreparedDefinition<'a>, + bind_name: bool, + ) -> ScopeResult<'a> { + let young = rank + 1; + let errors_before = state.errors.len(); + let start = self.wanted.len(); + let definition = prepared.definition; + let binder = Some(definition.site); + let declared = self.declared_contexts(uf, young, &[definition], &[]); + let given = definition.context.unwrap_or(&[]); + let depth = self.enter_givens(uf, young, given, binder); + let owners = self.owners.len(); + self.owners.push(definition.site.node()); + let mut state = self.infer_prepared_body(uf, env, young, state, &prepared); + self.retry_fields(uf, young, &mut state.errors); + state = self.resolve_wanted( + uf, + young, + state, + start, + binder, + definition.context.is_some(), + ); + if state.errors.len() > errors_before { + self.fail_definition(uf, definition.site.node()); + } + self.givens.truncate(depth); + self.owners.truncate(owners); + let name = definition.site.name(); + let locals = if bind_name { + vec![(name.value, Located::at(name.region, definition.typ))] + } else { + Vec::new() + }; + self.finish_bindings( + uf, + env, + rank, + state, + &[definition], + &[], + prepared.rigids, + locals, + &declared, + binder, + given, + start, + errors_before, + ) + } + + pub(super) fn infer_module( + &mut self, + uf: &mut UnionFind<'a>, + module: &Module<'a>, + state: State<'a>, + ) -> State<'a> { + self.infer_decls(uf, &Env::new(), OUTERMOST_RANK, state, module.decls, module) + } + + fn infer_decls( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + decls: &Decls<'a>, + module: &Module<'a>, + ) -> State<'a> { + let (scope, next) = match decls { + Decls::Declare { definition, next } => ( + self.infer_definition(uf, env, rank, state, &Rtv::new(), definition, true), + *next, + ), + Decls::DeclareRec { + definition, + following, + next, + } => { + let mut defs = vec![*definition]; + defs.extend_from_slice(following); + ( + self.infer_group(uf, env, rank, state, &Rtv::new(), &defs), + *next, + ) + } + Decls::Empty => { + let mut state = state; + for definition in module + .traits + .iter() + .flat_map(|t| t.value.methods.iter().filter_map(|m| m.default)) + .chain( + module + .impls + .iter() + .flat_map(|i| i.value.methods.iter().copied()), + ) + { + let scope = + self.infer_definition(uf, env, rank, state, &Rtv::new(), definition, false); + state = self.close_locals(uf, scope.state, scope.locals); + } + state.env = env.clone(); + return state; + } + }; + let state = self.infer_decls(uf, &scope.env, rank, scope.state, next, module); + self.close_locals(uf, state, scope.locals) + } +} + +pub(super) fn expectation_type(expected: Expected<'_, Variable>) -> Variable { + match expected { + Expected::NoExpectation(t) + | Expected::FromContext(_, _, t) + | Expected::FromAnnotation(_, _, _, t) => t, + } +} + +impl<'a> Solver<'a, '_> { + pub(super) fn infer_group( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + mut state: State<'a>, + rtv: &Rtv<'a>, + defs: &[&'a Def<'a>], + ) -> ScopeResult<'a> { + let owns_rigids = defs.iter().any(|def| match def { + Def::TypedDef { free_vars, .. } => free_vars.iter().any(|name| !rtv.contains_key(name)), + _ => false, + }); + let declared_rank = if owns_rigids { + self.young_pool(rank) + } else { + rank + }; + let mut typed = Vec::new(); + for def in defs + .iter() + .filter(|def| matches!(def, Def::TypedDef { .. })) + { + typed.push((*def, self.prepare_definition(uf, declared_rank, rtv, def))); + } + let declarations: Vec<_> = typed.iter().map(|(_, p)| p.definition).collect(); + let rigid_vars: Vec<_> = typed + .iter() + .rev() + .flat_map(|(_, p)| p.rigids.iter().copied()) + .collect(); + let rigid_vars = &*self.bump.alloc_slice_copy(&rigid_vars); + let declared = self.declared_contexts(uf, declared_rank, &[], &declarations); + if owns_rigids { + state = self.generalize_scope(uf, declared_rank, state); + } + let mut declared_env = env.clone(); + let mut locals = BTreeMap::new(); + for declaration in &declarations { + let name = declaration.site.name(); + locals.insert(name.value, Located::at(name.region, declaration.typ)); + declared_env.entry(name.value).or_insert(Binding { + variable: declaration.typ, + context: declared.get(name.value).copied().unwrap_or(&[]), + definition: Some(declaration.site.node()), + context_is_final: true, + declared_quantifiers: rigid_vars, + }); + } + let inferred_defs: Vec<_> = defs + .iter() + .filter(|def| matches!(def, Def::Def { .. })) + .copied() + .collect(); + let mut result = if inferred_defs.is_empty() { + ScopeResult { + env: declared_env, + state, + locals: Vec::new(), + } + } else { + let young = self.young_pool(rank); + let prepared: Vec<_> = inferred_defs + .iter() + .map(|def| self.prepare_definition(uf, young, rtv, def)) + .collect(); + let definitions: Vec<_> = prepared.iter().map(|p| p.definition).collect(); + let binder = Some(definitions[0].site); + let mut recursive_env = declared_env.clone(); + let mut inferred_locals = BTreeMap::new(); + for definition in &definitions { + let name = definition.site.name(); + inferred_locals.insert(name.value, Located::at(name.region, definition.typ)); + recursive_env.entry(name.value).or_insert(Binding { + variable: definition.typ, + context: &[], + definition: Some(definition.site.node()), + context_is_final: false, + declared_quantifiers: &[], + }); + } + let errors_before = state.errors.len(); + let start = self.wanted.len(); + let owners = self.owners.len(); + self.owners.push(definitions[0].site.node()); + for prepared in prepared.iter().rev() { + state = self.infer_prepared_body(uf, &recursive_env, young, state, prepared); + } + // The recursive headers are checked inside the group before its + // generalization, as well as after the group's continuation. + state = self.close_locals( + uf, + state, + inferred_locals + .iter() + .map(|(name, loc)| (*name, *loc)) + .collect(), + ); + self.retry_fields(uf, young, &mut state.errors); + state = self.resolve_wanted(uf, young, state, start, binder, false); + self.owners.truncate(owners); + self.finish_bindings( + uf, + &declared_env, + rank, + state, + &definitions, + &[], + &[], + inferred_locals.into_iter().collect(), + &BTreeMap::new(), + binder, + &[], + start, + errors_before, + ) + }; + for (def, prepared) in typed.into_iter().rev() { + let young = self.young_pool(rank); + self.introduce(uf, young, prepared.rigids); + let mut body = self.prepare_definition(uf, young, &prepared.rtv, def); + body.rigids = prepared.rigids; + let checked = self.check_prepared(uf, &result.env, rank, result.state, body, false); + result.state = self.close_locals(uf, checked.state, checked.locals); + } + locals.extend(result.locals); + result.locals = locals.into_iter().collect(); + result + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn infer_destruct( + &mut self, + uf: &mut UnionFind<'a>, + env: &Env<'a>, + rank: usize, + state: State<'a>, + rtv: &Rtv<'a>, + region: Region, + pattern: &'a Located>, + expr: &'a Located>, + ) -> ScopeResult<'a> { + let young = self.young_pool(rank); + let typ = self.fresh(uf, young); + let binder = Binder::Pattern { + node: nash_ast::NodeId::pattern(pattern), + name: self.bump.alloc(Located::at(region, "")), + }; + let definition = Definition { + site: binder, + typ, + context: None, + }; + let mut headers = BTreeMap::new(); + let errors_before = state.errors.len(); + let start = self.wanted.len(); + let owners = self.owners.len(); + self.owners.push(binder.node()); + let state = self.infer_pattern( + uf, + young, + state, + pattern, + PExpected::NoExpectation(typ), + &mut headers, + ); + let mut state = self.infer_expr( + uf, + env, + young, + state, + rtv, + expr, + Expected::FromContext(region, Context::Destructure, typ), + ); + self.retry_fields(uf, young, &mut state.errors); + state = self.resolve_wanted(uf, young, state, start, Some(binder), false); + self.owners.truncate(owners); + self.finish_bindings( + uf, + env, + rank, + state, + &[definition], + &[], + &[], + headers.into_iter().collect(), + &BTreeMap::new(), + Some(binder), + &[], + start, + errors_before, + ) + } +} + +#[cfg(test)] +mod preparation_tests { + use super::*; + use nash_ast::{Annotation, ModuleName, NodeId, Pred, QualifiedName, Type as CanType}; + + #[test] + fn apply_head_and_arguments_share_the_signature_substitution() { + let bump = Bump::new(); + let tables = nash_can::environment::Tables::default(); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let f = solver.fresh(&mut uf, 2); + let a = solver.fresh(&mut uf, 2); + let scope = BTreeMap::from([("f", f), ("a", a)]); + let head = &*bump.alloc(Located::at_zero(CanType::Var("f"))); + let arg = &*bump.alloc(Located::at_zero(CanType::Var("a"))); + let args = bump.alloc_slice_copy(&[arg]); + let application = + solver.canonical_predicate(&mut uf, 2, &scope, Pred::Apply { head, args }); + let Body::Apply { + head: actual_head, + args: actual_args, + } = application + else { + panic!("Apply preserved") + }; + assert_eq!(actual_head, f); + assert_eq!(actual_args, [a]); + let representation = solver.canonical_predicate( + &mut uf, + 2, + &scope, + Pred::Implied { + trait_: nash_ast::primitives::ReprTrait::Big.qualified(), + args, + }, + ); + let Body::Trait { + hidden, + args, + trait_, + } = representation + else { + panic!("representation predicate preserved") + }; + assert!(hidden); + assert_eq!(trait_, nash_ast::primitives::ReprTrait::Big.qualified()); + assert_eq!(args, [a]); + let signature = Located::at_zero(CanType::App { + head, + args: bump.alloc_slice_copy(&[arg]), + }); + let signature = solver.src_type_to_var(&mut uf, 2, &scope, &signature); + let Content::Structure(FlatType::AppV1(type_head, type_args)) = &uf.get(signature).content + else { + panic!("type application preserved") + }; + assert_eq!(*type_head, actual_head); + assert_eq!(type_args, &actual_args); + } + + #[test] + fn prepared_rigids_preserve_order_captures_and_predicate_substitution() { + let bump = Bump::new(); + let tables = nash_can::environment::Tables::default(); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let captured = solver.register(&mut uf, 1, Content::RigidVar("captured")); + let scope = BTreeMap::from([("captured", captured)]); + let z = &*bump.alloc(Located::at_zero(CanType::Var("z"))); + let cap = &*bump.alloc(Located::at_zero(CanType::Var("captured"))); + let a = &*bump.alloc(Located::at_zero(CanType::Var("a"))); + let result = &*bump.alloc(Located::at_zero(CanType::App { + head: z, + args: bump.alloc_slice_copy(&[cap, a]), + })); + let definition = bump.alloc(Def::TypedDef { + name: bump.alloc(Located::at_zero("test")), + free_vars: &["z", "captured", "a"], + context: bump.alloc_slice_copy(&[Pred::Apply { + head: z, + args: bump.alloc_slice_copy(&[cap, a]), + }]), + annotation: result, + args: &[], + body: bump.alloc(Located::at_zero(Expr::Unit)), + typ: result, + }); + let prepared = solver.prepare_definition(&mut uf, 2, &scope, definition); + assert_eq!(prepared.rigids.len(), 2); + assert!(matches!( + uf.get(prepared.rigids[0]).content, + Content::RigidVar("a") + )); + assert!(matches!( + uf.get(prepared.rigids[1]).content, + Content::RigidVar("z") + )); + assert_eq!(prepared.rtv["captured"], captured); + assert_eq!(uf.get(captured).rank, 1); + assert!(prepared.rigids.iter().all(|var| uf.get(*var).rank == 2)); + let [Body::Apply { head, args }] = prepared.definition.context.unwrap() else { + panic!("Apply context") + }; + assert_eq!(*head, prepared.rigids[1]); + assert_eq!(args, &[captured, prepared.rigids[0]]); + let Content::Structure(FlatType::AppV1(type_head, type_args)) = + &uf.get(prepared.definition.typ).content + else { + panic!("signature application") + }; + assert_eq!(type_head, head); + assert_eq!(type_args, args); + let other = solver.prepare_definition(&mut uf, 2, &scope, definition); + assert_eq!(other.rtv["captured"], captured); + assert_ne!(other.rtv["a"], prepared.rtv["a"]); + assert_ne!(other.rtv["z"], prepared.rtv["z"]); + } + + #[test] + fn literals_and_patterns_keep_original_nodes_and_ordered_predicates() { + let bump = Bump::new(); + let mut tables = nash_can::environment::Tables::default(); + for trait_ in [ + type_::literal_trait("FromInt"), + type_::literal_trait("FromString"), + type_::literal_trait("FromBytes"), + type_::eq_trait(), + ] { + tables.kinds.traits.insert(trait_, &[&nash_ast::Kind::Type]); + } + for (expression, pattern, trait_name) in [ + (Expr::Int(7), Pattern::Int(7), "FromInt"), + ( + Expr::Bytes(&[0, 255]), + Pattern::Bytes(&[0, 255]), + "FromBytes", + ), + (Expr::Str("nash"), Pattern::Str("nash"), "FromString"), + ] { + let expression = bump.alloc(Located::at_zero(expression)); + let pattern = bump.alloc(Located::at_zero(pattern)); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let value = solver.fresh(&mut uf, 2); + let state = State { + env: Env::new(), + mark: NO_MARK.next(), + errors: Vec::new(), + }; + let state = solver.infer_expr( + &mut uf, + &Env::new(), + 2, + state, + &Rtv::new(), + expression, + Expected::NoExpectation(value), + ); + assert!(state.errors.is_empty()); + assert_eq!(solver.uses.len(), 1); + let use_ = &solver.uses[0]; + assert_eq!(use_.site.node, NodeId::expr(expression)); + assert_eq!(use_.predicates.len(), 1); + let expression_predicate = &solver.predicates.get(use_.predicates[0]).body; + let trait_ = expression_predicate.trait_ref().unwrap(); + assert_eq!(trait_.name, trait_name); + assert_eq!(trait_.home.package, Some(nash_ast::primitives::CORE)); + let value = solver.fresh(&mut uf, 2); + let mut headers = BTreeMap::new(); + let state = solver.infer_pattern( + &mut uf, + 2, + state, + pattern, + PExpected::NoExpectation(value), + &mut headers, + ); + assert!(state.errors.is_empty()); + assert_eq!( + solver.uses.len(), + 2, + "one evidence use per literal expression/pattern" + ); + let use_ = &solver.uses[1]; + assert_eq!(use_.site.node, NodeId::pattern(pattern)); + let predicates: Vec<_> = use_ + .predicates + .iter() + .map(|id| &solver.predicates.get(*id).body) + .collect(); + assert_eq!( + predicates + .iter() + .map(|pred| pred.trait_ref().unwrap().name) + .collect::>(), + [trait_name, "Eq"] + ); + assert_eq!( + predicates[0].args(), + predicates[1].args(), + "literal and Eq use one substitution" + ); + assert_eq!(predicates[0].args().len(), 1); + } + } + + #[test] + fn operator_and_method_uses_keep_distinct_node_identity_at_the_same_region() { + let bump = Bump::new(); + let tables = nash_can::environment::Tables::default(); + let mut solver = Solver::new(&bump, &tables); + let mut uf = UnionFind::new(); + let unit = &*bump.alloc(Located::at_zero(CanType::unit())); + let method_annotation = bump.alloc(Annotation { + free_vars: &[], + context: &[], + typ: unit, + }); + let tail = bump.alloc(Located::at_zero(CanType::Lambda { + from: unit, + to: unit, + })); + let op_annotation = bump.alloc(Annotation { + free_vars: &[], + context: &[], + typ: bump.alloc(Located::at_zero(CanType::Lambda { + from: unit, + to: tail, + })), + }); + let reference = QualifiedName { + home: ModuleName { + package: None, + name: "Main", + }, + name: "Combine", + }; + let local = &*bump.alloc(Located::at_zero(Expr::VarLocal("x"))); + let method = &*bump.alloc(Located::at_zero(Expr::VarMethod { + trait_: reference, + method: "combine", + annotation: method_annotation, + })); + let operator = &*bump.alloc(Located::at_zero(Expr::Binop { + symbol: "+", + operator_home: reference.home, + reference, + annotation: op_annotation, + left: local, + right: method, + })); + let name = bump.alloc(Located::at_zero("x")); + let variable = solver.src_type_to_var(&mut uf, 2, &Rtv::new(), unit); + let env = Env::from([( + "x", + Binding { + variable, + context: &[], + definition: Some(NodeId::def(name)), + context_is_final: true, + declared_quantifiers: &[], + }, + )]); + let state = State { + env: Env::new(), + mark: NO_MARK.next(), + errors: Vec::new(), + }; + let state = solver.infer_expr( + &mut uf, + &env, + 2, + state, + &Rtv::new(), + operator, + Expected::NoExpectation(variable), + ); + assert!(state.errors.is_empty()); + let nodes: Vec<_> = solver.uses.iter().map(|use_| use_.site.node).collect(); + assert_eq!( + nodes, + [ + NodeId::expr(operator), + NodeId::expr(local), + NodeId::expr(method) + ] + ); + assert_ne!(nodes[0], nodes[1]); + assert_ne!(nodes[0], nodes[2]); + assert_ne!(nodes[1], nodes[2]); + assert!( + solver + .uses + .iter() + .all(|use_| use_.site.region == Region::zero()) + ); + } +} diff --git a/crates/nash-solve/src/solve/patterns.rs b/crates/nash-solve/src/solve/patterns.rs new file mode 100644 index 00000000..5dbc8055 --- /dev/null +++ b/crates/nash-solve/src/solve/patterns.rs @@ -0,0 +1,328 @@ +use super::*; +use nash_constrain::error::{PCategory, PContext}; + +/// Match whether the former pattern state would own any fresh variables. +/// The caller must select the pattern's rank before inference begins. +pub(super) fn pattern_owns_vars(pattern: &Located>) -> bool { + use nash_ast::Pattern; + match &pattern.value { + Pattern::Anything | Pattern::Var(_) | Pattern::Unit | Pattern::Bool { .. } => false, + Pattern::Alias { pattern, .. } => pattern_owns_vars(pattern), + Pattern::Constructor(ctor) => { + !ctor.union.parameters.is_empty() + || ctor + .arguments + .iter() + .any(|arg| pattern_owns_vars(arg.pattern)) + } + Pattern::Tuple { .. } + | Pattern::List(_) + | Pattern::Cons { .. } + | Pattern::Record(_) + | Pattern::Int(_) + | Pattern::Str(_) + | Pattern::Bytes(_) => true, + } +} + +impl<'a> Solver<'a, '_> { + #[allow(clippy::too_many_arguments)] + pub(super) fn infer_pattern( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + mut state: State<'a>, + pattern: &Located>, + expected: PExpected<'a, Variable>, + headers: &mut BTreeMap<&'a str, Located>, + ) -> State<'a> { + use nash_ast::Pattern; + let region = pattern.region; + let expected_var = match expected { + PExpected::NoExpectation(var) | PExpected::FromContext(_, _, var) => var, + }; + match &pattern.value { + Pattern::Anything => state, + Pattern::Var(name) => { + headers.insert(name, Located::at(region, expected_var)); + state + } + Pattern::Alias { + pattern: inner, + name, + } => { + headers.insert(name, Located::at(region, expected_var)); + self.infer_pattern(uf, rank, state, inner, expected, headers) + } + Pattern::Unit | Pattern::Bool { .. } => { + let (name, category) = if matches!(pattern.value, Pattern::Unit) { + ("unit", PCategory::Unit) + } else { + ("bool", PCategory::Bool) + }; + let actual = self.register( + uf, + rank, + Content::Structure(FlatType::App1( + nash_ast::primitives::builtin_home(), + name, + Vec::new(), + )), + ); + self.pattern_equal(uf, rank, state, region, category, actual, expected) + } + Pattern::Tuple { + first, + second, + rest, + } => { + let first_var = self.register(uf, rank, Content::FlexVar(None)); + let second_var = self.register(uf, rank, Content::FlexVar(None)); + let rest_vars: Vec<_> = rest + .iter() + .map(|_| self.register(uf, rank, Content::FlexVar(None))) + .collect(); + let actual = self.register( + uf, + rank, + Content::Structure(FlatType::Tuple1(first_var, second_var, rest_vars.clone())), + ); + state = + self.pattern_equal(uf, rank, state, region, PCategory::Tuple, actual, expected); + for (item, var) in rest.iter().zip(rest_vars).rev() { + state = self.infer_pattern( + uf, + rank, + state, + item, + PExpected::NoExpectation(var), + headers, + ); + } + state = self.infer_pattern( + uf, + rank, + state, + second, + PExpected::NoExpectation(second_var), + headers, + ); + self.infer_pattern( + uf, + rank, + state, + first, + PExpected::NoExpectation(first_var), + headers, + ) + } + Pattern::Constructor(ctor) => { + let pairs: Vec<_> = ctor + .union + .parameters + .iter() + .map(|name| (*name, self.register(uf, rank, Content::FlexVar(Some(name))))) + .collect(); + let variables: BTreeMap<_, _> = pairs.iter().copied().collect(); + let actual = self.register( + uf, + rank, + Content::Structure(FlatType::App1( + ctor.reference.home, + ctor.reference.union, + pairs.iter().map(|(_, var)| *var).collect(), + )), + ); + state = self.pattern_equal( + uf, + rank, + state, + region, + PCategory::Ctor(ctor.reference.name), + actual, + expected, + ); + for arg in ctor.arguments.iter().rev() { + state = self.infer_canonical_pattern( + uf, + rank, + state, + arg.pattern, + PExpected::FromContext( + region, + PContext::CtorArg(ctor.reference.name, arg.index as usize), + arg.typ, + ), + &variables, + headers, + ); + } + state + } + Pattern::List(items) => { + let entry = self.register(uf, rank, Content::FlexVar(None)); + let list = self.register( + uf, + rank, + Content::Structure(FlatType::App1( + nash_ast::primitives::builtin_home(), + "list", + vec![entry], + )), + ); + state = + self.pattern_equal(uf, rank, state, region, PCategory::List, list, expected); + for (index, item) in items.iter().enumerate().rev() { + state = self.infer_pattern( + uf, + rank, + state, + item, + PExpected::FromContext(region, PContext::ListEntry(index), entry), + headers, + ); + } + state + } + Pattern::Cons { head, tail } => { + let entry = self.register(uf, rank, Content::FlexVar(None)); + let list = self.register( + uf, + rank, + Content::Structure(FlatType::App1( + nash_ast::primitives::builtin_home(), + "list", + vec![entry], + )), + ); + state = + self.pattern_equal(uf, rank, state, region, PCategory::List, list, expected); + state = self.infer_pattern( + uf, + rank, + state, + head, + PExpected::NoExpectation(entry), + headers, + ); + // The tail has its own structural expectation; a failed outer + // comparison must not poison this independent equation. + let list = self.structure( + uf, + rank, + FlatType::App1(nash_ast::primitives::builtin_home(), "list", vec![entry]), + ); + self.infer_pattern( + uf, + rank, + state, + tail, + PExpected::FromContext(region, PContext::Tail, list), + headers, + ) + } + Pattern::Record(fields) => { + let record = self.register(uf, rank, Content::FlexVar(None)); + // Allocate in source order, then execute field checks in the + // reverse order used by the former rev_cons list. + let fields_with_vars: Vec<_> = fields + .iter() + .map(|field| { + let var = self.register(uf, rank, Content::FlexVar(None)); + headers + .entry(*field) + .or_insert_with(|| Located::at(region, var)); + (*field, var) + }) + .collect(); + if fields.is_empty() { + state = self.field( + uf, + rank, + state, + DeferredField { + region, + context: type_::FieldContext::Pattern, + record, + field: None, + }, + ); + } + for field in fields_with_vars.into_iter().rev() { + state = self.field( + uf, + rank, + state, + DeferredField { + region, + context: type_::FieldContext::Pattern, + record, + field: Some(field), + }, + ); + } + self.pattern_equal(uf, rank, state, region, PCategory::Record, record, expected) + } + Pattern::Int(_) | Pattern::Str(_) | Pattern::Bytes(_) => { + let (trait_name, category) = match pattern.value { + Pattern::Int(_) => ("FromInt", PCategory::Int), + Pattern::Bytes(_) => ("FromBytes", PCategory::Bytes), + _ => ("FromString", PCategory::Str), + }; + let actual = self.register(uf, rank, Content::FlexVar(None)); + let annotation = type_::literal_annotation( + self.bump, + &[type_::literal_trait(trait_name), type_::eq_trait()], + ); + state = self.foreign( + uf, + rank, + state, + region, + nash_ast::NodeId::pattern(pattern), + "literal", + annotation, + Expected::NoExpectation(actual), + ); + self.pattern_equal(uf, rank, state, region, category, actual, expected) + } + } + } +} + +impl<'a> Solver<'a, '_> { + /// Canonical structures are materialized separately for alias bindings and + /// pattern equations. Canonical variables still resolve through the same + /// substitution, even when inference has already learned their structure. + #[allow(clippy::too_many_arguments)] + pub(super) fn infer_canonical_pattern( + &mut self, + uf: &mut UnionFind<'a>, + rank: usize, + state: State<'a>, + pattern: &Located>, + expected: PExpected<'a, &'a Located>>, + variables: &BTreeMap<&'a str, Variable>, + headers: &mut BTreeMap<&'a str, Located>, + ) -> State<'a> { + let (PExpected::NoExpectation(typ) | PExpected::FromContext(_, _, typ)) = expected; + let var = self.src_type_to_var(uf, rank, variables, typ); + match &pattern.value { + nash_ast::Pattern::Alias { + pattern: inner, + name, + } => { + headers.insert(name, Located::at(pattern.region, var)); + self.infer_canonical_pattern(uf, rank, state, inner, expected, variables, headers) + } + _ => self.infer_pattern( + uf, + rank, + state, + pattern, + expected.type_replace(var), + headers, + ), + } + } +} diff --git a/crates/nash-solve/tests/evidence.rs b/crates/nash-solve/tests/evidence.rs index 0df65cb1..d824b339 100644 --- a/crates/nash-solve/tests/evidence.rs +++ b/crates/nash-solve/tests/evidence.rs @@ -17,8 +17,8 @@ fn fixture<'a>(bump: &'a Bump, source: &str) -> (Tables<'a>, Annotations<'a>) { ) .unwrap(); let mut uf = nash_constrain::UnionFind::new(); - let constraint = nash_constrain::constrain(bump, &mut uf, &canonical.module); - let (annotations, _) = nash_solve::run(bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, _) = nash_solve::run(bump, &mut uf, module, &canonical.tables).unwrap(); (canonical.tables, annotations) } diff --git a/crates/nash-solve/tests/inference.rs b/crates/nash-solve/tests/inference.rs index e24e1475..04a79289 100644 --- a/crates/nash-solve/tests/inference.rs +++ b/crates/nash-solve/tests/inference.rs @@ -42,8 +42,8 @@ fn literal_interfaces(bump: &Bump) -> std::collections::BTreeMap<&str, nash_can: ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(bump, &mut uf, &can.module); - let (annotations, _) = nash_solve::run(bump, &mut uf, &constraint, &can.tables).unwrap(); + let module = &can.module; + let (annotations, _) = nash_solve::run(bump, &mut uf, module, &can.tables).unwrap(); interfaces.insert( "Literal", nash_can::from_module(bump, &can.module, &annotations), @@ -67,9 +67,8 @@ fn infer<'a>(bump: &'a Bump, input: &str) -> Result, Vec(constraint: &'b Constraint<'a>, found: &mut Vec<&'b Constraint<'a>>) { - match constraint { - Constraint::Let { - definitions: defs, - header_con, - body_con, - .. - } => { - if !defs.is_empty() { - found.push(constraint); - } - definitions(header_con, found); - definitions(body_con, found); - } - Constraint::And(constraints) => { - for constraint in *constraints { - definitions(constraint, found); - } - } - _ => {} - } - } - let bump = Bump::new(); let source = indoc!( " @@ -1470,7 +1442,8 @@ fn recursive_definition_metadata_preserves_names_types_and_given_variables() { ); let parsed = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap(); - let mut original_names = Vec::new(); + let mut definitions = std::collections::BTreeMap::new(); + let mut inferred_group_first = None; let mut decls = canonical.module.decls; loop { let (defs, next) = match decls { @@ -1489,73 +1462,105 @@ fn recursive_definition_metadata_preserves_names_types_and_given_variables() { }; for def in defs { let (nash_ast::Def::Def { name, .. } | nash_ast::Def::TypedDef { name, .. }) = def; - original_names.push(*name); + if matches!(def, nash_ast::Def::Def { .. }) { + inferred_group_first.get_or_insert(nash_ast::NodeId::def(name)); + } + definitions.insert(name.value, def); } decls = next; } - let nash_ast::Def::TypedDef { name, .. } = - canonical.module.traits[0].value.methods[0].default.unwrap() + let method = canonical.module.traits[0].value.methods[0].default.unwrap(); + let nash_ast::Def::TypedDef { + name: method_name, .. + } = method else { panic!("typed default") }; - original_names.push(*name); + definitions.insert(method_name.value, method); + assert_eq!( + definitions.keys().copied().collect::>(), + ["f", "g", "h", "keep"] + ); + let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let mut found = Vec::new(); - definitions(&constraint, &mut found); - let mut names = Vec::new(); - for constraint in found { - let Constraint::Let { - given, - binder: Some(binder), - definitions, - header, - rigid_vars, - .. - } = constraint + let (annotations, solved) = + nash_solve::run(&bump, &mut uf, &canonical.module, &canonical.tables) + .expect("recursive schemes and default method solve"); + assert_eq!( + annotations.keys().copied().collect::>(), + ["f", "g", "h"] + ); + assert!( + !annotations.contains_key("keep"), + "method must not be published as a lexical binding" + ); + assert_eq!( + solved.schemes.len(), + 4, + "one scheme per original definition" + ); + let group_binder = inferred_group_first.expect("two inferred recursive members"); + + for (text, definition) in definitions { + let (nash_ast::Def::Def { name, body, .. } | nash_ast::Def::TypedDef { name, body, .. }) = + definition; + // Lookup through the original arena name, not a rebuilt name or region. + let node = nash_ast::NodeId::def(name); + let scheme = &solved.schemes[&node]; + assert_eq!( + scheme.binder, + if text == "g" || text == "h" { + group_binder + } else { + node + } + ); + let annotation = scheme.annotation; + let CanType::Lambda { + from: argument, + to: result, + } = annotation.typ.value else { - panic!("each definition scope must have an evidence binder"); + panic!("{text}: preserve the full function type") }; - assert!(std::ptr::eq(binder.name(), definitions[0].site.name())); - for definition in *definitions { - assert!( - original_names - .iter() - .any(|name| std::ptr::eq(*name, definition.site.name())) - ); - names.push(definition.site.name().value); - assert!( - matches!(definition.typ, Type::FunN(..)), - "retain the full function type" - ); - } - if binder.name().value == "f" || binder.name().value == "keep" { - assert_eq!(given.len(), 1); - let Type::VarN(predicate_var) = given[0].types().next().unwrap() else { - panic!("predicate variable") + let (CanType::Var(argument), CanType::Var(result)) = (&argument.value, &result.value) + else { + panic!("{text}: polymorphic identity arguments") + }; + assert_eq!( + argument, result, + "{text}: input and output share one quantified variable" + ); + assert_eq!( + annotation.free_vars, + [*argument], + "{text}: preserve its quantified variable" + ); + let [predicate] = annotation.context else { + panic!("{text}: retain the declared or inferred Keep context") + }; + assert_eq!(predicate.trait_ref().expect("Keep predicate").name, "Keep"); + let [context_argument] = predicate.args() else { + panic!("unary Keep") + }; + assert!( + matches!(context_argument.value, CanType::Var(variable) if variable == *argument), + "{text}: predicate, argument, and result share the signature substitution" + ); + if text != "keep" { + let nash_ast::Expr::Call { function, .. } = body.value else { + panic!("recursive call") }; - let Type::FunN(Type::VarN(argument), Type::VarN(result)) = definitions[0].typ else { - panic!("function type") + let instance = &solved.instances[&nash_ast::NodeId::expr(function)]; + let [nash_ast::Evidence::Given { binder, index: 0 }] = instance.evidence else { + panic!("{text}: recursive call must use the enclosing context slot") }; - assert_eq!(predicate_var, argument); - assert_eq!(predicate_var, result); - assert!(rigid_vars.contains(predicate_var)); - assert!( - header.is_empty(), - "methods and recursive typed bodies have no lexical header" - ); - } else { - assert!(given.is_empty()); assert_eq!( - definitions.len(), - 2, - "both untyped recursive members share the group binder" + *binder, scheme.binder, + "{text}: recursive evidence uses the final owning binder" ); - assert_eq!(header.len(), 2); } } - names.sort_unstable(); - assert_eq!(names, ["f", "g", "h", "keep"]); } #[test] @@ -1682,8 +1687,8 @@ fn negation_retains_num_evidence() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &can.module); - let (annotations, solved) = nash_solve::run(&bump, &mut uf, &constraint, &can.tables).unwrap(); + let module = &can.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &can.tables).unwrap(); let [predicate] = annotations["flip"].context else { panic!("Num constraint") }; @@ -1778,9 +1783,8 @@ fn literal_syntax_records_impls_and_pattern_givens() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &can.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &can.tables).unwrap(); + let module = &can.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &can.tables).unwrap(); let mut decls = can.module.decls; while let nash_ast::Decls::Declare { definition, next } = decls { match definition { @@ -1866,9 +1870,8 @@ fn user_twins_preserve_local_imported_and_pattern_identity() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, _) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, _) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); insta::assert_snapshot!( format!("user_twins_{}", canonical.module.name.name), render_annotations(&annotations) @@ -2085,8 +2088,8 @@ fn destructured_bindings_preserve_contexts_and_polymorphism() { let parsed = nash_parse::Parser::new(&bump, input).module().unwrap(); let can = nash_can::canonicalize(&bump, Context::default(), &parsed).unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &can.module); - let (polymorphic, solved) = nash_solve::run(&bump, &mut uf, &constraint, &can.tables) + let module = &can.module; + let (polymorphic, solved) = nash_solve::run(&bump, &mut uf, module, &can.tables) .expect("destructured functions remain polymorphic"); assert!(polymorphic["main"].context.is_empty()); let nash_ast::Decls::Declare { definition, .. } = can.module.decls else { @@ -2512,9 +2515,9 @@ fn operator_methods_preserve_provider_and_backing_method() { canonical.warnings ); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); + let module = &canonical.module; let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); let interface = nash_can::from_module(&bump, &canonical.module, &annotations); for binop in interface.binops { assert_eq!(binop.function.home.name, "Methods"); @@ -2808,9 +2811,9 @@ fn nested_operator_sections_apply() { ) .expect("nested sections canonicalize"); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, _) = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables) - .expect("nested sections infer"); + let module = &canonical.module; + let (annotations, _) = + nash_solve::run(&bump, &mut uf, module, &canonical.tables).expect("nested sections infer"); let rendered = render_annotations(&annotations); assert!(rendered.contains("FromString a => a"), "{rendered}"); assert!(rendered.contains("left : unit"), "{rendered}"); @@ -2917,8 +2920,8 @@ fn higher_kinded_partial_alias_retains_its_nominal_impl() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let result = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables); + let module = &canonical.module; + let result = nash_solve::run(&bump, &mut uf, module, &canonical.tables); if module_name == "Reject" { let errors = result.expect_err("structurally identical aliases retain distinct impl heads"); @@ -3044,9 +3047,8 @@ fn imported_values_retain_declared_and_inferred_representation_contexts() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); let context = annotations["first"].context; assert!( matches!(context, [pred] if pred.trait_ref() == Some(nash_ast::primitives::ReprTrait::Storable.qualified())) @@ -3094,8 +3096,8 @@ fn imported_values_retain_declared_and_inferred_representation_contexts() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let errors = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap_err(); + let module = &canonical.module; + let errors = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap_err(); assert!( errors.iter().all(|error| matches!(error, Error::MissingImpl { trait_, .. } if *trait_ == nash_ast::primitives::ReprTrait::Storable.qualified())) && errors.iter().any(|error| matches!(error, Error::MissingImpl { name: actual, .. } if *actual == name)), "{errors:?}" @@ -3159,9 +3161,8 @@ fn do_infers_monad() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); assert!( annotations["run"] .context @@ -3239,8 +3240,8 @@ fn lift_interface(bump: &Bump, core: bool) -> nash_can::Interface<'_> { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(bump, &mut uf, &canonical.module); - let (annotations, _) = nash_solve::run(bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, _) = nash_solve::run(bump, &mut uf, module, &canonical.tables).unwrap(); nash_can::from_module(bump, &canonical.module, &annotations) } @@ -3282,9 +3283,8 @@ fn reflexive_lift_retains_big_evidence() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); assert!( ["concrete", "explicit", "nested"] .iter() @@ -3355,8 +3355,8 @@ fn reflexive_lift_neither_narrows_types_nor_uses_foreign_identity() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let errors = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap_err(); + let module = &canonical.module; + let errors = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap_err(); assert!(matches!( errors.as_slice(), [Error::MissingConstraint { .. }] | [Error::MissingImpl { .. }] @@ -3462,9 +3462,8 @@ fn higher_kinded_traits_resolve_distinct_constructors() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); let mut decls = canonical.module.decls; let mut checked = 0; while let nash_ast::Decls::Declare { definition, next } = decls { @@ -3522,8 +3521,8 @@ fn imported_higher_kinded_value_preserves_application() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (producer, _) = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (producer, _) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); let annotation = producer["value"]; let a = annotation .free_vars @@ -3555,8 +3554,8 @@ fn imported_higher_kinded_value_preserves_application() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables) + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables) .expect("imported higher-kinded applications infer"); let nash_ast::Decls::Declare { definition, .. } = canonical.module.decls else { panic!("value declaration") @@ -3614,9 +3613,8 @@ fn core_cast_schemes_preserve_nominal_source_and_target_types() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); assert_eq!(solved.instances.len(), 5); insta::assert_snapshot!(render_annotations(&annotations)); } @@ -3689,9 +3687,9 @@ fn literal_impls_preserve_little_defaults_with_big_and_utf8_candidates() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); + let module = &canonical.module; let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); if name == "Main" { for (trait_name, primitive) in [ ("FromInt", "int"), @@ -3765,9 +3763,9 @@ fn big_equality_is_automatic_and_retains_structural_evidence() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); + let module = &canonical.module; let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); if name == "Main" { let mut evidence: Vec<_> = solved .instances @@ -4017,9 +4015,8 @@ fn deferred_captured_field_preserves_trait_evidence() { let module = nash_parse::Parser::new(&bump, source).module().unwrap(); let canonical = nash_can::canonicalize(&bump, Context::default(), &module).unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + let module = &canonical.module; + let (annotations, solved) = nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); assert_eq!( render_annotation(annotations["f"]), "point -> ( int, unit )" @@ -4265,3 +4262,84 @@ fn labeled_ctor_big_multi_access_error() { "# ); } + +#[test] +fn recovery_direct_recursive_occurs_precedes_generalization() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +f x = f +use = f () +bad : () +bad = \x -> x +"# + ); +} + +#[test] +fn recovery_direct_annotated_if_keeps_both_mismatches() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +import Builtin exposing (..) +f : () +f = if True then (\x -> x) else (\y -> y) +"# + ); +} + +#[test] +fn recovery_direct_annotated_case_keeps_both_mismatches() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +import Builtin exposing (..) +f : () +f = case True of + True -> (\x -> x) + False -> (\y -> y) +"# + ); +} + +#[test] +fn recovery_direct_cons_tail_keeps_independent_mismatch() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +import Builtin exposing (..) +f : () -> () +f (x :: ()) = () +"# + ); +} + +#[test] +fn recovery_direct_alias_bool_keeps_header_type() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +import Builtin exposing (..) +f : () -> () +f (True as whole) = whole () +"# + ); +} + +#[test] +fn recovery_direct_alias_nested_keeps_header_type() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +import Builtin exposing (..) +f : ((), ()) -> () +f (((True as a), (() as b)) as whole) = whole () +"# + ); +} + +#[test] +fn recovery_direct_alias_ctor_keeps_header_type() { + assert_inference_error_snapshot!( + r#"module Main exposing (..) +import Builtin exposing (..) +type box = Box bool +f : box -> () +f (Box (() as whole)) = whole () +"# + ); +} diff --git a/crates/nash-solve/tests/representation_predicates.rs b/crates/nash-solve/tests/representation_predicates.rs index 2a83c16b..21538388 100644 --- a/crates/nash-solve/tests/representation_predicates.rs +++ b/crates/nash-solve/tests/representation_predicates.rs @@ -21,8 +21,8 @@ fn infer<'a>( ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(bump, &mut uf, &canonical.module); - nash_solve::run(bump, &mut uf, &constraint, &canonical.tables) + let module = &canonical.module; + nash_solve::run(bump, &mut uf, module, &canonical.tables) } #[test] @@ -103,157 +103,6 @@ fn inferred_list_context_is_retained_and_instantiated() { ); } -#[test] -fn a_partial_constructor_cannot_be_a_value_type() { - let bump = Bump::new(); - let mut uf = UnionFind::new(); - let typ = bump.alloc(nash_constrain::Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "list", - args: &[], - }); - let constraint = nash_constrain::Constraint::Equal( - nash_region::Region::zero(), - nash_constrain::error::Category::List, - typ, - nash_constrain::error::Expected::NoExpectation(typ), - ); - let result = nash_solve::run( - &bump, - &mut uf, - &constraint, - &nash_can::environment::Tables::default(), - ); - assert!( - matches!(result, Err(errors) if errors.iter().any(|e| matches!(e, Error::BadKind { .. }))) - ); -} - -#[test] -fn recovery_collects_final_kind_errors_after_independent_type_failures() { - use nash_constrain::{ - Constraint, Type, - error::{Category, Expected}, - }; - let bump = Bump::new(); - let mut uf = UnionFind::new(); - let partial = bump.alloc(Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "list", - args: &[], - }); - let unit = bump.alloc(Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "unit", - args: &[], - }); - let function = bump.alloc(Type::FunN(unit, unit)); - let at = |line| nash_region::Region { - start: nash_region::Position { line, column: 1 }, - end: nash_region::Position { line, column: 2 }, - }; - let constraints = [ - Constraint::Equal( - at(1), - Category::Lambda, - function, - Expected::NoExpectation(unit), - ), - Constraint::Equal( - at(2), - Category::List, - partial, - Expected::NoExpectation(partial), - ), - Constraint::Equal( - at(3), - Category::List, - partial, - Expected::NoExpectation(partial), - ), - ]; - let errors = nash_solve::run( - &bump, - &mut uf, - &Constraint::And(&constraints), - &nash_can::environment::Tables::default(), - ) - .unwrap_err(); - assert_eq!( - errors - .iter() - .filter(|error| matches!(error, Error::BadExpr(..))) - .count(), - 1, - "{errors:#?}" - ); - assert_eq!( - errors - .iter() - .filter(|error| matches!(error, Error::BadKind { .. })) - .count(), - 2, - "{errors:#?}" - ); -} - -#[test] -fn recovery_retains_shared_heads_removed_by_successful_normalization() { - use nash_constrain::{ - Constraint, Type, - error::{Category, Expected}, - }; - let bump = Bump::new(); - let mut uf = UnionFind::new(); - let head = nash_constrain::type_::mk_flex_var(&mut uf); - let result = nash_constrain::type_::mk_flex_var(&mut uf); - let head_type = bump.alloc(Type::VarN(head)); - let result_type = bump.alloc(Type::VarN(result)); - let unit = bump.alloc(Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "unit", - args: &[], - }); - let args = bump.alloc_slice_copy(&[&*unit]); - let applied = bump.alloc(Type::AppVarN(head_type, args)); - let list = bump.alloc(Type::AppN { - home: nash_ast::primitives::builtin_home(), - name: "list", - args, - }); - let function = bump.alloc(Type::FunN(unit, unit)); - let pairs = [ - (result_type as &Type<'_>, applied as &Type<'_>), - (result_type, list), - (result_type, function), - (head_type, function), - ]; - let constraints: Vec<_> = pairs - .into_iter() - .map(|(actual, expected)| { - Constraint::Equal( - nash_region::Region::zero(), - Category::List, - actual, - Expected::NoExpectation(expected), - ) - }) - .collect(); - let errors = nash_solve::run( - &bump, - &mut uf, - &Constraint::And(&constraints), - &nash_can::environment::Tables::default(), - ) - .unwrap_err(); - assert_eq!( - errors.len(), - 1, - "normalization must not sever the dependency from the applied type to its shared head: {errors:#?}" - ); - assert!(matches!(errors[0], Error::BadExpr(..))); -} - #[test] fn annotation_kinds_are_fixed_before_instantiation() { let bump = Bump::new(); @@ -292,8 +141,8 @@ fn imported_scheme_defaults_are_fixed_before_instantiation() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); - let result = nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables); + let module = &canonical.module; + let result = nash_solve::run(&bump, &mut uf, module, &canonical.tables); if name == "Source" { let (annotations, _) = result.unwrap(); interfaces.insert( @@ -340,9 +189,9 @@ fn representation_givens_follow_transparent_alias_bodies() { ) .unwrap(); let mut uf = UnionFind::new(); - let constraint = nash_constrain::constrain(&bump, &mut uf, &canonical.module); + let module = &canonical.module; let (annotations, solved) = - nash_solve::run(&bump, &mut uf, &constraint, &canonical.tables).unwrap(); + nash_solve::run(&bump, &mut uf, module, &canonical.tables).unwrap(); if name == "Main" { assert!(solved.instances.values().any(|instance| matches!( instance.evidence, diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_bool_keeps_header_type.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_bool_keeps_header_type.snap new file mode 100644 index 00000000..43f7ab73 --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_bool_keeps_header_type.snap @@ -0,0 +1,118 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nimport Builtin exposing (..)\nf : () -> ()\nf (True as whole) = whole ()\n" +--- +[ + BadExpr( + Region { + start: Position { + line: 4, + column: 21, + }, + end: Position { + line: 4, + column: 26, + }, + }, + CallResult( + FuncName( + "whole", + ), + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + FromContext( + Region { + start: Position { + line: 4, + column: 21, + }, + end: Position { + line: 4, + column: 29, + }, + }, + CallArity( + FuncName( + "whole", + ), + 1, + ), + Lambda( + FlexVar( + "a", + ), + FlexVar( + "b", + ), + [], + ), + ), + ), + BadPattern( + Region { + start: Position { + line: 4, + column: 4, + }, + end: Position { + line: 4, + column: 9, + }, + }, + Bool, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "bool", + args: [], + }, + FromContext( + Region { + start: Position { + line: 4, + column: 4, + }, + end: Position { + line: 4, + column: 17, + }, + }, + TypedArg( + "f", + 0, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), +] diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_ctor_keeps_header_type.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_ctor_keeps_header_type.snap new file mode 100644 index 00000000..724f0d4c --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_ctor_keeps_header_type.snap @@ -0,0 +1,118 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nimport Builtin exposing (..)\ntype box = Box bool\nf : box -> ()\nf (Box (() as whole)) = whole ()\n" +--- +[ + BadExpr( + Region { + start: Position { + line: 5, + column: 25, + }, + end: Position { + line: 5, + column: 30, + }, + }, + CallResult( + FuncName( + "whole", + ), + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "bool", + args: [], + }, + FromContext( + Region { + start: Position { + line: 5, + column: 25, + }, + end: Position { + line: 5, + column: 33, + }, + }, + CallArity( + FuncName( + "whole", + ), + 1, + ), + Lambda( + FlexVar( + "a", + ), + FlexVar( + "b", + ), + [], + ), + ), + ), + BadPattern( + Region { + start: Position { + line: 5, + column: 9, + }, + end: Position { + line: 5, + column: 11, + }, + }, + Unit, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + FromContext( + Region { + start: Position { + line: 5, + column: 4, + }, + end: Position { + line: 5, + column: 21, + }, + }, + CtorArg( + "Box", + 0, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "bool", + args: [], + }, + ), + ), +] diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_nested_keeps_header_type.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_nested_keeps_header_type.snap new file mode 100644 index 00000000..a4d1cded --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_alias_nested_keeps_header_type.snap @@ -0,0 +1,120 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nimport Builtin exposing (..)\nf : ((), ()) -> ()\nf (((True as a), (() as b)) as whole) = whole ()\n" +--- +[ + BadExpr( + Region { + start: Position { + line: 4, + column: 41, + }, + end: Position { + line: 4, + column: 46, + }, + }, + CallResult( + FuncName( + "whole", + ), + ), + Tuple( + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + [], + ), + FromContext( + Region { + start: Position { + line: 4, + column: 41, + }, + end: Position { + line: 4, + column: 49, + }, + }, + CallArity( + FuncName( + "whole", + ), + 1, + ), + Lambda( + FlexVar( + "a", + ), + FlexVar( + "b", + ), + [], + ), + ), + ), + BadPattern( + Region { + start: Position { + line: 4, + column: 6, + }, + end: Position { + line: 4, + column: 11, + }, + }, + Bool, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "bool", + args: [], + }, + NoExpectation( + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), +] diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_case_keeps_both_mismatches.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_case_keeps_both_mismatches.snap new file mode 100644 index 00000000..379964ca --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_case_keeps_both_mismatches.snap @@ -0,0 +1,90 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nimport Builtin exposing (..)\nf : ()\nf = case True of\n True -> (\\x -> x)\n False -> (\\y -> y)\n" +--- +[ + BadExpr( + Region { + start: Position { + line: 6, + column: 15, + }, + end: Position { + line: 6, + column: 22, + }, + }, + Lambda, + Lambda( + FlexVar( + "a", + ), + FlexVar( + "a", + ), + [], + ), + FromAnnotation( + "f", + 0, + TypedCaseBranch( + 1, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), + BadExpr( + Region { + start: Position { + line: 5, + column: 14, + }, + end: Position { + line: 5, + column: 21, + }, + }, + Lambda, + Lambda( + FlexVar( + "a", + ), + FlexVar( + "a", + ), + [], + ), + FromAnnotation( + "f", + 0, + TypedCaseBranch( + 0, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), +] diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_if_keeps_both_mismatches.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_if_keeps_both_mismatches.snap new file mode 100644 index 00000000..3b04447b --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_annotated_if_keeps_both_mismatches.snap @@ -0,0 +1,90 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nimport Builtin exposing (..)\nf : ()\nf = if True then (\\x -> x) else (\\y -> y)\n" +--- +[ + BadExpr( + Region { + start: Position { + line: 4, + column: 34, + }, + end: Position { + line: 4, + column: 41, + }, + }, + Lambda, + Lambda( + FlexVar( + "a", + ), + FlexVar( + "a", + ), + [], + ), + FromAnnotation( + "f", + 0, + TypedIfBranch( + 1, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), + BadExpr( + Region { + start: Position { + line: 4, + column: 19, + }, + end: Position { + line: 4, + column: 26, + }, + }, + Lambda, + Lambda( + FlexVar( + "a", + ), + FlexVar( + "a", + ), + [], + ), + FromAnnotation( + "f", + 0, + TypedIfBranch( + 0, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), +] diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_cons_tail_keeps_independent_mismatch.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_cons_tail_keeps_independent_mismatch.snap new file mode 100644 index 00000000..7c519b82 --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_cons_tail_keeps_independent_mismatch.snap @@ -0,0 +1,158 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nimport Builtin exposing (..)\nf : () -> ()\nf (x :: ()) = ()\n" +--- +[ + AmbiguousType { + region: Region { + start: Position { + line: 4, + column: 1, + }, + end: Position { + line: 4, + column: 2, + }, + }, + name: "f", + variable: FlexVar( + "a", + ), + predicates: [ + AmbiguousPredicate { + trait_: QualifiedName { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "Storable", + }, + args: [ + FlexVar( + "a", + ), + ], + }, + ], + }, + BadPattern( + Region { + start: Position { + line: 4, + column: 9, + }, + end: Position { + line: 4, + column: 11, + }, + }, + Unit, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + FromContext( + Region { + start: Position { + line: 4, + column: 4, + }, + end: Position { + line: 4, + column: 11, + }, + }, + Tail, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "list", + args: [ + FlexVar( + "a", + ), + ], + }, + ), + ), + BadPattern( + Region { + start: Position { + line: 4, + column: 4, + }, + end: Position { + line: 4, + column: 11, + }, + }, + List, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "list", + args: [ + FlexVar( + "a", + ), + ], + }, + FromContext( + Region { + start: Position { + line: 4, + column: 4, + }, + end: Position { + line: 4, + column: 11, + }, + }, + TypedArg( + "f", + 0, + ), + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), +] diff --git a/crates/nash-solve/tests/snapshots/inference__recovery_direct_recursive_occurs_precedes_generalization.snap b/crates/nash-solve/tests/snapshots/inference__recovery_direct_recursive_occurs_precedes_generalization.snap new file mode 100644 index 00000000..ad91b594 --- /dev/null +++ b/crates/nash-solve/tests/snapshots/inference__recovery_direct_recursive_occurs_precedes_generalization.snap @@ -0,0 +1,66 @@ +--- +source: crates/nash-solve/tests/inference.rs +description: "Code:\n\nmodule Main exposing (..)\nf x = f\nuse = f ()\nbad : ()\nbad = \\x -> x\n" +--- +[ + BadExpr( + Region { + start: Position { + line: 5, + column: 7, + }, + end: Position { + line: 5, + column: 14, + }, + }, + Lambda, + Lambda( + FlexVar( + "a", + ), + FlexVar( + "a", + ), + [], + ), + FromAnnotation( + "bad", + 0, + TypedBody, + Type { + home: ModuleName { + package: Some( + PackageName { + author: "nash", + project: "core", + }, + ), + name: "Builtin", + }, + name: "unit", + args: [], + }, + ), + ), + InfiniteType { + region: Region { + start: Position { + line: 2, + column: 1, + }, + end: Position { + line: 2, + column: 2, + }, + }, + name: "f", + overall_type: Lambda( + FlexVar( + "a", + ), + Infinite, + [], + ), + }, +] diff --git a/docs/frontend-hardening-verification.md b/docs/frontend-hardening-verification.md new file mode 100644 index 00000000..f2766170 --- /dev/null +++ b/docs/frontend-hardening-verification.md @@ -0,0 +1,131 @@ +# Frontend hardening verification + +The six frontend cleanup changes and direct AST inference are complete. The +replacement preserves the union-find and predicate engines, removes the +allocated Constraint tree and intermediate inference Type, and passes all +adoption gates. Plan 07 and code generation are outside this change. + +## Commit sequence + +Each row is a separate, verified jj change. Change IDs remain stable across +rebases; use `jj log -r ` for the current commit hash. + +| jj change | Change | +|---|---| +| `lrnzrllu` | Remove parser accumulator copies | +| `tsqsspqw` | Require valid UTF-8 input | +| `wplqvsku` | Widen source coordinates | +| `rnvunqnt` | Bound parser nesting and iterate flat sequences | +| `otpvxozr` | Remove inactive interface-cache machinery | +| `ovwmkvpo` | Borrow canonical local scopes | +| `npksuwvn` | Bound trait candidate traversal | +| `lssnqtpu` | Infer directly from the canonical AST | + +An external rebase placed these changes on release commit `046cb63c`. Its +changes from the saved baseline are release metadata and changelogs; no Rust +source changed. The release changes are preserved. + +## Validation + +Each completed implementation chunk passed formatting, workspace check with all +targets and features, clippy with all targets/features and warnings denied, +full workspace tests, and relevant scratch checks. Final commands were: + +```sh +cargo fmt --all +cargo check --workspace --all-targets --all-features +cargo clippy --all-targets --all-features -- -D warnings +cargo test +cargo run -p nash-cli -- check scratch +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. +Trait traversal compares candidate keys and payload identity with the original +ordered full-map filter, including missing traits and empty heads. + +Seven additional negative CLI scratch projects check recursive infinite types, +annotated if/case branches, Cons tails, and alias bindings. Their diagnostics +remain present. A qualified recursive-group project with a trait default +compiles successfully. + +## Parser allocation + +The measurement is retained arena bytes after parsing an operator chain. Tests +also verify operand count and complete source consumption. + +| Operands | Original accumulator | After accumulator change | Final, wider Region | +|---:|---:|---:|---:| +| 1,000 | 4,192,960 | 130,048 | 261,056 | +| 2,000 | 16,775,744 | 261,056 | 523,136 | +| 4,000 | โ€” | 523,136 | 1,047,360 | + +Both optimized columns were measured in debug and release and gave identical +results. Final allocation grows approximately twofold per input doubling. +Region now uses usize coordinates, increasing its size from 8 to 32 bytes on +the tested 64-bit host. + +## Inference parity + +The saved original engine is based on `59668452`. Two original-engine runs +produce identical transcripts. The final replacement produces **426 matching +records**, with no missing or changed records. Each record contains either the +complete ordered diagnostics or complete annotations and all four SolvedTypes +maps, including scheme binders, type arguments, predicate contexts, and nested +evidence. + +The recorder assigns structural canonical-AST paths to pointer-backed NodeIds +and sorts map entries. It does not sort diagnostics, free variables, contexts, +type arguments, or evidence. An unmapped NodeId fails the recording. + +All 411 original records remain. Three synthetic tests moved to immediate +solver operations; their full errors are compared using the same test names. +The metadata test now checks actual solved schemes/evidence and is also run +against the original engine. Seven new original-engine recovery fixtures add +14 records, including their support-module inference. Baseline runs pass 205 +tests each; the candidate passes 209, including four migrated low-level tests. +Twenty-two additional targeted original/candidate probes match as well. + +The added regressions check recursive occurs checks before generalization, +separate canonical expectations for annotated branches, separate Cons tail +structures, and canonical alias headers. Each failed against the first direct +implementation before its correction. Their snapshots come from the original +engine; existing snapshots were not changed to accommodate the replacement. + +## Rust size + +Counts are physical Rust lines, including comments and blank lines. A syn-based +tool excludes complete cfg(test) items and external test-only modules while +retaining production below embedded tests. All replacement helpers are counted. + +| Inference pipeline crate | Production before | Production after | Delta | +|---|---:|---:|---:| +| nash-constrain | 3,045 | 1,016 | -2,029 | +| nash-solve | 6,197 | 7,593 | +1,396 | +| nash-driver | 1,554 | 1,553 | -1 | +| nash-report | 10,412 | 10,412 | 0 | +| **Total** | **21,208** | **20,574** | **-634** | + +Test Rust grows from 14,757 to 14,908 lines (+151); total Rust decreases from +35,965 to 35,482 lines (-483). Snapshots and documentation are excluded from +these Rust counts. Across all frontend changes from `b7ff823b`, production Rust +decreases by 625 lines, tests grow by 310, and total Rust decreases by 315. +The unchanged nash-plutus crate does not affect these deltas. + +## Local evidence + +The retained local audit root is +`/tmp/nash-frontend-hardening-20260910`. It contains: + +- `08b-results.json` and `08b-*.log`: final required checks and parser allocation. +- `08b-scratch-regressions.json`: additional CLI results and diagnostic titles. +- `inference-audit/final7-comparison.json`: complete-record parity and source hash checks. +- `inference-audit/README.md`, `audit.py`, and `recorder.rs`: reproduction instructions and recorder. +- `loc-audit/README.md`, `final7.tsv`, and `final7-all.tsv`: the parsed line-count method and totals. +- `occurs-review/`: targeted fixtures and complete original/candidate outputs. + +The seven recovery snapshots are committed with the inference tests. External +audit copies contain the original engine only for verification; the production +tree has one direct inference path. diff --git a/docs/overview.md b/docs/overview.md index 7f686015..43d578b2 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -43,7 +43,7 @@ Done (ported from the Elm compiler, Haskell -> Rust): - `nash-parse` โ€” recursive-descent parser with Elm's full error hierarchy - `nash-can` โ€” canonicalization (name resolution, SCC ordering, interfaces) -- `nash-constrain` + `nash-solve` โ€” HM inference (Elm's rank-based solver) +- `nash-constrain` + `nash-solve` โ€” direct AST inference with Elm's rank-based solver - `nash-driver` / `nash-config` / `nash-cli` โ€” build graph, `nash.jsonc`, `nash check` - `nash-plutus` โ€” complete UPLC: terms, flat codec, CEK machine, cost models @@ -192,7 +192,7 @@ tests ## Pipeline ``` -source โ”€parseโ”€> Source AST โ”€canonicalizeโ”€> Can AST โ”€constrain/solveโ”€> types + trait evidence +source โ”€parseโ”€> Source AST โ”€canonicalizeโ”€> Can AST โ”€inferโ”€> types + trait evidence โ–ฒ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ macro expansion (typed AST in, source AST out) โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ @@ -214,8 +214,8 @@ crates/ nash-parse parser (extend: same) nash-ast canonical AST (extend: kinds, traits, evidence slots) nash-can canonicalization + Haskell 98 kinds + datatype contexts - nash-constrain type and predicate constraint generation - nash-solve type/trait solving + kind contracts + defaulting + evidence + nash-constrain union-find types + canonical instantiation + type errors + nash-solve direct AST inference + traits + kind contracts + evidence nash-nitpick exhaustiveness (new) nash-report diagnostics: Elm prose -> miette (new) nash-ir Core IR + Core->Core passes (new) diff --git a/docs/traits.md b/docs/traits.md index 3bf629cd..ac9fa769 100644 --- a/docs/traits.md +++ b/docs/traits.md @@ -268,7 +268,25 @@ Context reduction: a retained context never contains both `Ord 'a` and ## Inference Nash keeps Elm's rank-based solver (`nash-solve`). Predicates ride along -with unification variables. +with unification variables. Inference walks the canonical AST directly; +there is no allocated constraint tree or intermediate inference type. +Canonical types instantiate directly into union-find variables. + +Each definition owns its young rank, wanted predicates, and evidence binder. +Pattern scopes introduce a younger rank only when they own fresh variables. +Field obligations finish before generalization. Recursive typed declarations +are available before checking the inferred group; inferred bodies run in +reverse group order, then typed bodies run in reverse order with their own +givens. Recursive headers are checked for infinite types before group +generalization, and lexical headers are checked again after their continuation. + +Structural expectations from annotations are instantiated for each branch +equation. They share the annotation's rigid variables, but a failed branch +cannot poison another branch's structural expectation. Cons patterns likewise +create separate outer and tail list structures while sharing the element +variable. Alias bindings from canonical argument types instantiate their +headers separately from pattern equations; inferred pattern variables retain +their identity. This preserves independent diagnostics and their order. ### Where predicates live @@ -281,8 +299,8 @@ int)`. ### Producing wanted predicates -- A use of a name with scheme `forall vs. C => t` (a `Constraint::Local` - on a generalized variable, or a `Constraint::Foreign` with an annotation) +- A use of a name with scheme `forall vs. C => t` (a local generalized + binding, or a foreign name with an annotation) instantiates `vs` with fresh variables and creates one wanted predicate per element of `C`, tagged with the use site's region and its index in `C`. diff --git a/plans/frontend-hardening.md b/plans/frontend-hardening.md index 5f1246ae..ab71e32e 100644 --- a/plans/frontend-hardening.md +++ b/plans/frontend-hardening.md @@ -11,7 +11,7 @@ Implement the six Nash/Alder comparison findings, then evaluate a direct inferen - [x] Delete unused driver interface-cache machinery and orphaned dependencies. - [x] Separate canonical module data from local scopes without cloning the whole environment. - [x] Bound trait selection and evidence lookup using existing map ordering. -- [ ] Replace the constraint tree and intermediate inference Type with direct AST inference, subject to the adoption gates below. +- [x] Replace the constraint tree and intermediate inference Type with direct AST inference, subject to the adoption gates below. ## Verification and commits @@ -19,6 +19,8 @@ Use a separate reviewed jj commit for each verified logical chunk. Before each c Parser allocation regression: before the first change, 1,000 and 2,000 operands retained 4,192,960 and 16,775,744 arena bytes. Afterward 1,000 / 2,000 / 4,000 operands retain 130,048 / 261,056 / 523,136 bytes in both debug and release probes. The test checks complete consumption, operand count, and bounded growth. Function application and negative-argument paths now append a single parsed argument instead of copying the accumulated list. +After widening Region, the final measurements are 261,056 / 523,136 / 1,047,360 bytes for the same 1,000 / 2,000 / 4,000 operands, identical in debug and release. Each doubling uses approximately twice the arena memory. The earlier measurements describe the smaller Region representation at chunk 1. + Chunk 1 verification passed: formatting, workspace check (all targets/features), clippy (all targets/features, warnings denied), full workspace tests, `nash check scratch`, and the release allocation test. Existing snapshots were unchanged. Chunk 2 requires `Parser::new` source text to be `&str`; all callers are updated directly, with no byte-input adapter. All seven unchecked UTF-8 conversions are replaced with checked conversions. Added snapshots preserve raw Unicode, mixed Unicode/escapes, and CRLF normalization. A compile-fail doctest rejects arbitrary bytes. Formatting, workspace check, clippy, full tests (including the doctest), and `nash check scratch` passed. Only the three reviewed new Unicode snapshots were added. @@ -47,6 +49,14 @@ Retain the existing union-find and predicate engines. Preserve ranks, generaliza Remove both Constraint and the intermediate inference Type without introducing a delayed replacement IR. Count all production Rust changes across the affected pipeline, including helpers and moved code; tests are counted separately. Adopt only with demonstrated behavioral parity and a net production-code reduction. If a gate fails, keep the original engine and document concrete evidence. Do not retain dual engines in the final implementation. +The replacement passes these gates. The driver now passes the canonical module to the solver. Expressions, patterns, definitions, recursive groups, and annotated branches infer directly into the existing union-find and predicate engine. Both old representations and their construction/conversion paths are deleted. Canonical annotation structures remain available for independent equations; only their variables share the lexical substitution. + +The differential audit compares 426 complete records byte for byte, with two identical original-engine runs. It preserves all 411 original records, adds a complete scheme/evidence metadata fixture, and adds seven original-engine diagnostic regressions. Only pointer NodeIds and unordered map iteration are normalized. Twenty-two additional targeted probes also match. The new regressions were observed failing before the fixes; they preserve recursive occurs-check timing, separate annotated branch expectations, Cons tail expectations, and canonical alias headers. Existing snapshots remain unchanged. Representation-level tests were migrated to direct operations and solved-output assertions. + +Production Rust across nash-constrain, nash-solve, nash-driver, and nash-report decreases from 21,208 to 20,574 physical lines (634 removed), including all replacement helpers. Test Rust increases from 14,757 to 14,908 lines (151 added); total Rust decreases by 483 lines. The count parses cfg(test) items and includes production below test modules. See [the verification record](../docs/frontend-hardening-verification.md) for per-crate counts, artifacts, and scope. + ## Final verification Check Unicode and escape behavior, coordinates in debug and release, mixed recursive forms on a fixed stack, scope errors and shadowing, trait-selection equivalence, and inference differential cases. Re-run workspace checks against the final state. Report jj commits, measurements, parity results, net code changes, and any unmet adoption gate. Leave no unintended or uncommitted task changes. + +Final formatting, all-target/all-feature workspace check, clippy with warnings denied, full workspace tests, positive scratch compilation, and the full release parser suite passed. Refreshed debug/release allocation probes passed. Seven negative CLI scratch projects retain their expected diagnostics, and a qualified recursive-group scratch project compiles. All adoption gates are met; Plan 07 remains untouched. From 1aebcf627878c88799977b540cfb5773b93b8bed Mon Sep 17 00:00:00 2001 From: microproofs Date: Thu, 10 Sep 2026 18:34:13 -0400 Subject: [PATCH 09/11] refactor(report): add concise structured reports Signed-off-by: microproofs --- crates/nash-cli/tests/diagnostics.rs | 100 ++- ...diagnostics__documented_examples_json.snap | 151 +++-- ...nostics__documented_examples_terminal.snap | 56 +- .../diagnostics__poisoned_tuple_json.snap | 78 ++- .../diagnostics__poisoned_tuple_terminal.snap | 30 +- .../diagnostics__type_mismatch_json.snap | 50 +- .../diagnostics__type_mismatch_terminal.snap | 17 +- crates/nash-constrain/src/error.rs | 14 +- crates/nash-driver/src/compile.rs | 20 +- .../nash-driver/src/compile/nitpick_tests.rs | 22 +- ...tpick_tests__impl_method_fails_module.snap | 10 +- ...k_tests__incomplete_case_fails_module.snap | 12 +- ...ck_tests__redundant_case_fails_module.snap | 7 +- ...ut_top_level_definitions_fails_module.snap | 12 +- ...k_tests__unsafe_argument_fails_module.snap | 10 +- ...ests__unsafe_destructure_fails_module.snap | 23 +- ...orphan_and_overlap_at_the_impl_module.snap | 9 +- .../nash-language-server/src/diagnostics.rs | 151 ++++- crates/nash-language-server/src/workspace.rs | 2 +- crates/nash-report/src/canonicalize.rs | 473 ++++++-------- crates/nash-report/src/code.rs | 2 - ...snippet__tests__pair_snippet_snapshot.snap | 9 - ...tests__unicode_pair_on_separate_lines.snap | 9 - crates/nash-report/src/code/snippet.rs | 372 ----------- crates/nash-report/src/json.rs | 110 +++- crates/nash-report/src/lib.rs | 99 ++- crates/nash-report/src/pattern.rs | 102 ++- crates/nash-report/src/render.rs | 128 +++- ...nicalize__branches__bad_head_function.snap | 2 +- ...nonicalize__branches__bad_head_record.snap | 2 +- ...anches__bad_head_variable_application.snap | 2 +- ...icalize__branches__duplicate_destruct.snap | 10 +- ...icalize__branches__duplicate_function.snap | 10 +- ...onicalize__branches__duplicate_lambda.snap | 10 +- ...canonicalize__branches__duplicate_let.snap | 10 +- ...branches__export_multiple_suggestions.snap | 11 +- ...lize__branches__export_no_suggestions.snap | 10 +- ...e__branches__module_name_is_preserved.snap | 2 +- ...__branches__operator_javascript_equal.snap | 6 +- ...anches__operator_javascript_not_equal.snap | 8 +- ..._operator_javascript_strict_not_equal.snap | 8 +- ...onicalize__branches__operator_missing.snap | 6 +- ...onicalize__branches__operator_percent.snap | 8 +- ...anonicalize__branches__operator_power.snap | 9 +- ...calize__branches__qualified_ambiguity.snap | 13 +- ...e__branches__qualified_missing_import.snap | 9 +- ...lize__branches__qualified_not_exposed.snap | 11 +- ...lize__branches__recursive_alias_cycle.snap | 13 +- ...alize__branches__recursive_decl_cycle.snap | 12 +- ...icalize__branches__recursive_let_self.snap | 17 +- ...es__source_pipeline_overlapping_impls.snap | 7 +- ...ce_pipeline_reports_all_missing_names.snap | 18 +- ...onicalize__branches__superclass_cycle.snap | 2 +- ...onicalize__branches__superclass_limit.snap | 2 +- ..._canonicalize__branches__too_few_args.snap | 4 +- ...alize__branches__too_many_args_plural.snap | 4 +- ...ize__branches__unbound_alias_variable.snap | 8 +- ...ze__branches__unbound_union_variables.snap | 8 +- ...lize__branches__unused_alias_variable.snap | 8 +- ...ize__branches__unused_alias_variables.snap | 8 +- ...ze__coverage__variant_ambiguous_binop.snap | 15 +- ...ize__coverage__variant_ambiguous_ctor.snap | 15 +- ...ze__coverage__variant_ambiguous_trait.snap | 15 +- ...ize__coverage__variant_ambiguous_type.snap | 15 +- ...lize__coverage__variant_ambiguous_var.snap | 15 +- ...overage__variant_annotation_too_short.snap | 8 +- ...nicalize__coverage__variant_bad_arity.snap | 4 +- ...__coverage__variant_bad_instance_head.snap | 2 +- ...ize__coverage__variant_binop_conflict.snap | 4 +- ...age__variant_binop_function_not_found.snap | 5 +- ...rage__variant_context_var_not_in_type.snap | 2 +- ..._variant_contradictory_representation.snap | 5 +- ...e__coverage__variant_do_without_monad.snap | 7 +- ...coverage__variant_duplicate_alias_arg.snap | 10 +- ...ze__coverage__variant_duplicate_binop.snap | 10 +- ...ize__coverage__variant_duplicate_ctor.snap | 10 +- ...ize__coverage__variant_duplicate_decl.snap | 10 +- ...ze__coverage__variant_duplicate_field.snap | 10 +- ...e__coverage__variant_duplicate_method.snap | 10 +- ...__coverage__variant_duplicate_pattern.snap | 10 +- ...ze__coverage__variant_duplicate_trait.snap | 10 +- ...ge__variant_duplicate_trait_parameter.snap | 10 +- ...ize__coverage__variant_duplicate_type.snap | 10 +- ...coverage__variant_duplicate_union_arg.snap | 10 +- ...e__coverage__variant_export_duplicate.snap | 10 +- ...e__coverage__variant_export_not_found.snap | 11 +- ...__coverage__variant_export_open_alias.snap | 7 +- ...__coverage__variant_export_open_trait.snap | 2 +- ..._variant_impl_context_var_not_in_head.snap | 2 +- ...verage__variant_impl_of_builtin_trait.snap | 2 +- ..._coverage__variant_impl_pattern_limit.snap | 5 +- ...coverage__variant_import_ctor_by_name.snap | 7 +- ...ge__variant_import_exposing_not_found.snap | 4 +- ...e__coverage__variant_import_not_found.snap | 4 +- ...__coverage__variant_import_open_alias.snap | 6 +- ...__coverage__variant_import_open_trait.snap | 2 +- ...coverage__variant_irregular_recursion.snap | 5 +- ...lize__coverage__variant_kind_infinite.snap | 5 +- ...lize__coverage__variant_kind_mismatch.snap | 7 +- ...age__variant_labeled_ctor_extra_field.snap | 2 +- ...e__variant_labeled_ctor_missing_field.snap | 2 +- ...e__variant_labeled_ctor_unknown_field.snap | 2 +- ...age__variant_method_missing_parameter.snap | 2 +- ...ize__coverage__variant_missing_method.snap | 5 +- ...verage__variant_missing_module_header.snap | 2 +- ..._coverage__variant_missing_superclass.snap | 2 +- ..._coverage__variant_negate_without_num.snap | 7 +- ...ze__coverage__variant_not_found_binop.snap | 7 +- ...ize__coverage__variant_not_found_ctor.snap | 11 +- ...ze__coverage__variant_not_found_trait.snap | 9 +- ...ize__coverage__variant_not_found_type.snap | 11 +- ...lize__coverage__variant_not_found_var.snap | 11 +- ...calize__coverage__variant_orphan_impl.snap | 2 +- ...__coverage__variant_overlapping_impls.snap | 7 +- ...rage__variant_pattern_has_record_ctor.snap | 7 +- ...age__variant_record_literal_ambiguous.snap | 2 +- ...rage__variant_record_literal_no_alias.snap | 4 +- ...ge__variant_record_type_outside_alias.snap | 2 +- ...ze__coverage__variant_recursive_alias.snap | 11 +- ...ize__coverage__variant_recursive_decl.snap | 17 +- ...lize__coverage__variant_recursive_let.snap | 12 +- ...overage__variant_recursive_superclass.snap | 5 +- ...erage__variant_reflexive_lift_overlap.snap | 2 +- ...erage__variant_refutable_bind_pattern.snap | 5 +- ...rage__variant_representation_mismatch.snap | 2 +- ...nicalize__coverage__variant_shadowing.snap | 12 +- ...erage__variant_structural_eq_override.snap | 2 +- ..._coverage__variant_superclass_bad_arg.snap | 2 +- ...calize__coverage__variant_trait_arity.snap | 4 +- ..._variant_type_vars_messed_up_in_alias.snap | 4 +- ...e__variant_type_vars_unbound_in_union.snap | 8 +- ...ize__coverage__variant_unknown_method.snap | 5 +- ...calize__coverage__variant_unsupported.snap | 4 +- ...t__canonicalize__tests__kind_mismatch.snap | 7 +- ...icalize__tests__missing_module_header.snap | 2 +- ...t__canonicalize__tests__not_found_var.snap | 9 +- ..._tests__not_found_var_with_suggestion.snap | 9 +- ...ema_preserves_primary_span_and_styles.snap | 31 +- ...ts__paired_regions_are_self_contained.snap | 48 +- ...pattern__tests__missing_patterns_data.snap | 12 +- ...__tests__missing_patterns_nested_list.snap | 12 +- ...rt__pattern__tests__redundant_pattern.snap | 7 +- ...sh_report__pattern__tests__unsafe_arg.snap | 10 +- ...port__pattern__tests__unsafe_destruct.snap | 13 +- ...pty_line_render_without_losing_labels.snap | 17 + ...__render__tests__render_eof_insertion.snap | 2 +- ...nder__tests__render_no_snippet_report.snap | 2 +- ...rt__render__tests__render_pair_report.snap | 2 +- ..._render__tests__render_snippet_report.snap | 2 +- ..._render__tests__render_warning_header.snap | 2 +- ...__render_zero_width_region_gets_caret.snap | 2 +- ...rt__warning__tests__unused_definition.snap | 10 +- ...report__warning__tests__unused_import.snap | 6 +- ...rning__tests__unused_variable_pattern.snap | 10 +- crates/nash-report/src/syntax/expr.rs | 5 +- crates/nash-report/src/syntax/mod.rs | 9 +- ...ax__expr__tests__parsed_bytes_bad_hex.snap | 2 +- ..._expr__tests__parsed_case_wrong_arrow.snap | 2 +- ...__expr__tests__parsed_do_last_binding.snap | 2 +- ...__expr__tests__parsed_if_missing_else.snap | 2 +- ...pr__tests__parsed_lambda_missing_body.snap | 2 +- ...x__expr__tests__parsed_let_missing_in.snap | 2 +- ...pr__tests__parsed_list_trailing_comma.snap | 2 +- ...ntax__expr__tests__parsed_macro_close.snap | 2 +- ...__expr__tests__parsed_record_reserved.snap | 2 +- ...ax__expr__tests__parsed_unicode_short.snap | 2 +- ...ntax__tests__alias_reserved_parameter.snap | 2 +- ...x__tests__custom_type_missing_variant.snap | 2 +- ...tests__custom_type_reserved_parameter.snap | 2 +- ...__syntax__tests__decl_def_indent_body.snap | 2 +- ...yntax__tests__decl_def_missing_equals.snap | 2 +- ...t__syntax__tests__decl_def_name_match.snap | 2 +- ...eport__syntax__tests__decl_start_case.snap | 2 +- ..._report__syntax__tests__decl_start_if.snap | 2 +- ...ort__syntax__tests__decl_start_import.snap | 2 +- ...__syntax__tests__decl_start_uppercase.snap | 2 +- ...ntax__tests__definition_missing_colon.snap | 2 +- ...__tests__definition_reserved_argument.snap | 2 +- ...tests__definition_unexpected_operator.snap | 2 +- ...syntax__tests__exposing_bare_operator.snap | 2 +- ...syntax__tests__exposing_missing_paren.snap | 2 +- ...syntax__tests__exposing_reserved_word.snap | 2 +- ...rt__syntax__tests__exposing_value_bad.snap | 2 +- ..._syntax__tests__fresh_line_after_decl.snap | 2 +- ...rt__syntax__tests__fresh_line_keyword.snap | 2 +- ...port__syntax__tests__import_bad_alias.snap | 2 +- ...s__import_exposing_list_missing_paren.snap | 2 +- ...t__syntax__tests__import_missing_name.snap | 2 +- ..._syntax__tests__module_name_lowercase.snap | 2 +- ...__syntax__tests__module_name_mismatch.snap | 2 +- ...t__syntax__tests__module_name_missing.snap | 2 +- ...report__syntax__tests__module_problem.snap | 2 +- ...ax__tests__pattern_alias_missing_name.snap | 2 +- ...ntax__tests__pattern_list_missing_end.snap | 2 +- ...yntax__tests__pattern_negative_number.snap | 2 +- ...ax__tests__pattern_record_missing_end.snap | 2 +- ...ax__tests__pattern_reserved_list_open.snap | 2 +- ..._tests__pattern_reserved_record_field.snap | 2 +- ...ax__tests__pattern_reserved_tuple_end.snap | 2 +- ...x__tests__pattern_reserved_tuple_open.snap | 2 +- ...__syntax__tests__pattern_start_in_arg.snap | 2 +- ..._syntax__tests__pattern_start_in_case.snap | 2 +- ...__syntax__tests__pattern_start_in_let.snap | 2 +- ..._syntax__tests__pattern_stray_bracket.snap | 2 +- ...tax__tests__pattern_tuple_missing_end.snap | 2 +- ...__tests__pattern_underscore_only_name.snap | 2 +- ...ts__pattern_underscore_uppercase_name.snap | 2 +- ...ntax__tests__pattern_wildcard_not_var.snap | 2 +- ..._syntax__tests__space_endless_comment.snap | 2 +- ..._report__syntax__tests__space_has_tab.snap | 2 +- ...t__syntax__tests__type_alias_bad_body.snap | 2 +- ...tax__tests__type_alias_missing_equals.snap | 2 +- ...__syntax__tests__type_indent_in_alias.snap | 2 +- ...ax__tests__type_indent_in_custom_type.snap | 2 +- ...ntax__tests__type_record_double_comma.snap | 2 +- ...tax__tests__type_record_missing_colon.snap | 2 +- ...ax__tests__type_record_reserved_field.snap | 2 +- ...tax__tests__type_record_reserved_open.snap | 2 +- ...ax__tests__type_record_trailing_comma.snap | 2 +- ...ests__type_record_underindented_close.snap | 2 +- ...rt__syntax__tests__type_reserved_word.snap | 2 +- ...__tests__type_start_bad_in_annotation.snap | 2 +- ...t__syntax__tests__type_start_in_alias.snap | 2 +- ...tax__tests__type_start_in_custom_type.snap | 2 +- ...syntax__tests__type_tuple_missing_end.snap | 2 +- ...ntax__tests__type_tuple_reserved_open.snap | 2 +- ...rt__syntax__tests__weird_end_backtick.snap | 2 +- ..._syntax__tests__weird_end_close_paren.snap | 2 +- ...eport__syntax__tests__weird_end_comma.snap | 2 +- ...eport__syntax__tests__weird_end_empty.snap | 2 +- ...t__syntax__tests__weird_end_lowercase.snap | 2 +- ...rt__syntax__tests__weird_end_operator.snap | 2 +- ...yntax__tests__weird_end_reserved_word.snap | 2 +- ...t__syntax__tests__weird_end_semicolon.snap | 2 +- ...t__syntax__tests__weird_end_uppercase.snap | 2 +- ...ntax__variants__variant_attribute_arg.snap | 2 +- ...ntax__variants__variant_attribute_end.snap | 2 +- ...ariants__variant_attribute_fresh_line.snap | 2 +- ...ariants__variant_attribute_indent_arg.snap | 2 +- ...ariants__variant_attribute_indent_end.snap | 2 +- ...tax__variants__variant_attribute_name.snap | 2 +- ...ax__variants__variant_attribute_space.snap | 2 +- ...ax__variants__variant_custom_type_bar.snap | 2 +- ..._variants__variant_custom_type_equals.snap | 2 +- ...__variants__variant_custom_type_field.snap | 2 +- ...ants__variant_custom_type_field_colon.snap | 2 +- ...riants__variant_custom_type_field_end.snap | 2 +- ...iants__variant_custom_type_field_type.snap | 2 +- ..._variant_custom_type_indent_after_bar.snap | 2 +- ...riant_custom_type_indent_after_equals.snap | 2 +- ...iants__variant_custom_type_indent_bar.snap | 2 +- ...ts__variant_custom_type_indent_equals.snap | 2 +- ...nts__variant_custom_type_indent_field.snap | 2 +- ...variant_custom_type_indent_field_type.snap | 2 +- ...x__variants__variant_custom_type_name.snap | 2 +- ...__variants__variant_custom_type_param.snap | 2 +- ...__variants__variant_custom_type_space.snap | 2 +- ...variants__variant_custom_type_variant.snap | 2 +- ...ants__variant_custom_type_variant_arg.snap | 2 +- ...tax__variants__variant_decl_attribute.snap | 2 +- ...t__syntax__variants__variant_decl_def.snap | 2 +- ...yntax__variants__variant_decl_def_arg.snap | 2 +- ...ntax__variants__variant_decl_def_body.snap | 2 +- ...ax__variants__variant_decl_def_equals.snap | 2 +- ...ariants__variant_decl_def_indent_body.snap | 2 +- ...iants__variant_decl_def_indent_equals.snap | 2 +- ...ariants__variant_decl_def_indent_type.snap | 2 +- ...variants__variant_decl_def_name_match.snap | 2 +- ...ariants__variant_decl_def_name_repeat.snap | 2 +- ...tax__variants__variant_decl_def_space.snap | 2 +- ...ntax__variants__variant_decl_def_type.snap | 2 +- ...ant_decl_fresh_line_after_doc_comment.snap | 2 +- ...__syntax__variants__variant_decl_impl.snap | 2 +- ..._syntax__variants__variant_decl_space.snap | 2 +- ..._syntax__variants__variant_decl_start.snap | 2 +- ..._syntax__variants__variant_decl_trait.snap | 2 +- ...__syntax__variants__variant_decl_type.snap | 2 +- ...ax__variants__variant_decl_type_alias.snap | 2 +- ...riants__variant_decl_type_indent_name.snap | 2 +- ...tax__variants__variant_decl_type_name.snap | 2 +- ...ax__variants__variant_decl_type_space.snap | 2 +- ...ax__variants__variant_decl_type_union.snap | 2 +- ...__variants__variant_excessive_nesting.snap | 2 +- ...yntax__variants__variant_exposing_end.snap | 2 +- ...variants__variant_exposing_indent_end.snap | 2 +- ...riants__variant_exposing_indent_value.snap | 2 +- ...__variants__variant_exposing_operator.snap | 2 +- ...s__variant_exposing_operator_reserved.snap | 2 +- ...variant_exposing_operator_right_paren.snap | 2 +- ...tax__variants__variant_exposing_space.snap | 2 +- ...tax__variants__variant_exposing_start.snap | 2 +- ..._variants__variant_exposing_type_name.snap | 2 +- ...riants__variant_exposing_type_privacy.snap | 2 +- ...tax__variants__variant_exposing_value.snap | 2 +- ...tax__variants__variant_impl_alignment.snap | 2 +- ...ntax__variants__variant_impl_bad_head.snap | 2 +- ...__syntax__variants__variant_impl_head.snap | 2 +- ...x__variants__variant_impl_indent_head.snap | 2 +- ..._variants__variant_impl_indent_method.snap | 2 +- ...__variants__variant_impl_indent_where.snap | 2 +- ...syntax__variants__variant_impl_method.snap | 2 +- ...x__variants__variant_impl_method_name.snap | 2 +- ..._syntax__variants__variant_impl_space.snap | 2 +- ..._syntax__variants__variant_impl_where.snap | 2 +- ...tax__variants__variant_module_bad_end.snap | 2 +- ...variants__variant_module_declarations.snap | 2 +- ...ax__variants__variant_module_exposing.snap | 2 +- ...__variants__variant_module_fresh_line.snap | 2 +- ...variants__variant_module_import_alias.snap | 2 +- ...x__variants__variant_module_import_as.snap | 2 +- ...__variants__variant_module_import_end.snap | 2 +- ...iants__variant_module_import_exposing.snap | 2 +- ...__variant_module_import_exposing_list.snap | 2 +- ...s__variant_module_import_indent_alias.snap | 2 +- ...nt_module_import_indent_exposing_list.snap | 2 +- ...ts__variant_module_import_indent_name.snap | 2 +- ..._variants__variant_module_import_name.snap | 2 +- ...variants__variant_module_import_start.snap | 2 +- ...yntax__variants__variant_module_infix.snap | 2 +- ...syntax__variants__variant_module_name.snap | 2 +- ...tax__variants__variant_module_problem.snap | 2 +- ...yntax__variants__variant_module_space.snap | 2 +- ...yntax__variants__variant_module_tests.snap | 2 +- ...x__variants__variant_module_validator.snap | 2 +- ..._syntax__variants__variant_p_list_end.snap | 2 +- ...syntax__variants__variant_p_list_expr.snap | 2 +- ...__variants__variant_p_list_indent_end.snap | 2 +- ..._variants__variant_p_list_indent_expr.snap | 2 +- ..._variants__variant_p_list_indent_open.snap | 2 +- ...syntax__variants__variant_p_list_open.snap | 2 +- ...yntax__variants__variant_p_list_space.snap | 2 +- ...yntax__variants__variant_p_record_end.snap | 2 +- ...tax__variants__variant_p_record_field.snap | 2 +- ...variants__variant_p_record_indent_end.snap | 2 +- ...riants__variant_p_record_indent_field.snap | 2 +- ...ariants__variant_p_record_indent_open.snap | 2 +- ...ntax__variants__variant_p_record_open.snap | 2 +- ...tax__variants__variant_p_record_space.snap | 2 +- ...syntax__variants__variant_p_tuple_end.snap | 2 +- ...yntax__variants__variant_p_tuple_expr.snap | 2 +- ..._variants__variant_p_tuple_indent_end.snap | 2 +- ...ariants__variant_p_tuple_indent_expr1.snap | 2 +- ...riants__variant_p_tuple_indent_expr_n.snap | 2 +- ...yntax__variants__variant_p_tuple_open.snap | 2 +- ...ntax__variants__variant_p_tuple_space.snap | 2 +- ...ntax__variants__variant_pattern_alias.snap | 2 +- ...ntax__variants__variant_pattern_bytes.snap | 2 +- ...ariants__variant_pattern_indent_alias.snap | 2 +- ...ariants__variant_pattern_indent_start.snap | 2 +- ...yntax__variants__variant_pattern_list.snap | 2 +- ...tax__variants__variant_pattern_number.snap | 2 +- ...tax__variants__variant_pattern_record.snap | 2 +- ...ntax__variants__variant_pattern_space.snap | 2 +- ...ntax__variants__variant_pattern_start.snap | 2 +- ...tax__variants__variant_pattern_string.snap | 2 +- ...ntax__variants__variant_pattern_tuple.snap | 2 +- ...nts__variant_pattern_wildcard_not_var.snap | 2 +- ..._syntax__variants__variant_repr_arrow.snap | 2 +- ...__syntax__variants__variant_repr_name.snap | 2 +- ..._syntax__variants__variant_repr_space.snap | 2 +- ..._syntax__variants__variant_repr_start.snap | 2 +- ...tax__variants__variant_t_record_colon.snap | 2 +- ...yntax__variants__variant_t_record_end.snap | 2 +- ...tax__variants__variant_t_record_field.snap | 2 +- ...riants__variant_t_record_indent_colon.snap | 2 +- ...variants__variant_t_record_indent_end.snap | 2 +- ...riants__variant_t_record_indent_field.snap | 2 +- ...ariants__variant_t_record_indent_open.snap | 2 +- ...ariants__variant_t_record_indent_type.snap | 2 +- ...ntax__variants__variant_t_record_open.snap | 2 +- ...tax__variants__variant_t_record_space.snap | 2 +- ...ntax__variants__variant_t_record_type.snap | 2 +- ...syntax__variants__variant_t_tuple_end.snap | 2 +- ..._variants__variant_t_tuple_indent_end.snap | 2 +- ...variants__variant_t_tuple_indent_repr.snap | 2 +- ...ariants__variant_t_tuple_indent_type1.snap | 2 +- ...riants__variant_t_tuple_indent_type_n.snap | 2 +- ...yntax__variants__variant_t_tuple_open.snap | 2 +- ...yntax__variants__variant_t_tuple_repr.snap | 2 +- ...ntax__variants__variant_t_tuple_space.snap | 2 +- ...yntax__variants__variant_t_tuple_type.snap | 2 +- ...riants__variant_test_binder_alignment.snap | 2 +- ...__syntax__variants__variant_test_body.snap | 2 +- ...rt__syntax__variants__variant_test_do.snap | 2 +- ...syntax__variants__variant_test_equals.snap | 2 +- ...syntax__variants__variant_test_fuzzer.snap | 2 +- ...rt__syntax__variants__variant_test_in.snap | 2 +- ..._variants__variant_test_indent_binder.snap | 2 +- ...x__variants__variant_test_indent_body.snap | 2 +- ..._variants__variant_test_indent_equals.snap | 2 +- ...tax__variants__variant_test_indent_in.snap | 2 +- ...x__variants__variant_test_indent_name.snap | 2 +- ...t__syntax__variants__variant_test_let.snap | 2 +- ...__syntax__variants__variant_test_name.snap | 2 +- ...ax__variants__variant_test_name_start.snap | 2 +- ...iants__variant_test_once_on_unit_test.snap | 2 +- ...yntax__variants__variant_test_pattern.snap | 2 +- ..._syntax__variants__variant_test_space.snap | 2 +- ...t__syntax__variants__variant_test_via.snap | 2 +- ...riants__variant_test_within_duplicate.snap | 2 +- ...ax__variants__variant_test_within_end.snap | 2 +- ...x__variants__variant_test_within_kind.snap | 2 +- ..._variants__variant_test_within_number.snap | 2 +- ...x__variants__variant_test_within_open.snap | 2 +- ...ax__variants__variant_tests_alignment.snap | 2 +- ...yntax__variants__variant_tests_import.snap | 2 +- ..._variants__variant_tests_indent_start.snap | 2 +- ...syntax__variants__variant_tests_space.snap | 2 +- ...syntax__variants__variant_tests_start.snap | 2 +- ..._syntax__variants__variant_tests_test.snap | 2 +- ...ax__variants__variant_trait_alignment.snap | 2 +- ...syntax__variants__variant_trait_colon.snap | 2 +- ...ntax__variants__variant_trait_default.snap | 2 +- ..._variants__variant_trait_indent_colon.snap | 2 +- ...variants__variant_trait_indent_method.snap | 2 +- ...__variants__variant_trait_indent_name.snap | 2 +- ..._variants__variant_trait_indent_param.snap | 2 +- ...__variants__variant_trait_indent_type.snap | 2 +- ..._variants__variant_trait_indent_where.snap | 2 +- ...__variants__variant_trait_method_name.snap | 2 +- ..._syntax__variants__variant_trait_name.snap | 2 +- ...syntax__variants__variant_trait_param.snap | 2 +- ...syntax__variants__variant_trait_space.snap | 2 +- ...syntax__variants__variant_trait_super.snap | 2 +- ...ax__variants__variant_trait_super_arg.snap | 2 +- ..._syntax__variants__variant_trait_type.snap | 2 +- ...syntax__variants__variant_trait_where.snap | 2 +- ...ax__variants__variant_type_alias_body.snap | 2 +- ...__variants__variant_type_alias_equals.snap | 2 +- ...iants__variant_type_alias_indent_body.snap | 2 +- ...nts__variant_type_alias_indent_equals.snap | 2 +- ...ax__variants__variant_type_alias_name.snap | 2 +- ...x__variants__variant_type_alias_param.snap | 2 +- ...x__variants__variant_type_alias_space.snap | 2 +- ...yntax__variants__variant_type_context.snap | 2 +- ...ts__variant_type_indent_after_context.snap | 2 +- ...__variants__variant_type_indent_start.snap | 2 +- ...x__variants__variant_type_param_colon.snap | 2 +- ...tax__variants__variant_type_param_end.snap | 2 +- ...ants__variant_type_param_indent_colon.snap | 2 +- ...riants__variant_type_param_indent_end.snap | 2 +- ...iants__variant_type_param_indent_repr.snap | 2 +- ...ax__variants__variant_type_param_repr.snap | 2 +- ...x__variants__variant_type_param_space.snap | 2 +- ...x__variants__variant_type_param_start.snap | 2 +- ...syntax__variants__variant_type_record.snap | 2 +- ..._syntax__variants__variant_type_space.snap | 2 +- ..._syntax__variants__variant_type_start.snap | 2 +- ..._syntax__variants__variant_type_tuple.snap | 2 +- ...tax__variants__variant_type_var_start.snap | 2 +- crates/nash-report/src/type_.rs | 616 +++++++----------- crates/nash-report/src/type_/operators.rs | 464 +------------ crates/nash-report/src/type_/records.rs | 400 ++++-------- ...type___tests__ambiguous_record_access.snap | 14 +- ..._report__type___tests__ambiguous_type.snap | 15 +- ...___tests__annotation_variable_escapes.snap | 12 +- ...report__type___tests__append_int_left.snap | 10 +- ...t__type___tests__append_int_to_string.snap | 8 +- ...ash_report__type___tests__append_left.snap | 9 +- ...sh_report__type___tests__boolean_left.snap | 6 +- ...h_report__type___tests__boolean_right.snap | 6 +- ...sh_report__type___tests__compare_left.snap | 8 +- ...__tests__contradictory_representation.snap | 14 +- ...ash_report__type___tests__custom_left.snap | 10 +- ...sh_report__type___tests__custom_right.snap | 14 +- ...t__type___tests__destructure_mismatch.snap | 14 +- ...h_report__type___tests__division_left.snap | 6 +- ..._report__type___tests__division_right.snap | 6 +- ..._report__type___tests__every_category.snap | 34 +- ..._type___tests__every_pattern_category.snap | 20 +- ...ts__example_one_big_little_annotation.snap | 29 +- ...tests__expression_without_expectation.snap | 14 +- ...__type___tests__field_mismatch_update.snap | 18 +- ...eport__type___tests__hint_arity_fewer.snap | 2 +- ...report__type___tests__hint_arity_more.snap | 2 +- ...t__type___tests__hint_big_little_need.snap | 5 +- ...port__type___tests__hint_double_rigid.snap | 6 +- ...report__type___tests__hint_field_typo.snap | 5 +- ...rt__type___tests__hint_missing_fields.snap | 2 +- ...ash_report__type___tests__hint_option.snap | 3 +- ...__type___tests__impl_resolution_limit.snap | 12 +- ...h_report__type___tests__infinite_kind.snap | 11 +- ...h_report__type___tests__infinite_type.snap | 12 +- ...h_report__type___tests__kind_mismatch.snap | 20 +- ...nash_report__type___tests__minus_left.snap | 8 +- ...ash_report__type___tests__minus_right.snap | 8 +- ...ype___tests__mismatch_annotation_body.snap | 17 +- ...type___tests__mismatch_call_arg_first.snap | 14 +- ...ts__mismatch_call_arg_second_has_hint.snap | 18 +- ..._type___tests__mismatch_case_branches.snap | 20 +- ...t__type___tests__mismatch_if_branches.snap | 20 +- ...tests__mismatch_if_condition_not_bool.snap | 16 +- ...__type___tests__mismatch_list_entries.snap | 20 +- ...ort__type___tests__missing_constraint.snap | 15 +- ...rt__type___tests__missing_field_alias.snap | 15 +- ...sh_report__type___tests__missing_impl.snap | 20 +- ...ocal_union_deriving_not_yet_available.snap | 15 - ...local_union_suggests_a_supported_impl.snap | 5 + ..._storable_constraint_for_list_element.snap | 21 +- ...h_report__type___tests__multiply_left.snap | 8 +- ..._report__type___tests__multiply_right.snap | 8 +- ...t__type___tests__not_a_record_pattern.snap | 13 +- ...__type___tests__op_append_string_list.snap | 9 +- ...rt__type___tests__op_compare_mismatch.snap | 17 +- ...ype___tests__op_cons_element_mismatch.snap | 16 +- ..._type___tests__op_cons_right_not_list.snap | 12 +- ...t__type___tests__op_equality_mismatch.snap | 17 +- ...pe___tests__op_pipe_argument_mismatch.snap | 14 +- ...e___tests__op_pipe_right_not_function.snap | 11 +- ...rt__type___tests__op_plus_left_string.snap | 12 +- ...___tests__pattern_case_first_mismatch.snap | 16 +- ...___tests__pattern_case_later_mismatch.snap | 17 +- ...pe___tests__pattern_ctor_arg_mismatch.snap | 14 +- ...ort__type___tests__pattern_list_entry.snap | 18 +- ...port__type___tests__pattern_list_tail.snap | 14 +- ...e___tests__pattern_typed_arg_mismatch.snap | 17 +- ...___tests__pattern_without_expectation.snap | 14 +- ...ort__type___tests__pipe_left_argument.snap | 10 +- ..._type___tests__pipe_left_not_function.snap | 8 +- ...nash_report__type___tests__plus_right.snap | 8 +- ...__type___tests__polymorphic_recursion.snap | 14 +- ...nash_report__type___tests__power_left.snap | 8 +- ...ash_report__type___tests__power_right.snap | 8 +- ...sts__record_access_missing_field_typo.snap | 14 +- ...___tests__record_access_on_non_record.snap | 12 +- ...__type___tests__record_field_mismatch.snap | 14 +- ...pe___tests__record_update_change_type.snap | 18 +- ...___tests__record_update_unknown_field.snap | 12 +- ...ort__type___tests__rigid_var_mismatch.snap | 25 +- ...ests__source_pipeline_annotation_body.snap | 17 +- ..._tests__source_pipeline_call_argument.snap | 14 +- ..._source_pipeline_call_second_argument.snap | 18 +- ..._tests__source_pipeline_case_branches.snap | 20 +- ...___tests__source_pipeline_if_branches.snap | 19 +- ...__tests__source_pipeline_if_condition.snap | 16 +- ..._tests__source_pipeline_infinite_type.snap | 12 +- ...__tests__source_pipeline_list_entries.snap | 19 +- ...s__source_pipeline_missing_constraint.snap | 13 +- ...__tests__source_pipeline_missing_impl.snap | 18 +- ...sts__source_pipeline_pattern_ctor_arg.snap | 14 +- ...ts__source_pipeline_pattern_typed_arg.snap | 17 +- ..._tests__source_pipeline_record_access.snap | 15 +- ...s__source_pipeline_record_update_type.snap | 18 +- ...pe___tests__too_many_args_on_function.snap | 5 +- ..._type___tests__too_many_args_on_value.snap | 5 +- ...nash_report__type___tests__typed_case.snap | 17 +- .../nash_report__type___tests__typed_if.snap | 17 +- ..._type___tests__unresolved_application.snap | 12 +- ...__type___tests__unresolved_constraint.snap | 11 +- ...port__type___tests__update_not_record.snap | 11 +- crates/nash-report/src/type_/tests.rs | 69 +- crates/nash-report/src/type_/traits.rs | 380 +++++------ crates/nash-report/src/warning.rs | 50 +- crates/nash-solve/src/solve/expressions.rs | 32 +- crates/nash-solve/src/solve/infer.rs | 25 +- ..._kinded_rigid_heads_cannot_specialize.snap | 10 + ...must_match_its_specialized_annotation.snap | 10 + ...ured_projection_keeps_outer_parameter.snap | 10 + ...ured_field_does_not_generalize_result.snap | 10 + ...ference__nominal_records_do_not_unify.snap | 10 + ...arent_alias_preserves_record_identity.snap | 10 + ...y_direct_alias_bool_keeps_header_type.snap | 10 + ..._annotated_case_keeps_both_mismatches.snap | 20 + ...ct_annotated_if_keeps_both_mismatches.snap | 20 + ..._cons_tail_keeps_independent_mismatch.snap | 10 + ...ursive_occurs_precedes_generalization.snap | 10 + ...erence__recovery_mixed_errors_forward.snap | 10 + ...rence__recovery_mixed_errors_reversed.snap | 10 + ...ntity_cannot_change_its_argument_type.snap | 10 + .../inference__rigid_vars_do_not_unify.snap | 12 +- ...efault_body_must_match_its_annotation.snap | 10 + ...ers_preserve_distinct_record_identity.snap | 10 + .../inference__tuple_arity_mismatch.snap | 10 + ...ence__tuple_fourth_component_mismatch.snap | 10 + ...tor_does_not_follow_the_expected_type.snap | 10 + docs/diagnostics.md | 263 +++----- 576 files changed, 3119 insertions(+), 4333 deletions(-) delete mode 100644 crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__pair_snippet_snapshot.snap delete mode 100644 crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__unicode_pair_on_separate_lines.snap delete mode 100644 crates/nash-report/src/code/snippet.rs create mode 100644 crates/nash-report/src/snapshots/nash_report__render__source_edge_tests__unicode_crlf_tabs_and_final_empty_line_render_without_losing_labels.snap delete mode 100644 crates/nash-report/src/type_/snapshots/nash_report__type___tests__missing_impl_local_union_deriving_not_yet_available.snap create mode 100644 crates/nash-report/src/type_/snapshots/nash_report__type___tests__missing_impl_local_union_suggests_a_supported_impl.snap diff --git a/crates/nash-cli/tests/diagnostics.rs b/crates/nash-cli/tests/diagnostics.rs index 6bce20c5..8bdca5c9 100644 --- a/crates/nash-cli/tests/diagnostics.rs +++ b/crates/nash-cli/tests/diagnostics.rs @@ -56,7 +56,7 @@ fn terminal_and_json_type_mismatch() { assert!(human.stdout.is_empty()); let text = normalized(&human.stderr, &root); assert!(!text.contains('\u{1b}')); - assert!(text.contains("TYPE MISMATCH")); + assert!(text.contains("nash::type::mismatch")); insta::assert_snapshot!("type_mismatch_terminal", text); let json = check(&root, &["--report=json"]); assert_eq!(json.status.code(), Some(1)); @@ -118,7 +118,7 @@ fn independent_errors_are_stable_and_dependents_are_blocked() { assert_eq!(modules[0]["problems"].as_array().unwrap().len(), 2); let human = check(&project.0, &[]); let text = String::from_utf8(human.stderr).unwrap(); - assert_eq!(text.matches("NAMING ERROR").count(), 3); + assert_eq!(text.matches("nash::names::not_found_var").count(), 3); assert!(text.contains("Skipped")); assert!(!text.contains("IMPORT PROBLEM")); } @@ -142,12 +142,12 @@ fn documented_examples_run_through_the_real_core_package() { let human = check(&root, &["--no-warnings"]); assert_eq!(human.status.code(), Some(1)); let text = normalized(&human.stderr, &root); - assert!(text.contains("This `map` call produces:"), "{text}"); + assert!(text.contains("found `list Int`"), "{text}"); let docs = include_str!("../../../docs/diagnostics.md"); let names = [ - "TYPE MISMATCH", - "MISSING IMPL", - "MISSING PATTERNS", + "nash::type::mismatch", + "nash::type::missing_impl", + "nash::pattern::incomplete", "Compilation failed:", ]; for pair in names.windows(2) { @@ -229,7 +229,7 @@ async fn mixed_errors_match_across_terminal_json_and_lsp() { let text = String::from_utf8(human.stderr).unwrap(); let mut previous = 0; for problem in problems { - let title = problem["title"].as_str().unwrap(); + let title = problem["code"].as_str().unwrap(); assert_eq!(text.matches(&format!("{title}\n")).count(), 1); let position = text.find(&format!("{title}\n")).unwrap(); assert!(position >= previous); @@ -269,7 +269,7 @@ async fn mixed_errors_match_across_terminal_json_and_lsp() { "{rendered}\nExpected {location}" ); let lsp = nash_language_server::diagnostics::to_lsp(report, &source, &uri); - assert_eq!(serde_json::to_value(&lsp).unwrap()["code"], json["title"]); + assert_eq!(serde_json::to_value(&lsp).unwrap()["code"], json["code"]); assert_eq!( u64::from(lsp.range.start.line) + 1, json["region"]["start"]["line"] @@ -315,3 +315,87 @@ fn poisoned_tuple_child_keeps_independent_type_mismatch() { ) ); } + +#[test] +fn type_expectation_origins_reach_json_and_terminal() { + let project = Project::new(&[ + ( + "Annotation", + "module Annotation exposing (..)\nvalue :\n ()\nvalue = ((), ())\n", + ), + ( + "Elements", + "module Elements exposing (..)\nvalue = [(), ((), ())]\n", + ), + ( + "Branches", + "module Branches exposing (..)\nvalue flag = if flag then () else ((), ())\n", + ), + ( + "Cases", + "module Cases exposing (..)\nvalue x =\n case x of\n () -> ()\n _ -> ((), ())\n", + ), + ]); + let output = check(&project.0, &["--report=json"]); + assert_eq!(output.status.code(), Some(1)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let modules = json["errors"].as_array().unwrap(); + assert_eq!(modules.len(), 4, "{json}"); + for (name, line, column, text) in [ + ("Annotation", 3, 5, "declared type"), + ("Elements", 2, 10, "previous list element"), + ("Branches", 2, 27, "previous branch"), + ("Cases", 4, 15, "previous branch"), + ] { + let module = modules + .iter() + .find(|module| module["name"] == name) + .unwrap(); + let problems = module["problems"].as_array().unwrap(); + assert_eq!(problems.len(), 1, "{module}"); + let labels = problems[0]["labels"].as_array().unwrap(); + let origin = labels + .iter() + .find(|label| label["text"] == text) + .expect("origin label"); + assert_eq!(origin["primary"], false); + assert_eq!( + origin["region"]["start"], + serde_json::json!({"line": line, "column": column}) + ); + } + let output = check(&project.0, &[]); + let text = String::from_utf8(output.stderr).unwrap(); + for label in ["declared type", "previous list element", "previous branch"] { + assert!(text.contains(label), "{text}"); + } +} + +#[test] +fn imported_function_alias_labels_the_local_annotation() { + let project = Project::new(&[ + ( + "Types", + "module Types exposing (type callback)\n\n\n\ntype alias callback = () -> ()\n", + ), + ( + "Main", + "module Main exposing (..)\nimport Types exposing (type callback)\nf : callback\nf (x, y) = ()\n", + ), + ]); + let output = check(&project.0, &["--report=json", "--no-warnings"]); + assert_eq!(output.status.code(), Some(1)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let problem = &json["errors"][0]["problems"][0]; + assert_eq!(problem["code"], "nash::type::pattern_mismatch"); + let origin = problem["labels"] + .as_array() + .unwrap() + .iter() + .find(|label| label["text"] == "declared argument type") + .unwrap(); + assert_eq!( + origin["region"]["start"], + serde_json::json!({"line": 3, "column": 5}) + ); +} diff --git a/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_json.snap b/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_json.snap index 0fd54f2c..a23e5ece 100644 --- a/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_json.snap +++ b/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_json.snap @@ -9,36 +9,53 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "path": "/app/src/Ledger.nash", "problems": [ { - "message": [ - "Something is off with the body of the `settle` definition:\n\n11| map balanceOf accounts\n ", + "code": "nash::type::mismatch", + "labels": [ { - "bold": false, - "color": "RED", - "string": "^^^^^^^^^^^^^^^^^^^^^^", - "underline": false + "primary": true, + "region": { + "end": { + "column": 27, + "line": 11 + }, + "start": { + "column": 5, + "line": 11 + } + }, + "text": "body of `settle`" }, - "\nThis `map` call produces:\n\n list ", { - "bold": false, - "color": "yellow", - "string": "Int", - "underline": false - }, - "\n\nBut the type annotation on `settle` says it should be:\n\n list ", + "primary": false, + "region": { + "end": { + "column": 34, + "line": 9 + }, + "start": { + "column": 10, + "line": 9 + } + }, + "text": "declared type" + } + ], + "message": [ + "Type mismatch: expected `list ", { "bold": false, "color": "yellow", "string": "int", "underline": false }, - "\n\n", + "`, found `list ", { "bold": false, - "color": null, - "string": "Hint", - "underline": true + "color": "yellow", + "string": "Int", + "underline": false }, - ": `Int` is the Big (Data) type and `int` is the little type. They never\nconvert implicitly. Where an appropriate `Lift` impl is available, use `lower`\nto go from `Int` to `int`, or `lift` to go the other way." + "`.\n\nUse `lower` to convert `Int` to `int` where a `Lift` impl is available." ], "region": { "end": { @@ -50,6 +67,9 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "line": 11 } }, + "related": [], + "severity": "error", + "suggestions": [], "title": "TYPE MISMATCH" } ] @@ -59,22 +79,25 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "path": "/app/src/Steps.nash", "problems": [ { - "message": [ - "I cannot find an `Eq` impl for `step`:\n\n8| isDone s = s == Done\n ", + "code": "nash::type::missing_impl", + "labels": [ { - "bold": false, - "color": "RED", - "string": "^^^^^^^^^", - "underline": false - }, - "\nThe (==) operator needs its arguments to implement `Eq`, and here they are:\n\n step\n\nBut there is no `impl Eq step` in this module or in any import.\n\n`Eq` is implemented for these heads:\n\n bool\n bytes\n int\n (list 'a0)\n\n", - { - "bold": false, - "color": null, - "string": "Hint", - "underline": true - }, - ": This local datatype is a candidate for `@derive(Eq)`, but automatic\nderiving is not available yet. Write the impl by hand:\n\n impl Eq step where\n eq a b = ..." + "primary": true, + "region": { + "end": { + "column": 21, + "line": 8 + }, + "start": { + "column": 12, + "line": 8 + } + }, + "text": "required by `==`" + } + ], + "message": [ + "No impl for `Eq step`.\n\nAvailable impl heads:\n\n bool\n bytes\n int\n (list 'a0)\n\nโ€ฆ\n\nImport or define an impl for `Eq step`." ], "region": { "end": { @@ -86,6 +109,9 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "line": 8 } }, + "related": [], + "severity": "error", + "suggestions": [], "title": "MISSING IMPL" } ] @@ -95,36 +121,25 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "path": "/app/src/Tag.nash", "problems": [ { - "message": [ - "This `case` does not have branches for all possibilities:\n\n 7|", - { - "bold": false, - "color": "RED", - "string": ">", - "underline": false - }, - " case d of\n 8|", + "code": "nash::pattern::incomplete", + "labels": [ { - "bold": false, - "color": "RED", - "string": ">", - "underline": false - }, - " Constr n _ -> n\n 9|", - { - "bold": false, - "color": "RED", - "string": ">", - "underline": false - }, - " List _ -> 0\n10|", - { - "bold": false, - "color": "RED", - "string": ">", - "underline": false - }, - "\n\nMissing possibilities include:\n\n ", + "primary": true, + "region": { + "end": { + "column": 1, + "line": 10 + }, + "start": { + "column": 5, + "line": 7 + } + }, + "text": "" + } + ], + "message": [ + "Case expression is not exhaustive.\n\nMissing patterns:\n\n ", { "bold": false, "color": "yellow", @@ -145,14 +160,7 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "string": "B _", "underline": false }, - "\n\nI would have to crash if I saw one of those. Add branches for them!\n\n", - { - "bold": false, - "color": null, - "string": "Hint", - "underline": true - }, - ": If you want to write the code for each branch later, use `todo` as a\nplaceholder. Read for more\nguidance on this workflow." + "\n\nAdd the missing branches; use `todo` for unfinished bodies." ], "region": { "end": { @@ -164,6 +172,9 @@ expression: "normalized(serde_json::to_string_pretty(&json).unwrap().as_bytes(), "line": 7 } }, + "related": [], + "severity": "error", + "suggestions": [], "title": "MISSING PATTERNS" } ] diff --git a/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_terminal.snap b/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_terminal.snap index b0c27da8..0ed2a801 100644 --- a/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_terminal.snap +++ b/crates/nash-cli/tests/snapshots/diagnostics__documented_examples_terminal.snap @@ -2,72 +2,56 @@ source: crates/nash-cli/tests/diagnostics.rs expression: text --- -TYPE MISMATCH +nash::type::mismatch - ร— Something is off with the body of the `settle` definition: + ร— Type mismatch: expected `list int`, found `list Int`. โ•ญโ”€[/app/src/Ledger.nash:11:5] + 8 โ”‚ + 9 โ”‚ settle : list Account -> list int + ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ declared type 10 โ”‚ settle accounts = 11 โ”‚ map balanceOf accounts - ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ body of `settle` โ•ฐโ”€โ”€โ”€โ”€ - help: This `map` call produces: + help: Use `lower` to convert `Int` to `int` where a `Lift` impl is available. - list Int +nash::type::missing_impl - But the type annotation on `settle` says it should be: - - list int - - Hint: `Int` is the Big (Data) type and `int` is the little type. They never - convert implicitly. Where an appropriate `Lift` impl is available, use `lower` - to go from `Int` to `int`, or `lift` to go the other way. - -MISSING IMPL - - ร— I cannot find an `Eq` impl for `step`: + ร— No impl for `Eq step`. โ•ญโ”€[/app/src/Steps.nash:8:12] 7 โ”‚ isDone : step -> bool 8 โ”‚ isDone s = s == Done - ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ required by `==` โ•ฐโ”€โ”€โ”€โ”€ - help: The (==) operator needs its arguments to implement `Eq`, and here they are: - - step - - But there is no `impl Eq step` in this module or in any import. - - `Eq` is implemented for these heads: + help: Available impl heads: bool bytes int (list 'a0) - Hint: This local datatype is a candidate for `@derive(Eq)`, but automatic - deriving is not available yet. Write the impl by hand: + โ€ฆ - impl Eq step where - eq a b = ... + Import or define an impl for `Eq step`. -MISSING PATTERNS +nash::pattern::incomplete - ร— This `case` does not have branches for all possibilities: + ร— Case expression is not exhaustive. โ•ญโ”€[/app/src/Tag.nash:7:5] 6 โ”‚ tag d = 7 โ”‚ โ•ญโ”€โ–ถ case d of 8 โ”‚ โ”‚ Constr n _ -> n 9 โ”‚ โ•ฐโ”€โ–ถ List _ -> 0 โ•ฐโ”€โ”€โ”€โ”€ - help: Missing possibilities include: + help: Missing patterns: Map _ I _ B _ - I would have to crash if I saw one of those. Add branches for them! - - Hint: If you want to write the code for each branch later, use `todo` as a - placeholder. Read for more - guidance on this workflow. + Add the missing branches; use `todo` for unfinished bodies. Compilation failed: 22 succeeded, 3 failed or blocked. diff --git a/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_json.snap b/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_json.snap index 2b420705..b147e4ef 100644 --- a/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_json.snap +++ b/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_json.snap @@ -9,29 +9,53 @@ expression: "normalized(serde_json::to_string_pretty(&value).unwrap().as_bytes() "path": "/src/Main.nash", "problems": [ { - "message": [ - "Something is off with the body of the `bad` definition:\n\n3| bad = (().field, \\x -> x)\n ", + "code": "nash::type::mismatch", + "labels": [ { - "bold": false, - "color": "RED", - "string": "^^^^^^^^^^^^^^^^^^^", - "underline": false + "primary": true, + "region": { + "end": { + "column": 26, + "line": 3 + }, + "start": { + "column": 7, + "line": 3 + } + }, + "text": "body of `bad`" }, - "\nThe body is a tuple of type:\n\n ( ?, ", + { + "primary": false, + "region": { + "end": { + "column": 15, + "line": 2 + }, + "start": { + "column": 7, + "line": 2 + } + }, + "text": "declared type" + } + ], + "message": [ + "Type mismatch: expected `( ?, ", { "bold": false, "color": "yellow", - "string": "'a -> 'a", + "string": "unit", "underline": false }, - " )\n\nBut the type annotation on `bad` says it should be:\n\n ( ?, ", + " )`, found `( ?, ", { "bold": false, "color": "yellow", - "string": "unit", + "string": "'a -> 'a", "underline": false }, - " )" + " )`.\n\n" ], "region": { "end": { @@ -43,18 +67,31 @@ expression: "normalized(serde_json::to_string_pretty(&value).unwrap().as_bytes() "line": 3 } }, + "related": [], + "severity": "error", + "suggestions": [], "title": "TYPE MISMATCH" }, { - "message": [ - "This value is not a record, so I cannot use it when accessing the `field` field\nof this value:\n\n3| bad = (().field, \\x -> x)\n ", + "code": "nash::type::not_a_record", + "labels": [ { - "bold": false, - "color": "RED", - "string": "^^^^^^^^", - "underline": false - }, - "\nIt has type:\n\n unit\n\nBut I need a value with record fields!" + "primary": true, + "region": { + "end": { + "column": 16, + "line": 3 + }, + "start": { + "column": 8, + "line": 3 + } + }, + "text": "field `field` access" + } + ], + "message": [ + "Expected a record, found `unit`.\n\n" ], "region": { "end": { @@ -66,6 +103,9 @@ expression: "normalized(serde_json::to_string_pretty(&value).unwrap().as_bytes() "line": 3 } }, + "related": [], + "severity": "error", + "suggestions": [], "title": "TYPE MISMATCH" } ] diff --git a/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_terminal.snap b/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_terminal.snap index 1a7f716d..bea4225c 100644 --- a/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_terminal.snap +++ b/crates/nash-cli/tests/snapshots/diagnostics__poisoned_tuple_terminal.snap @@ -2,35 +2,27 @@ source: crates/nash-cli/tests/diagnostics.rs expression: "normalized(&human.stderr, &project.0)" --- -TYPE MISMATCH +nash::type::mismatch - ร— Something is off with the body of the `bad` definition: + ร— Type mismatch: expected `( ?, unit )`, found `( ?, 'a -> 'a )`. โ•ญโ”€[/src/Main.nash:3:7] + 1 โ”‚ module Main exposing (..) 2 โ”‚ bad : ((), ()) + ยท โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ declared type 3 โ”‚ bad = (().field, \x -> x) - ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ body of `bad` โ•ฐโ”€โ”€โ”€โ”€ - help: The body is a tuple of type: - ( ?, 'a -> 'a ) +nash::type::not_a_record - But the type annotation on `bad` says it should be: - - ( ?, unit ) - -TYPE MISMATCH - - ร— This value is not a record, so I cannot use it when accessing the `field` field - โ”‚ of this value: + ร— Expected a record, found `unit`. โ•ญโ”€[/src/Main.nash:3:8] 2 โ”‚ bad : ((), ()) 3 โ”‚ bad = (().field, \x -> x) - ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ยท โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ field `field` access โ•ฐโ”€โ”€โ”€โ”€ - help: It has type: - - unit - - But I need a value with record fields! Compilation failed: 0 succeeded, 1 failed or blocked. diff --git a/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_json.snap b/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_json.snap index f56fba89..c284f481 100644 --- a/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_json.snap +++ b/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_json.snap @@ -9,28 +9,53 @@ expression: "normalized(serde_json::to_string_pretty(&value).unwrap().as_bytes() "path": "/src/Main.nash", "problems": [ { - "message": [ - "Something is off with the body of the `identity` definition:\n\n5| identity flag = flag\n ", + "code": "nash::type::mismatch", + "labels": [ { - "bold": false, - "color": "RED", - "string": "^^^^", - "underline": false + "primary": true, + "region": { + "end": { + "column": 21, + "line": 5 + }, + "start": { + "column": 17, + "line": 5 + } + }, + "text": "body of `identity`" }, - "\nThis `flag` value is a:\n\n ", + { + "primary": false, + "region": { + "end": { + "column": 24, + "line": 4 + }, + "start": { + "column": 12, + "line": 4 + } + }, + "text": "declared type" + } + ], + "message": [ + "Type mismatch: expected `", { "bold": false, "color": "yellow", - "string": "bool", + "string": "unit", "underline": false }, - "\n\nBut the type annotation on `identity` says it should be:\n\n ", + "`, found `", { "bold": false, "color": "yellow", - "string": "unit", + "string": "bool", "underline": false - } + }, + "`.\n\n" ], "region": { "end": { @@ -42,6 +67,9 @@ expression: "normalized(serde_json::to_string_pretty(&value).unwrap().as_bytes() "line": 5 } }, + "related": [], + "severity": "error", + "suggestions": [], "title": "TYPE MISMATCH" } ] diff --git a/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_terminal.snap b/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_terminal.snap index 950a6b52..196983b4 100644 --- a/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_terminal.snap +++ b/crates/nash-cli/tests/snapshots/diagnostics__type_mismatch_terminal.snap @@ -2,20 +2,17 @@ source: crates/nash-cli/tests/diagnostics.rs expression: text --- -TYPE MISMATCH +nash::type::mismatch - ร— Something is off with the body of the `identity` definition: + ร— Type mismatch: expected `unit`, found `bool`. โ•ญโ”€[/src/Main.nash:5:17] + 3 โ”‚ 4 โ”‚ identity : bool -> unit + ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€ + ยท โ•ฐโ”€โ”€ declared type 5 โ”‚ identity flag = flag - ยท โ”€โ”€โ”€โ”€ + ยท โ”€โ”€โ”ฌโ”€ + ยท โ•ฐโ”€โ”€ body of `identity` โ•ฐโ”€โ”€โ”€โ”€ - help: This `flag` value is a: - - bool - - But the type annotation on `identity` says it should be: - - unit Compilation failed: 0 succeeded, 1 failed or blocked. diff --git a/crates/nash-constrain/src/error.rs b/crates/nash-constrain/src/error.rs index c97adca2..9dd71a78 100644 --- a/crates/nash-constrain/src/error.rs +++ b/crates/nash-constrain/src/error.rs @@ -163,19 +163,19 @@ pub struct AmbiguousPredicate<'a> { pub enum Expected<'a, T> { NoExpectation(T), FromContext(Region, Context<'a>, T), - FromAnnotation(&'a str, usize, SubContext, T), + FromAnnotation(&'a str, Region, usize, SubContext, T), } /// Indexes are zero-based, mirroring Elm's `Index.ZeroBased`. #[derive(Clone, Copy, Debug)] pub enum Context<'a> { RecordField(&'a str, &'a str), - ListEntry(usize), + ListEntry(usize, Option), OpLeft(&'a str), OpRight(&'a str), IfCondition, - IfBranch(usize), - CaseBranch(usize), + IfBranch(usize, Option), + CaseBranch(usize, Option), CallArity(MaybeName<'a>, usize), CallArg(MaybeName<'a>, usize), RecordAccess { @@ -233,7 +233,7 @@ pub enum PExpected<'a, T> { #[derive(Clone, Copy, Debug)] pub enum PContext<'a> { - TypedArg(&'a str, usize), + TypedArg(&'a str, usize, Region), CaseMatch(usize), CtorArg(&'a str, usize), ListEntry(usize), @@ -265,8 +265,8 @@ impl<'a, T> Expected<'a, T> { Expected::FromContext(region, context, _) => { Expected::FromContext(*region, *context, tipe) } - Expected::FromAnnotation(name, arity, context, _) => { - Expected::FromAnnotation(name, *arity, *context, tipe) + Expected::FromAnnotation(name, region, arity, context, _) => { + Expected::FromAnnotation(name, *region, *arity, *context, tipe) } } } diff --git a/crates/nash-driver/src/compile.rs b/crates/nash-driver/src/compile.rs index e022a342..9cd91538 100644 --- a/crates/nash-driver/src/compile.rs +++ b/crates/nash-driver/src/compile.rs @@ -749,12 +749,12 @@ mod trait_tests { ( "orphan", "module Bad exposing (..)\nimport Methods exposing (Keep)\nimport Types exposing (Token)\nimpl Keep Token where\n keep x = x\n", - "ORPHAN IMPL", + "nash::names::orphan_impl", ), ( "overlap", "module Bad exposing (..)\ntrait Keep 'a where\n keep : 'a -> 'a\nimpl Keep () where\n keep x = x\nimpl Keep () where\n keep x = x\n", - "OVERLAPPING IMPL", + "nash::names::overlapping_impls", ), ] { let result = compile_sources(&[ @@ -828,7 +828,7 @@ mod kind_tests { panic!("consumer must reject hidden label") }; let message = report_text(reports); - assert!(message.contains("not a record"), "{message}"); + assert!(message.contains("nash::type::not_a_record"), "{message}"); } #[tokio::test] @@ -845,7 +845,7 @@ mod kind_tests { }; let message = report_text(reports); assert!( - message.contains("does not support record updates"), + message.contains("nash::type::update_not_record"), "{message}" ); } @@ -864,7 +864,7 @@ mod kind_tests { panic!("private constructor labels must remain hidden") }; let message = report_text(reports); - assert!(message.contains("not a record"), "{message}"); + assert!(message.contains("nash::type::not_a_record"), "{message}"); } #[tokio::test] @@ -944,7 +944,7 @@ mod kind_tests { panic!("producer must report a kind error"); }; let message = report_text(reports); - assert!(message.contains("INFINITE KIND"), "{message}"); + assert!(message.contains("nash::names::kind_infinite"), "{message}"); assert!(message.contains("infinite kind"), "{message}"); } @@ -963,7 +963,7 @@ mod kind_tests { panic!("producer must fail") }; let message = report_text(reports); - assert!(message.contains("INFINITE KIND"), "{message}"); + assert!(message.contains("nash::names::kind_infinite"), "{message}"); } } @@ -999,7 +999,8 @@ mod kind_tests { }; let message = report_text(reports); assert!( - message.contains("REPRESENTATION MISMATCH") && message.contains("Storable"), + message.contains("nash::names::representation_mismatch") + && message.contains("Storable"), "{message}" ); } @@ -1029,7 +1030,8 @@ mod kind_tests { }; let message = report_text(reports); assert!( - message.contains("REPRESENTATION MISMATCH") && message.contains("Storable"), + message.contains("nash::names::representation_mismatch") + && message.contains("Storable"), "{message}" ); assert!( diff --git a/crates/nash-driver/src/compile/nitpick_tests.rs b/crates/nash-driver/src/compile/nitpick_tests.rs index 3dab5f45..9a7b0e53 100644 --- a/crates/nash-driver/src/compile/nitpick_tests.rs +++ b/crates/nash-driver/src/compile/nitpick_tests.rs @@ -37,7 +37,7 @@ fn incomplete_case_fails_module() { ); let message = rejected(source); assert!( - message.contains("MISSING PATTERNS") && message.contains("False"), + message.contains("nash::pattern::incomplete") && message.contains("False"), "{message}" ); insta::assert_snapshot!(message); @@ -57,7 +57,7 @@ fn redundant_case_fails_module() { ); let message = rejected(source); assert!( - message.contains("REDUNDANT PATTERN") && message.contains("2nd pattern"), + message.contains("nash::pattern::redundant") && message.contains("2nd pattern"), "{message}" ); insta::assert_snapshot!(message); @@ -66,7 +66,10 @@ fn redundant_case_fails_module() { #[test] fn unsafe_argument_fails_module() { let message = rejected("module Main exposing (..)\nf (x :: _) = x\n"); - assert!(message.contains("function arguments"), "{message}"); + assert!( + message.contains("Argument pattern is not exhaustive"), + "{message}" + ); insta::assert_snapshot!(message); } @@ -83,7 +86,7 @@ fn unsafe_destructure_fails_module() { "# )); assert!( - message.contains("only if there is ONE possibility"), + message.contains("Binding pattern is not exhaustive"), "{message}" ); insta::assert_snapshot!(message); @@ -103,7 +106,7 @@ fn trait_default_without_top_level_definitions_fails_module() { "# )); assert!( - message.contains("MISSING PATTERNS") && message.contains("False"), + message.contains("nash::pattern::incomplete") && message.contains("False"), "{message}" ); insta::assert_snapshot!(message); @@ -122,7 +125,8 @@ fn impl_method_fails_module() { "# )); assert!( - message.contains("UNSAFE PATTERN") && message.contains("function arguments"), + message.contains("nash::pattern::incomplete") + && message.contains("Argument pattern is not exhaustive"), "{message}" ); insta::assert_snapshot!(message); @@ -138,8 +142,8 @@ fn type_errors_precede_nitpick() { f True = True "# )); - assert!(message.contains("TYPE MISMATCH"), "{message}"); - assert!(!message.contains("MISSING PATTERNS"), "{message}"); + assert!(message.contains("nash::type::mismatch"), "{message}"); + assert!(!message.contains("nash::pattern::incomplete"), "{message}"); } #[test] @@ -173,7 +177,7 @@ fn rejected_module_publishes_no_interface_to_dependents() { panic!("base must fail") }; let message = report_text(reports); - assert!(message.contains("UNSAFE PATTERN"), "{message}"); + assert!(message.contains("nash::pattern::incomplete"), "{message}"); assert!( matches!(&result.modules[&url("Main")], ModuleResult::Blocked { dependencies } if dependencies == &[url("Base")]) ); diff --git a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__impl_method_fails_module.snap b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__impl_method_fails_module.snap index 03d17767..75d21a4e 100644 --- a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__impl_method_fails_module.snap +++ b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__impl_method_fails_module.snap @@ -2,18 +2,16 @@ source: crates/nash-driver/src/compile/nitpick_tests.rs expression: message --- -UNSAFE PATTERN +nash::pattern::incomplete - ร— This pattern does not cover all possibilities: + ร— Argument pattern is not exhaustive. โ•ญโ”€[/Main.nash:6:12] 5 โ”‚ impl Choose bool where 6 โ”‚ choose True = () ยท โ”€โ”€โ”€โ”€ โ•ฐโ”€โ”€โ”€โ”€ - help: Other possibilities include: + help: Missing patterns: False - I would have to crash if I saw one of those! So rather than pattern matching in - function arguments, put a `case` in the function body to account for all - possibilities. + Use a case expression in the function body to handle the missing patterns. diff --git a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__incomplete_case_fails_module.snap b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__incomplete_case_fails_module.snap index 9c5ef837..3ea0b3d9 100644 --- a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__incomplete_case_fails_module.snap +++ b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__incomplete_case_fails_module.snap @@ -2,20 +2,16 @@ source: crates/nash-driver/src/compile/nitpick_tests.rs expression: message --- -MISSING PATTERNS +nash::pattern::incomplete - ร— This `case` does not have branches for all possibilities: + ร— Case expression is not exhaustive. โ•ญโ”€[/Main.nash:4:5] 3 โ”‚ f x = 4 โ”‚ โ•ญโ”€โ–ถ case x of 5 โ”‚ โ•ฐโ”€โ–ถ True -> () โ•ฐโ”€โ”€โ”€โ”€ - help: Missing possibilities include: + help: Missing patterns: False - I would have to crash if I saw one of those. Add branches for them! - - Hint: If you want to write the code for each branch later, use `todo` as a - placeholder. Read for more - guidance on this workflow. + Add the missing branches; use `todo` for unfinished bodies. diff --git a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__redundant_case_fails_module.snap b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__redundant_case_fails_module.snap index a1c6a34b..5f0ac4ad 100644 --- a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__redundant_case_fails_module.snap +++ b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__redundant_case_fails_module.snap @@ -2,9 +2,9 @@ source: crates/nash-driver/src/compile/nitpick_tests.rs expression: message --- -REDUNDANT PATTERN +nash::pattern::redundant - ร— The 2nd pattern is redundant: + ร— The 2nd pattern is unreachable. โ•ญโ”€[/Main.nash:6:9] 3 โ”‚ f x = 4 โ”‚ case x of @@ -12,5 +12,4 @@ REDUNDANT PATTERN 6 โ”‚ True -> () ยท โ”€โ”€โ”€โ”€โ”€ โ•ฐโ”€โ”€โ”€โ”€ - help: Any value with this shape will be handled by a previous pattern, so it should be - removed. + help: Remove it; earlier patterns cover every matching value. diff --git a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__trait_default_without_top_level_definitions_fails_module.snap b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__trait_default_without_top_level_definitions_fails_module.snap index 1ae07468..85eb2f19 100644 --- a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__trait_default_without_top_level_definitions_fails_module.snap +++ b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__trait_default_without_top_level_definitions_fails_module.snap @@ -2,20 +2,16 @@ source: crates/nash-driver/src/compile/nitpick_tests.rs expression: message --- -MISSING PATTERNS +nash::pattern::incomplete - ร— This `case` does not have branches for all possibilities: + ร— Case expression is not exhaustive. โ•ญโ”€[/Main.nash:6:9] 5 โ”‚ choose _ flag = 6 โ”‚ โ•ญโ”€โ–ถ case flag of 7 โ”‚ โ•ฐโ”€โ–ถ True -> () โ•ฐโ”€โ”€โ”€โ”€ - help: Missing possibilities include: + help: Missing patterns: False - I would have to crash if I saw one of those. Add branches for them! - - Hint: If you want to write the code for each branch later, use `todo` as a - placeholder. Read for more - guidance on this workflow. + Add the missing branches; use `todo` for unfinished bodies. diff --git a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_argument_fails_module.snap b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_argument_fails_module.snap index 7fe6e331..3103e280 100644 --- a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_argument_fails_module.snap +++ b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_argument_fails_module.snap @@ -2,18 +2,16 @@ source: crates/nash-driver/src/compile/nitpick_tests.rs expression: message --- -UNSAFE PATTERN +nash::pattern::incomplete - ร— This pattern does not cover all possibilities: + ร— Argument pattern is not exhaustive. โ•ญโ”€[/Main.nash:2:4] 1 โ”‚ module Main exposing (..) 2 โ”‚ f (x :: _) = x ยท โ”€โ”€โ”€โ”€โ”€โ”€ โ•ฐโ”€โ”€โ”€โ”€ - help: Other possibilities include: + help: Missing patterns: [] - I would have to crash if I saw one of those! So rather than pattern matching in - function arguments, put a `case` in the function body to account for all - possibilities. + Use a case expression in the function body to handle the missing patterns. diff --git a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_destructure_fails_module.snap b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_destructure_fails_module.snap index 65b9a3ed..b1889d98 100644 --- a/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_destructure_fails_module.snap +++ b/crates/nash-driver/src/compile/snapshots/nash_driver__compile__nitpick_tests__unsafe_destructure_fails_module.snap @@ -2,37 +2,28 @@ source: crates/nash-driver/src/compile/nitpick_tests.rs expression: message --- -UNSAFE PATTERN +nash::pattern::incomplete - ร— This pattern does not cover all possible values: + ร— Binding pattern is not exhaustive. โ•ญโ”€[/Main.nash:4:10] 3 โ”‚ let 4 โ”‚ (x :: rest) = xs ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 5 โ”‚ in โ•ฐโ”€โ”€โ”€โ”€ - help: Other possibilities include: + help: Missing patterns: [] - I would have to crash if I saw one of those! You can use `let` to deconstruct - values only if there is ONE possibility. Switch to a `case` expression to - account for all possibilities. + Use a case expression to handle the missing patterns. - Hint: Are you calling a function that definitely returns values with a very - specific shape? Try making the return type of that function more specific! +nash::warning::unused_definition -unused definition - - โš  You are not using `rest` anywhere. + โš  Unused definition `rest`. โ•ญโ”€[/Main.nash:4:15] 3 โ”‚ let 4 โ”‚ (x :: rest) = xs ยท โ”€โ”€โ”€โ”€ 5 โ”‚ in โ•ฐโ”€โ”€โ”€โ”€ - help: Is there a typo? Maybe you intended to use `rest` somewhere but typed another - name instead? - - If you are sure there is no typo, remove the definition. This way future readers - will not have to wonder why it is there! + help: Remove the definition if it is not needed. diff --git a/crates/nash-driver/src/snapshots/nash_driver__compile__trait_tests__driver_reports_orphan_and_overlap_at_the_impl_module.snap b/crates/nash-driver/src/snapshots/nash_driver__compile__trait_tests__driver_reports_orphan_and_overlap_at_the_impl_module.snap index e7dd108e..0269be85 100644 --- a/crates/nash-driver/src/snapshots/nash_driver__compile__trait_tests__driver_reports_orphan_and_overlap_at_the_impl_module.snap +++ b/crates/nash-driver/src/snapshots/nash_driver__compile__trait_tests__driver_reports_orphan_and_overlap_at_the_impl_module.snap @@ -2,7 +2,7 @@ source: crates/nash-driver/src/compile.rs expression: "diagnostics.join(\"\\n\")" --- -orphan: ORPHAN IMPL +orphan: nash::names::orphan_impl ร— This module cannot define an impl of `Methods.Keep` for Types.Token: โ•ญโ”€[/Bad.nash:4:1] @@ -13,7 +13,7 @@ orphan: ORPHAN IMPL help: An impl must be defined in the module that defines its trait or one of its head types. Move this impl to one of those modules. -overlap: OVERLAPPING IMPL +overlap: nash::names::overlapping_impls ร— These `Bad.Keep` impls can both match the same trait arguments. The overlapping โ”‚ head is Builtin.unit: @@ -26,6 +26,5 @@ overlap: OVERLAPPING IMPL 7 โ”‚ โ”œโ”€โ–ถ keep x = x ยท โ•ฐโ”€โ”€โ”€โ”€ overlapping impl in `Bad` โ•ฐโ”€โ”€โ”€โ”€ - help: I cannot choose which impl to use. Remove one of them, or change their heads so - they cannot match the same trait arguments. Adding different context constraints - does not disambiguate overlapping heads. + help: Remove one impl or make their heads disjoint; context constraints do not + disambiguate heads. diff --git a/crates/nash-language-server/src/diagnostics.rs b/crates/nash-language-server/src/diagnostics.rs index ab91d010..57ba383f 100644 --- a/crates/nash-language-server/src/diagnostics.rs +++ b/crates/nash-language-server/src/diagnostics.rs @@ -1,6 +1,6 @@ //! Convert compiler reports to LSP without changing their primary spans. use nash_region::{Position as NashPosition, Region}; -use nash_report::{Report, Severity, Snippet, Source}; +use nash_report::{Report, Severity, Source}; use tower_lsp_server::ls_types::{ Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location, NumberOrString, Position, Range, Uri, @@ -16,43 +16,74 @@ pub fn to_lsp(report: &Report, source: &Source<'_>, uri: &Uri) -> Diagnostic { } fn checked_to_lsp(report: &Report, source: &Source<'_>, uri: &Uri) -> Option { - let related = match &report.snippet { - Snippet::Pair { first, .. } => Some((first.region, first.text.clone())), - Snippet::Region { - highlight: Some(highlight), - .. - } if *highlight != report.region => Some((*highlight, "Related source".into())), - _ => None, - }; - let related_information = match related { - Some((region, message)) => Some(vec![DiagnosticRelatedInformation { + let mut related = Vec::new(); + for label in &report.labels { + related.push(DiagnosticRelatedInformation { location: Location { uri: uri.clone(), - range: to_range(region, source)?, + range: to_range(label.region, source)?, }, - message, - }]), - None => None, - }; + message: label.text.clone(), + }); + } + related_reports(&report.related, uri, &mut related)?; + let related_information = (!related.is_empty()).then_some(related); Some(Diagnostic { range: to_range(report.region, source)?, severity: Some(match report.severity { Severity::Error => DiagnosticSeverity::ERROR, Severity::Warning => DiagnosticSeverity::WARNING, }), - code: Some(NumberOrString::String(report.title.clone())), + code: Some(NumberOrString::String(report.code.to_string())), source: Some("nash".into()), - message: format!( - "{}\n\n{}", - report.before.render(80, false), - report.after.render(80, false) - ), + message: report.message(), related_information, data: (!report.suggestions.is_empty()).then(|| serde_json::json!(report.suggestions)), ..Diagnostic::default() }) } +fn related_reports( + modules: &[nash_report::ModuleReports], + base_uri: &Uri, + output: &mut Vec, +) -> Option<()> { + for module in modules { + let path = std::path::Path::new(&module.path); + let path = if path.is_absolute() { + path.to_owned() + } else { + let base = url::Url::parse(base_uri.as_str()) + .ok()? + .to_file_path() + .ok()?; + base.parent()?.join(path) + }; + let uri: Uri = url::Url::from_file_path(path).ok()?.as_str().parse().ok()?; + let source = Source::new(&module.source); + for report in &module.reports { + output.push(DiagnosticRelatedInformation { + location: Location { + uri: uri.clone(), + range: to_range(report.region, &source)?, + }, + message: report.message(), + }); + for label in &report.labels { + output.push(DiagnosticRelatedInformation { + location: Location { + uri: uri.clone(), + range: to_range(label.region, &source)?, + }, + message: label.text.clone(), + }); + } + related_reports(&report.related, &uri, output)?; + } + } + Some(()) +} + pub fn to_range(region: Region, source: &Source<'_>) -> Option { Some(Range::new( to_position(region.start, source)?, @@ -134,7 +165,10 @@ mod tests { diagnostic.related_information.unwrap()[0].location.range, to_range(region(1, 1, 1, 2), &source).unwrap() ); - assert_eq!(diagnostic.message, "Duplicate names:\n\nRename one."); + assert_eq!( + diagnostic.message, + "Duplicate names:\n\nsecond name\n\nRename one." + ); } #[test] fn highlighted_region_and_suggestions_survive() { @@ -152,8 +186,75 @@ mod tests { let diagnostic = to_lsp(&report, &source, &uri); assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING)); assert_eq!(diagnostic.data, Some(serde_json::json!(["known"]))); - assert!(diagnostic.related_information.is_some()); - report.snippet = Snippet::None; + assert_eq!( + diagnostic.range, + to_range(region(1, 5, 1, 12), &source).unwrap() + ); + report = report.without_source(); assert!(to_lsp(&report, &source, &uri).related_information.is_none()); } } + +#[cfg(test)] +mod structured_tests { + use super::*; + use nash_report::{Doc, Label, ModuleReports}; + fn region(column: usize) -> Region { + Region::new( + NashPosition::new(1, column), + NashPosition::new(1, column + 1), + ) + } + #[test] + fn labels_related_files_and_codes_survive_conversion() { + let mut report = Report::snippet( + "OLD TITLE", + region(5), + None, + Doc::text("Type mismatch."), + Doc::Empty, + ) + .with_code("nash::type::mismatch") + .with_label(Label { + region: region(1), + text: "first requirement".into(), + }) + .with_label(Label { + region: region(3), + text: "second requirement".into(), + }); + report.title = "A new display title".into(); + report.primary_label = Some("argument 2 of `f`".into()); + let mut origin = Report::snippet( + "ORIGIN", + region(5), + None, + Doc::text("Declared here."), + Doc::Empty, + ); + origin.primary_label = Some("annotation for `f`".into()); + let report = report.with_related(ModuleReports { + name: "Other".into(), + path: "Other #.nash".into(), + source: "a b c".into(), + reports: vec![origin], + }); + let uri: Uri = "file:///project/Main.nash".parse().unwrap(); + let diagnostic = to_lsp(&report, &Source::new("a b c"), &uri); + assert_eq!( + diagnostic.code, + Some(NumberOrString::String("nash::type::mismatch".into())) + ); + assert!( + diagnostic.message.contains("argument 2 of `f`"), + "{diagnostic:?}" + ); + let related = diagnostic.related_information.unwrap(); + assert_eq!(related.len(), 3); + assert_eq!( + related[2].location.uri.as_str(), + "file:///project/Other%20%23.nash" + ); + assert!(related[2].message.contains("annotation for `f`")); + } +} diff --git a/crates/nash-language-server/src/workspace.rs b/crates/nash-language-server/src/workspace.rs index 65955e98..6d5db528 100644 --- a/crates/nash-language-server/src/workspace.rs +++ b/crates/nash-language-server/src/workspace.rs @@ -329,7 +329,7 @@ mod tests { for (diagnostic, problem) in lsp.iter().zip(problems) { assert_eq!( serde_json::to_value(&diagnostic.code).unwrap(), - problem["title"] + problem["code"] ); assert_eq!( diagnostic.range.start.line + 1, diff --git a/crates/nash-report/src/canonicalize.rs b/crates/nash-report/src/canonicalize.rs index f8076295..8b13bdec 100644 --- a/crates/nash-report/src/canonicalize.rs +++ b/crates/nash-report/src/canonicalize.rs @@ -1,6 +1,6 @@ //! Canonicalization reports, adapted from Elm's Reporting/Error/Canonicalize.hs. //! Nash adds trait, kind, representation, and nominal-record diagnostics. -use crate::{Doc, Label, Report, Snippet, Source, suggest}; +use crate::{Doc, Label, Report, Source, suggest}; use nash_ast::{Kind, ModuleName, QualifiedName}; use nash_can::{ BadArityContext, DuplicatePatternContext, Error, KindContext, PossibleNames, VarKind, @@ -74,7 +74,7 @@ pub fn to_report(source: &Source<'_>, error: &Error<'_>) -> Report { } pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name: &str) -> Report { - match error { + let report = match error { Error::MissingModuleHeader => crate::syntax::to_report( source, &nash_parse::error::Error::ModuleNameUnspecified(expected_name), @@ -319,12 +319,10 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name second, } => Report::pair( "REDUNDANT EXPORT", - label(*first, "once here"), - label(*second, "and again right here"), - Doc::reflow(&format!( - "You are trying to expose `{name}` multiple times! Once here:" - )), - Doc::text("Remove one of them and you should be all set!"), + label(*first, "first export"), + label(*second, "duplicate export"), + Doc::reflow(&format!("Duplicate export `{name}`.")), + Doc::text("Remove the duplicate export."), ), Error::ExportNotFound { region, @@ -332,36 +330,31 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name name, suggestions, } => { - let (article, thing, display) = to_kind_info(*kind, name); + let (_article, thing, display) = to_kind_info(*kind, name); let nearby = nearby(name, suggestions, 4); let mut report = simple( "UNKNOWN EXPORT", *region, - &format!( - "You are trying to expose {article} {thing} named {display} but I cannot find its definition." - ), + &format!("Unknown exported {thing} {display}."), "", ); - report.snippet = Snippet::None; report.after = suggestion_details( &nearby, - "I do not see any super similar names in this file. Is the definition missing?", + "Define the name or remove it from the exposing list.", ); report.with_suggestions(nearby) } Error::ExportOpenAlias { region, name } => simple( "BAD EXPORT", *region, - &format!( - "The (..) syntax is for exposing variants of a custom type. It cannot be used with a type alias like `{name}` though." - ), - "Remove the (..) and you should be fine!", + &format!("Type alias `{name}` has no variants to expose."), + "Remove `(..)`.", ), Error::ImportOpenAlias { region, name } => simple( "BAD IMPORT", *region, - &format!("The `{name}` type alias cannot be followed by (..) like this:"), - "Remove the (..) and it should work.", + &format!("Type alias `{name}` has no variants to import."), + "Remove `(..)`.", ), Error::ImportCtorByName { region, @@ -370,15 +363,13 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name } => simple( "BAD IMPORT", *region, - &format!("You are trying to import the `{name}` variant by name:"), - &format!( - "Try importing {type_name}(..) instead. The dots mean โ€œexpose the {type_name} type and all its variantsโ€ so it gives you access to {name}." - ), + &format!("Cannot import variant `{name}` directly."), + &format!("Import `{type_name}(..)` to make its variants available."), ), Error::ImportNotFound { region, module } => simple( "UNKNOWN IMPORT", *region, - &format!("I could not find a `{module}` module to import!"), + &format!("Unknown module `{module}`."), "", ), Error::ImportExposingNotFound { @@ -395,10 +386,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name &format!("The `{}` module does not expose `{name}`:", module.name), "", ); - report.after = suggestion_details( - &nearby, - "I cannot find any super similar exposed names. Maybe it is private?", - ); + report.after = suggestion_details(&nearby, "Check that the module exposes this name."); report.with_suggestions(nearby) } Error::BinopFunctionNotFound { @@ -408,16 +396,14 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name } => simple( "INFIX PROBLEM", *region, - &format!( - "The ({op}) operator says it is implemented by `{function}`, but I cannot find a `{function}` definition in this file." - ), + &format!("Operator `({op})` refers to undefined function `{function}`."), "Define it, or point the `infix` declaration at an existing top-level value.", ), Error::BinopConflict { region, op1, op2 } => simple( "INFIX PROBLEM", *region, &format!("You cannot mix ({op1}) and ({op2}) without parentheses."), - "I do not know how to group these expressions. Add parentheses for me!", + "Add parentheses to specify the grouping.", ), Error::NotFoundBinop { region, @@ -427,10 +413,8 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name Error::PatternHasRecordCtor { region, name } => simple( "BAD PATTERN", *region, - &format!( - "You can construct records by using `{name}` as a function, but it is not available in pattern matching like this:" - ), - "I recommend matching the record as a variable and unpacking it later.", + &format!("Record constructor `{name}` cannot be used in a pattern."), + "Bind the record to a variable and access its fields.", ), Error::Shadowing { name, @@ -439,19 +423,9 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name } => Report::pair( "SHADOWING", label(*original, "first defined here"), - label(*new, "defined AGAIN here"), - Doc::reflow(&format!("The name `{name}` is first defined here:")), - Doc::stack([ - Doc::reflow( - "Think of a more helpful name for one of them and you should be all set!", - ), - Doc::link( - "Note", - "Linters advise against shadowing, so Nash makes โ€œbest practicesโ€ the default. Read", - "shadowing", - "for more details on this choice.", - ), - ]), + label(*new, "shadows this name"), + Doc::reflow(&format!("Name `{name}` is already defined.")), + Doc::text("Rename one of these bindings."), ), Error::RecursiveDecl { name, others } => { recursive_value(name.region, name.value, others, false) @@ -468,12 +442,12 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "BAD TYPE ANNOTATION", *region, &format!( - "The type annotation for `{name}` says it can accept {}, but the definition says it has {}:", + "Annotation for `{name}` expects {}; definition has {}.", args(*index), args(index + leftovers) ), &format!( - "Is the type annotation missing something? Should some argument{} be deleted? Maybe some parentheses are missing?", + "Match the annotation to the definition's argument{}.", if *leftovers == 1 { "" } else { "s" } ), ), @@ -506,9 +480,9 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name } => simple( "KIND MISMATCH", *region, - &format!("I found a kind mismatch in {}:", kind_context(context)), + &format!("Kind mismatch in {}.", kind_context(context)), &format!( - "This position needs kind `{}`, but the type has kind `{}`. Type arguments must have matching kinds.", + "Expected kind `{}`, found `{}`.", kind(expected), kind(actual) ), @@ -520,7 +494,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "This application in {} would require an infinite kind:", kind_context(context) ), - "A type constructor cannot be applied to itself. Check which type is being applied and the kinds of its arguments.", + "Check the type application and the kinds of its arguments.", ), Error::RepresentationMismatch { region, @@ -546,7 +520,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "CONTRADICTORY REPRESENTATION", *region, &format!("The representation requirements on '{variable} are incompatible:"), - "No type can satisfy all of these requirements. Change the constraints or the positions where this type variable is used.", + "Change the incompatible constraints or uses of this variable.", ), Error::IrregularRecursion { region, @@ -559,7 +533,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "This recursive use of `{}` constructs the parameter '{parameter}:", qualified(*constructor) ), - "This parameter controls the constraints needed to form the type. Pass a type variable here so context inference can terminate.", + "Pass a type variable for this parameter so context inference can terminate.", ), Error::ImplOfBuiltinTrait { region, trait_ } => simple( "BUILTIN TRAIT", @@ -578,9 +552,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "MISSING METHOD", *region, &format!("This `{trait_}` impl does not define `{name}`:"), - &format!( - "The `{trait_}` trait requires this method and does not provide a default. Add a `{name}` definition to this impl." - ), + &format!("Add a `{name}` definition to this impl."), ), Error::UnknownMethod { region, @@ -590,7 +562,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "UNKNOWN METHOD", *region, &format!("The `{trait_}` trait has no `{name}` method:"), - "Check the method name against the trait declaration. Remove this definition or rename it to the method you intended to implement.", + "Remove or rename this method to match the trait declaration.", ), Error::BadInstanceHead { region, reason } => { use nash_can::BadHead; @@ -640,7 +612,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name key.heads.iter().map(head).collect::>().join(" ") )), Doc::reflow( - "I cannot choose which impl to use. Remove one of them, or change their heads so they cannot match the same trait arguments. Adding different context constraints does not disambiguate overlapping heads.", + "Remove one impl or make their heads disjoint; context constraints do not disambiguate heads.", ), ), Error::MissingSuperclass { @@ -734,9 +706,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name first.map_or("trait", |n| n.value), &names.iter().skip(1).map(|n| n.value).collect::>(), ), - Doc::reflow( - "Remove a superclass dependency to break the cycle. A trait cannot require itself through its superclasses.", - ), + Doc::reflow("Remove a superclass dependency to break the cycle."), ]); report } @@ -762,7 +732,7 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "UNKNOWN RECORD", *region, &format!( - "I cannot find a visible record alias with exactly these fields: {}.", + "No visible record alias has exactly these fields: {}.", fields.join(", ") ), "Declare or import an alias for this record.", @@ -814,25 +784,25 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name "IMPL PATTERN LIMIT", *region, "This impl pattern is too large or deeply nested:", - "Simplify the impl head so the compiler can compare and resolve its patterns within the supported limit.", + "Simplify the impl head.", ), Error::NegateWithoutNum { region } => simple( "NAMING ERROR", *region, - "I cannot resolve numeric negation here:", - "Negation requires the `Num` trait. Import the module that defines `Num` before using a negative expression.", + "Numeric negation requires `Num`.", + "Import the module that defines `Num`.", ), Error::DoWithoutMonad { region } => simple( "NAMING ERROR", *region, - "I cannot resolve this `do` expression:", - "A `do` expression requires the `Monad` trait. Import the module that defines `Monad`.", + "A `do` expression requires `Monad`.", + "Import the module that defines `Monad`.", ), Error::RefutableBindPattern { region } => simple( "UNSAFE PATTERN", *region, "This `do` binding has a pattern that can fail to match:", - "Use a variable or another irrefutable pattern here. Match individual variants in a `case` expression so every possibility is handled.", + "Bind a variable here, then use `case` to handle every variant.", ), Error::StructuralEqOverride { head } => simple( "STRUCTURAL EQUALITY", @@ -855,10 +825,85 @@ pub fn to_report_with_name(source: &Source<'_>, error: &Error<'_>, expected_name Error::Unsupported { feature, region } => simple( "NOT SUPPORTED", *region, - &format!("I cannot canonicalize {feature} yet:"), + &format!("Unsupported feature: {feature}."), "This syntax is recognized, but its compiler implementation is not available yet.", ), - } + }; + report.with_code(match error { + Error::RecordLiteralNoAlias { .. } => "nash::names::record_literal_no_alias", + Error::RecordLiteralAmbiguous { .. } => "nash::names::record_literal_ambiguous", + Error::RecordTypeOutsideAlias { .. } => "nash::names::record_type_outside_alias", + Error::ImplPatternLimit { .. } => "nash::names::impl_pattern_limit", + Error::NegateWithoutNum { .. } => "nash::names::negate_without_num", + Error::DoWithoutMonad { .. } => "nash::names::do_without_monad", + Error::RefutableBindPattern { .. } => "nash::names::refutable_bind_pattern", + Error::StructuralEqOverride { .. } => "nash::names::structural_eq_override", + Error::ReflexiveLiftOverlap { .. } => "nash::names::reflexive_lift_overlap", + Error::MissingSuperclass { .. } => "nash::names::missing_superclass", + Error::BadInstanceHead { .. } => "nash::names::bad_instance_head", + Error::ImplContextVarNotInHead { .. } => "nash::names::impl_context_var_not_in_head", + Error::MissingMethod { .. } => "nash::names::missing_method", + Error::UnknownMethod { .. } => "nash::names::unknown_method", + Error::OrphanImpl { .. } => "nash::names::orphan_impl", + Error::OverlappingImpls { .. } => "nash::names::overlapping_impls", + Error::ImportOpenTrait { .. } => "nash::names::import_open_trait", + Error::DuplicateTrait { .. } => "nash::names::duplicate_trait", + Error::DuplicateMethod { .. } => "nash::names::duplicate_method", + Error::DuplicateTraitParameter { .. } => "nash::names::duplicate_trait_parameter", + Error::SuperclassBadArg { .. } => "nash::names::superclass_bad_arg", + Error::MethodMissingParameter { .. } => "nash::names::method_missing_parameter", + Error::RecursiveSuperclass { .. } => "nash::names::recursive_superclass", + Error::ExportOpenTrait { .. } => "nash::names::export_open_trait", + Error::NotFoundTrait { .. } => "nash::names::not_found_trait", + Error::AmbiguousTrait { .. } => "nash::names::ambiguous_trait", + Error::TraitArity { .. } => "nash::names::trait_arity", + Error::ContextVarNotInType { .. } => "nash::names::context_var_not_in_type", + Error::KindMismatch { .. } => "nash::names::kind_mismatch", + Error::KindInfinite { .. } => "nash::names::kind_infinite", + Error::RepresentationMismatch { .. } => "nash::names::representation_mismatch", + Error::ContradictoryRepresentation { .. } => "nash::names::contradictory_representation", + Error::ImplOfBuiltinTrait { .. } => "nash::names::impl_of_builtin_trait", + Error::IrregularRecursion { .. } => "nash::names::irregular_recursion", + Error::Unsupported { .. } => "nash::names::unsupported", + Error::MissingModuleHeader => "nash::names::missing_module_header", + Error::NotFoundType { .. } => "nash::names::not_found_type", + Error::ImportNotFound { .. } => "nash::names::import_not_found", + Error::AmbiguousType { .. } => "nash::names::ambiguous_type", + Error::BadArity { .. } => "nash::names::bad_arity", + Error::ExportNotFound { .. } => "nash::names::export_not_found", + Error::ExportOpenAlias { .. } => "nash::names::export_open_alias", + Error::DuplicateDecl { .. } => "nash::names::duplicate_decl", + Error::DuplicateType { .. } => "nash::names::duplicate_type", + Error::DuplicateCtor { .. } => "nash::names::duplicate_ctor", + Error::DuplicateBinop { .. } => "nash::names::duplicate_binop", + Error::BinopFunctionNotFound { .. } => "nash::names::binop_function_not_found", + Error::DuplicateUnionArg { .. } => "nash::names::duplicate_union_arg", + Error::DuplicateAliasArg { .. } => "nash::names::duplicate_alias_arg", + Error::RecursiveAlias { .. } => "nash::names::recursive_alias", + Error::TypeVarsUnboundInUnion { .. } => "nash::names::type_vars_unbound_in_union", + Error::TypeVarsMessedUpInAlias { .. } => "nash::names::type_vars_messed_up_in_alias", + Error::LabeledCtorMissingField { .. } => "nash::names::labeled_ctor_missing_field", + Error::LabeledCtorExtraField { .. } => "nash::names::labeled_ctor_extra_field", + Error::LabeledCtorUnknownField { .. } => "nash::names::labeled_ctor_unknown_field", + Error::DuplicateField { .. } => "nash::names::duplicate_field", + Error::ExportDuplicate { .. } => "nash::names::export_duplicate", + Error::NotFoundCtor { .. } => "nash::names::not_found_ctor", + Error::AmbiguousCtor { .. } => "nash::names::ambiguous_ctor", + Error::PatternHasRecordCtor { .. } => "nash::names::pattern_has_record_ctor", + Error::DuplicatePattern { .. } => "nash::names::duplicate_pattern", + Error::NotFoundVar { .. } => "nash::names::not_found_var", + Error::AmbiguousVar { .. } => "nash::names::ambiguous_var", + Error::NotFoundBinop { .. } => "nash::names::not_found_binop", + Error::AmbiguousBinop { .. } => "nash::names::ambiguous_binop", + Error::BinopConflict { .. } => "nash::names::binop_conflict", + Error::Shadowing { .. } => "nash::names::shadowing", + Error::RecursiveLet { .. } => "nash::names::recursive_let", + Error::RecursiveDecl { .. } => "nash::names::recursive_decl", + Error::AnnotationTooShort { .. } => "nash::names::annotation_too_short", + Error::ImportExposingNotFound { .. } => "nash::names::import_exposing_not_found", + Error::ImportCtorByName { .. } => "nash::names::import_ctor_by_name", + Error::ImportOpenAlias { .. } => "nash::names::import_open_alias", + }) } fn simple(title: &str, region: Region, before: &str, after: &str) -> Report { @@ -873,10 +918,10 @@ fn label(region: Region, text: &str) -> Label { fn name_clash(first: Region, second: Region, message: &str) -> Report { Report::pair( "NAME CLASH", - label(first, "one here"), - label(second, "and another one here"), - Doc::reflow(&format!("{message} One here:")), - Doc::text("How can I know which one you want? Rename one of them!"), + label(first, "first definition"), + label(second, "and another first definition"), + Doc::reflow(message), + Doc::text("Rename one of the definitions."), ) } fn qualified(name: QualifiedName<'_>) -> String { @@ -899,12 +944,12 @@ fn suggestion_details(nearby: &[String], empty: &str) -> Doc { match nearby { [] => Doc::reflow(empty), [one] => Doc::hsep([ - Doc::text("Maybe you want"), + Doc::text("Try"), Doc::text(one).dullyellow(), - Doc::text("instead?"), + Doc::text("instead."), ]), _ => Doc::stack([ - Doc::text("These names seem close though:"), + Doc::text("Similar names:"), Doc::indent( 4, Doc::vcat(nearby.iter().map(|n| Doc::text(n).dullyellow())), @@ -936,49 +981,19 @@ fn not_found( .into_iter() .take(4) .collect(); - let details = match prefix { - None => { - if nearby.is_empty() { - "Is there an `import` or `exposing` missing up top?".into() - } else { - "These names seem close though:".into() - } - } - Some(p) if possible.qualified.iter().any(|(m, _)| *m == p) => format!( - "The `{p}` module does not expose a `{name}` {thing}.{}", - if nearby.is_empty() { - "" - } else { - " These names seem close though:" - } - ), - Some(p) => { - if nearby.is_empty() { - format!("I cannot find a `{p}` module. Is there an `import` for it?") - } else { - format!("I cannot find a `{p}` import. These names seem close though:") - } + let hint = match prefix { + Some(p) if !possible.qualified.iter().any(|(m, _)| *m == p) => { + format!("Import `{p}` or check its alias.") } + Some(p) => format!("Check that `{p}` exposes `{name}`."), + None => "Define or import this name.".into(), }; - let mut docs = vec![Doc::reflow(&details)]; - if !nearby.is_empty() { - docs.push(Doc::indent( - 4, - Doc::vcat(nearby.iter().map(|n| Doc::text(n).dullyellow())), - )); - } - docs.push(Doc::link( - "Hint", - "Read", - "imports", - "to see how `import` declarations work in Nash.", - )); Report::snippet( "NAMING ERROR", region, None, - Doc::reflow(&format!("I cannot find a `{given}` {thing}:")), - Doc::stack(docs), + Doc::text(format!("Unknown {thing} `{given}`.")), + suggestion_details(&nearby, &hint), ) .with_suggestions(nearby) } @@ -993,65 +1008,30 @@ fn ambiguous_name( let mut homes = vec![first]; homes.extend_from_slice(others); homes.sort(); - match prefix { - None => Report::snippet( - "AMBIGUOUS NAME", - region, - None, - Doc::reflow(&format!("This usage of `{name}` is ambiguous:")), - Doc::stack([ - Doc::reflow(&format!( - "This name is exposed by {} of your imports, so I am not sure which one to use:", - homes.len() - )), - Doc::indent( - 4, - Doc::vcat( - homes - .iter() - .map(|h| Doc::text(to_qual_string(h.name, name)).dullyellow()), - ), - ), - Doc::reflow( - "I recommend using qualified names for imported values. I also recommend having at most one `exposing (..)` per file to make name clashes like this less common in the long run.", - ), - Doc::link( - "Note", - "Check out", - "imports", - "for more info on the import syntax.", - ), - ]), - ), - Some(prefix) => Report::snippet( - "AMBIGUOUS NAME", - region, - None, - Doc::reflow(&format!("This usage of `{prefix}.{name}` is ambiguous.")), - Doc::stack([ - Doc::reflow(&format!( - "It could refer to a {thing} from {} of these imports:", - if homes.len() == 2 { "either" } else { "any" } - )), - Doc::indent( - 4, - Doc::vcat(homes.iter().map(|h| { - Doc::text(if prefix == h.name { - format!("import {}", h.name) - } else { - format!("import {} as {prefix}", h.name) - }) - })), - ), - Doc::reflow_link( - "Read", - "imports", - "to learn how to clarify which one you want.", + let given = prefix.map_or_else(|| name.to_string(), |p| to_qual_string(p, name)); + Report::snippet( + "AMBIGUOUS NAME", + region, + None, + Doc::text(format!("Ambiguous {thing} `{given}`.")), + Doc::stack([ + Doc::indent( + 4, + Doc::vcat( + homes + .iter() + .map(|h| Doc::text(to_qual_string(h.name, name))), ), - ]), - ), - } + ), + Doc::text(if prefix.is_some() { + "Give these imports distinct aliases." + } else { + "Use a qualified name." + }), + ]), + ) } + fn args(n: usize) -> String { format!("{n} argument{}", if n == 1 { "" } else { "s" }) } @@ -1070,58 +1050,37 @@ fn arity(region: Region, name: &str, thing: &str, expected: usize, actual: usize args(expected) ), if actual < expected { - "What is missing? Are some parentheses misplaced?" - } else if actual - expected == 1 { - "Which is the extra one? Maybe some parentheses are missing?" + "Supply the missing arguments." } else { - "Which are the extra ones? Maybe some parentheses are missing?" + "Remove the extra arguments or check the grouping." }, ) } fn not_found_binop(region: Region, name: &str, available: &[&str]) -> Report { - let (before,after,suggestions) = match name { - "===" => ("Nash does not have a (===) operator like JavaScript.".into(),"Switch to (==) instead.".into(),vec!["==".into()]), - "!="|"!==" => ("Nash uses a different name for the โ€œnot equalโ€ operator:".into(),format!("Switch to (/=) instead. Our (/=) operator is supposed to look like a real โ€œnot equalโ€ sign (โ‰ ). I hope that history will remember ({name}) as a weird and temporary choice."),vec!["/=".into()]), - "**" => ("I do not recognize the (**) operator:".into(),"Switch to (^) for exponentiation. Or switch to (*) for multiplication.".into(),vec!["^".into(),"*".into()]), - // The stdlib names Int.rem and Int.mod are provisional in Plan 06. - "%" => ("Nash does not use (%) as the remainder operator:".into(),"If you want the behavior of (%) like in JavaScript, use the integer remainder function. If you want modular arithmetic like in math, use the integer modulus function. The difference is how things work when negative numbers are involved.".into(),vec![]), - _ => {let choices=nearby(name,available,2); let mut after="Is there an `import` and `exposing` entry for it?".to_string(); if !choices.is_empty() {after.push_str(&format!(" Maybe you want {} instead?",choices.iter().map(|s|format!("({s})")).collect::>().join(" or ")));} (format!("I do not recognize the ({name}) operator."),after,choices)} + let suggestions = match name { + "===" => vec!["==".into()], + "!=" | "!==" => vec!["/=".into()], + "**" => vec!["^".into(), "*".into()], + "%" => vec![], + _ => nearby(name, available, 2), }; - simple("UNKNOWN OPERATOR", region, &before, &after).with_suggestions(suggestions) + let mut report = simple( + "UNKNOWN OPERATOR", + region, + &format!("Unknown operator `({name})`."), + "", + ); + report.after = suggestion_details( + &suggestions, + if name == "%" { + "Use an integer remainder or modulus function." + } else { + "Import and expose the operator." + }, + ); + report.with_suggestions(suggestions) } fn recursive_value(region: Region, name: &str, others: &[&str], is_let: bool) -> Report { - let before = if others.is_empty() { - format!( - "The `{name}` value is defined directly in terms of itself, causing an infinite loop." - ) - } else if is_let { - "I do not allow cyclic values in `let` expressions.".into() - } else { - format!("The `{name}` definition is causing a very tricky infinite loop.") - }; - let mut docs = if others.is_empty() { - vec![ - Doc::reflow(&format!( - "Are you trying to mutate a variable? Nash does not have mutation, so when I see {name} defined in terms of {name}, I treat it as a recursive definition. Try giving the new value a new name!" - )), - Doc::reflow(&format!( - "Maybe you DO want a recursive value? To define {name} we need to know what {name} is, so letโ€™s expand it. Wait, but now we need to know what {name} is, so letโ€™s expand it... This will keep going infinitely!" - )), - ] - } else { - vec![ - Doc::reflow(&format!( - "The `{name}` value depends on itself through the following chain of definitions:" - )), - Doc::cycle(4, name, others), - ] - }; - docs.push(Doc::link( - "Hint", - "The root problem is often a typo in some variable name, but I recommend reading", - "bad-recursion", - "for more detailed advice, especially if you actually do need a recursive value.", - )); Report::snippet( if is_let { "CYCLIC VALUE" @@ -1130,8 +1089,15 @@ fn recursive_value(region: Region, name: &str, others: &[&str], is_let: bool) -> }, region, None, - Doc::reflow(&before), - Doc::stack(docs), + Doc::text(format!("Value `{name}` depends on itself.")), + Doc::stack([ + if others.is_empty() { + Doc::Empty + } else { + Doc::cycle(4, name, others) + }, + Doc::text("Break the cycle between these value definitions."), + ]), ) } fn alias_recursion_report( @@ -1141,41 +1107,23 @@ fn alias_recursion_report( typ: &nash_region::Located>, others: &[&str], ) -> Report { - let (before, after) = if others.is_empty() { - ( - "This type alias is recursive, forming an infinite type!", - Doc::stack([ - Doc::reflow( - "When I expand a recursive type alias, it just keeps getting bigger and bigger. So dealiasing results in an infinitely large type! Try this instead:", - ), + Report::snippet( + "ALIAS PROBLEM", + region, + None, + Doc::text(format!("Type alias `{name}` expands recursively.")), + Doc::stack(if others.is_empty() { + vec![ + Doc::text("Use a custom type:"), Doc::indent(4, alias_to_union_doc(name, args, typ)), - Doc::link( - "Hint", - "This is kind of a subtle distinction. I suggested the naive fix, but I recommend reading", - "recursive-alias", - "for ideas on how to do better.", - ), - ]), - ) - } else { - ( - "This type alias is part of a mutually recursive set of type aliases.", - Doc::stack([ - Doc::text("It is part of this cycle of type aliases:"), + ] + } else { + vec![ Doc::cycle(4, name, others), - Doc::reflow( - "You need to convert at least one of these type aliases into a `type`.", - ), - Doc::link( - "Note", - "Read", - "recursive-alias", - "to learn why this `type` vs `type alias` distinction matters. It is subtle but important!", - ), - ]), - ) - }; - Report::snippet("ALIAS PROBLEM", region, None, Doc::text(before), after) + Doc::text("Convert at least one alias in this cycle to a custom type."), + ] + }), + ) } fn alias_to_union_doc( name: &str, @@ -1230,12 +1178,8 @@ fn unbound_type_vars( others.is_empty().then_some(first.1), Doc::reflow(&before), Doc::stack([ - Doc::reflow("You probably need to change the declaration to something like this:"), + Doc::text("Declare the type variables:"), declaration(decl, name, args, &names), - Doc::reflow(&format!( - "Why? Well, imagine one `{name}` where `{}` is an Int and another where it is a Bool. When we explicitly list the type variables, the type checker can see that they are actually different types.", - first.0 - )), ]), ) } @@ -1295,13 +1239,10 @@ fn alias_vars( }), Doc::stack([ Doc::reflow(&format!( - "I recommend removing {} from the declaration, like this:", + "Remove {} from the declaration:", unused_names.join(" and ") )), declaration("type alias", name, &kept, &[]), - Doc::reflow( - "Why? Well, if I allowed `type alias Height 'a = Int` I would need to answer some weird questions. Is `Height Bool` the same as `Int`? Is `Height Bool` the same as `Height Int`? My solution is to not need to ask them!", - ), ]), ) } else { @@ -1335,7 +1276,7 @@ fn alias_vars( ) } )), - Doc::reflow("My guess is that a definition like this will work better:"), + Doc::reflow("Match the declaration to the variables used:"), declaration("type alias", name, &kept, &unbound_names), ]), ) @@ -2721,9 +2662,7 @@ mod branches { let report = to_report(&source, error); assert_eq!(report.title, "OVERLAPPING IMPL"); assert_eq!(report.region, *second); - assert!( - matches!(&report.snippet, Snippet::Pair { first: a, second: b } if a.region == *first && b.region == *second) - ); + assert!(report.labels[0].region == *first && report.region == *second); let rendered = crate::render_plain(&report, &source, "Bad.nash"); assert!(!rendered.contains("Rename")); assert!(rendered.contains("context constraints")); diff --git a/crates/nash-report/src/code.rs b/crates/nash-report/src/code.rs index 24866acb..cbe688b1 100644 --- a/crates/nash-report/src/code.rs +++ b/crates/nash-report/src/code.rs @@ -1,8 +1,6 @@ //! Source text access for reports, from Elm's `Reporting/Render/Code.hs`. //! Columns are byte-based, matching `nash_parse::Parser::advance`. -mod snippet; - use miette::SourceSpan; use nash_parse::{Col, Row}; use nash_region::{Position, Region}; diff --git a/crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__pair_snippet_snapshot.snap b/crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__pair_snippet_snapshot.snap deleted file mode 100644 index 280bd08e..00000000 --- a/crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__pair_snippet_snapshot.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: crates/nash-report/src/code/snippet.rs -expression: "source.snippet_doc(&snippet).render(80, false)" ---- -1| a = x - ^ - -2| b = y - ^ diff --git a/crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__unicode_pair_on_separate_lines.snap b/crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__unicode_pair_on_separate_lines.snap deleted file mode 100644 index 2b0b87c3..00000000 --- a/crates/nash-report/src/code/snapshots/nash_report__code__snippet__tests__unicode_pair_on_separate_lines.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: crates/nash-report/src/code/snippet.rs -expression: "Source::new(\"รฉ x\\n็•Œ\\ty\").pair_doc(region(1, 4, 1, 5),\nregion(2, 5, 2, 6)).render(80, false)" ---- -1| รฉ x - ^ - -2| ็•Œ y - ^ diff --git a/crates/nash-report/src/code/snippet.rs b/crates/nash-report/src/code/snippet.rs deleted file mode 100644 index 4cdc4d3f..00000000 --- a/crates/nash-report/src/code/snippet.rs +++ /dev/null @@ -1,372 +0,0 @@ -//! Elm's `Reporting/Render/Code.hs` source drawings for JSON messages. - -use super::Source; -use crate::{Doc, Snippet}; -use nash_region::Region; -use unicode_width::UnicodeWidthChar; - -impl Source<'_> { - /// Render source using Elm's line numbers, red carets, and multiline arrows. - pub fn snippet_doc(&self, snippet: &Snippet) -> Doc { - match snippet { - Snippet::Region { region, highlight } => self.region_doc(*region, *highlight), - Snippet::Pair { first, second } => self.pair_doc(first.region, second.region), - Snippet::None => Doc::Empty, - } - } - - /// Elm's `render`, `makeUnderline`, and `drawLines`. - pub fn region_doc(&self, region: Region, highlight: Option) -> Doc { - let start = region.start.line.max(1); - let end = region.end.line.max(start); - let lines: Vec<_> = (start..=end) - .map_while(|row| self.line(row).map(|line| (row, line))) - .collect(); - let Some(&(last, _)) = lines.last() else { - return Doc::Empty; - }; - let width = last.to_string().len(); - let highlight = highlight.unwrap_or(region); - let underline = highlight.start.line == highlight.end.line && highlight.end.line >= end; - let mut docs: Vec<_> = lines - .into_iter() - .map(|(row, line)| { - let spacer = - if !underline && highlight.start.line <= row && row <= highlight.end.line { - Doc::text(">").red() - } else { - Doc::text(" ") - }; - Doc::cat([ - Doc::text(format!("{row:>width$}|")), - spacer, - Doc::text(display_line(line)), - ]) - }) - .collect(); - docs.push(if underline { - let line = self.line(highlight.start.line).unwrap_or(""); - let (start, end) = visual_range(line, highlight); - Doc::cat([ - Doc::text(" ".repeat(start + width + 2)), - Doc::text("^".repeat(end - start)).red(), - ]) - } else { - Doc::Empty - }); - Doc::vcat(docs) - } - - /// Elm's `renderPair`: one line with two underlines, or two code chunks. - /// The report's primary region is independent of this source ordering. - pub fn pair_doc(&self, first: Region, second: Region) -> Doc { - let (first, second) = - if (first.start.line, first.start.column) <= (second.start.line, second.start.column) { - (first, second) - } else { - (second, first) - }; - if first.start.line == first.end.line - && first.end.line == second.start.line - && second.start.line == second.end.line - { - let row = first.start.line; - let Some(line) = self.line(row) else { - return Doc::Empty; - }; - let width = row.to_string().len(); - let (first_start, first_end) = visual_range(line, first); - let (second_start, second_end) = visual_range(line, second); - Doc::vcat([ - Doc::text(format!("{row}| {}", display_line(line))), - Doc::cat([ - Doc::text(" ".repeat(first_start + width + 2)), - Doc::text("^".repeat(first_end - first_start)).red(), - Doc::text(" ".repeat(second_start.saturating_sub(first_end))), - Doc::text("^".repeat(second_end - second_start)).red(), - ]), - ]) - } else { - Doc::stack([self.region_doc(first, None), self.region_doc(second, None)]) - } - } -} - -// Match miette's default four-cell tab stops, measured from the source text -// rather than the line-number gutter. Regions remain one-based byte columns. -const TAB_WIDTH: usize = 4; - -fn char_width(ch: char, column: usize) -> usize { - if ch == '\t' { - TAB_WIDTH - column % TAB_WIDTH - } else { - ch.width().unwrap_or(0) - } -} - -fn display_line(line: &str) -> String { - let mut text = String::with_capacity(line.len()); - let mut column = 0; - for ch in line.chars() { - let width = char_width(ch, column); - if ch == '\t' { - text.extend(std::iter::repeat_n(' ', width)); - } else { - text.push(ch); - } - column += width; - } - text -} - -fn visual_column(line: &str, byte_column: usize) -> usize { - let mut offset = byte_column.saturating_sub(1).min(line.len()); - // Match Source::offset when an invalid input points inside a UTF-8 scalar. - while !line.is_char_boundary(offset) { - offset -= 1; - } - line[..offset] - .chars() - .fold(0, |column, ch| column + char_width(ch, column)) -} - -fn visual_range(line: &str, region: Region) -> (usize, usize) { - let start = visual_column(line, region.start.column); - let end = visual_column(line, region.end.column).max(start + 1); - (start, end) -} - -#[cfg(test)] -mod tests { - use crate::{Doc, Label, Snippet, Source}; - use nash_region::{Position, Region}; - - fn region(sr: usize, sc: usize, er: usize, ec: usize) -> Region { - Region::new(Position::new(sr, sc), Position::new(er, ec)) - } - - #[test] - fn single_accent_uses_visible_columns() { - assert_eq!( - Source::new("รฉ x") - .region_doc(region(1, 4, 1, 5), None) - .render(80, false), - "1| รฉ x\n ^" - ); - assert_eq!( - Source::new("รฉ x") - .region_doc(region(1, 1, 1, 3), None) - .render(80, false), - "1| รฉ x\n ^" - ); - } - - #[test] - fn single_astral_uses_two_cells() { - assert_eq!( - Source::new("๐Ÿ˜€ x") - .region_doc(region(1, 6, 1, 7), None) - .render(80, false), - "1| ๐Ÿ˜€ x\n ^" - ); - assert_eq!( - Source::new("๐Ÿ˜€ x") - .region_doc(region(1, 1, 1, 5), None) - .render(80, false), - "1| ๐Ÿ˜€ x\n ^^" - ); - } - - #[test] - fn single_cjk_uses_two_cells() { - assert_eq!( - Source::new("็•Œ x") - .region_doc(region(1, 5, 1, 6), None) - .render(80, false), - "1| ็•Œ x\n ^" - ); - assert_eq!( - Source::new("็•Œ x") - .region_doc(region(1, 1, 1, 4), None) - .render(80, false), - "1| ็•Œ x\n ^^" - ); - } - - #[test] - fn single_tabs_expand_from_source_column() { - assert_eq!( - Source::new("\tx") - .region_doc(region(1, 2, 1, 3), None) - .render(80, false), - "1| x\n ^" - ); - assert_eq!( - Source::new("รฉ\t็•Œ x") - .region_doc(region(1, 8, 1, 9), None) - .render(80, false), - "1| รฉ ็•Œ x\n ^" - ); - } - - #[test] - fn pair_accent_uses_visible_columns() { - assert_eq!( - Source::new("รฉ x") - .pair_doc(region(1, 1, 1, 3), region(1, 4, 1, 5)) - .render(80, false), - "1| รฉ x\n ^ ^" - ); - } - - #[test] - fn pair_astral_uses_two_cells() { - assert_eq!( - Source::new("๐Ÿ˜€ x") - .pair_doc(region(1, 1, 1, 5), region(1, 6, 1, 7)) - .render(80, false), - "1| ๐Ÿ˜€ x\n ^^ ^" - ); - } - - #[test] - fn pair_cjk_uses_two_cells() { - assert_eq!( - Source::new("็•Œ x") - .pair_doc(region(1, 1, 1, 4), region(1, 5, 1, 6)) - .render(80, false), - "1| ็•Œ x\n ^^ ^" - ); - } - - #[test] - fn pair_tabs_expand_from_source_column() { - assert_eq!( - Source::new("\tx") - .pair_doc(region(1, 1, 1, 2), region(1, 2, 1, 3)) - .render(80, false), - "1| x\n ^^^^^" - ); - assert_eq!( - Source::new("รฉ\t็•Œ x") - .pair_doc(region(1, 1, 1, 3), region(1, 8, 1, 9)) - .render(80, false), - "1| รฉ ็•Œ x\n ^ ^" - ); - } - - #[test] - fn combining_marks_and_unicode_insertion() { - let source = Source::new("e\u{301} x"); - assert_eq!( - source - .region_doc(region(1, 5, 1, 6), None) - .render(80, false), - "1| e\u{301} x\n ^" - ); - assert_eq!( - source - .region_doc(region(1, 6, 1, 6), None) - .render(80, false), - "1| e\u{301} x\n ^" - ); - } - - #[test] - fn unicode_pair_on_separate_lines() { - insta::assert_snapshot!( - Source::new("รฉ x\n็•Œ\ty") - .pair_doc(region(1, 4, 1, 5), region(2, 5, 2, 6)) - .render(80, false) - ); - } - - #[test] - fn single_line_and_insertion_carets() { - let source = Source::new("value = missing"); - assert_eq!( - source - .region_doc(region(1, 9, 1, 16), None) - .render(80, false), - "1| value = missing\n ^^^^^^^" - ); - assert_eq!( - source - .region_doc(region(1, 16, 1, 16), None) - .render(80, false), - "1| value = missing\n ^" - ); - } - - #[test] - fn multiline_highlight_arrows_and_line_number_width() { - let source = Source::new("\n\n\n\n\n\n\n\nfirst\nsecond\nthird"); - assert_eq!( - source - .region_doc(region(9, 1, 11, 6), Some(region(10, 1, 10, 7))) - .render(80, false), - " 9| first\n10|>second\n11| third\n" - ); - assert_eq!( - source - .region_doc(region(9, 1, 11, 6), Some(region(11, 1, 11, 6))) - .render(80, false), - " 9| first\n10| second\n11| third\n ^^^^^" - ); - } - - #[test] - fn same_line_pair_and_separate_chunks() { - let source = Source::new("first second\nthird"); - assert_eq!( - source - .pair_doc(region(1, 1, 1, 6), region(1, 7, 1, 13)) - .render(80, false), - "1| first second\n ^^^^^ ^^^^^^" - ); - assert_eq!( - source - .pair_doc(region(1, 1, 1, 6), region(2, 1, 2, 6)) - .render(80, false), - "1| first second\n ^^^^^\n\n2| third\n ^^^^^" - ); - } - - #[test] - fn pair_accepts_reverse_source_order() { - let source = Source::new("first second"); - assert_eq!( - source - .pair_doc(region(1, 7, 1, 13), region(1, 1, 1, 6)) - .render(80, false), - "1| first second\n ^^^^^ ^^^^^^" - ); - } - - #[test] - fn empty_source_and_absent_snippet() { - assert_eq!( - Source::new("") - .region_doc(region(1, 1, 1, 1), None) - .render(80, false), - "1|\n ^" - ); - assert_eq!(Source::new("").snippet_doc(&Snippet::None), Doc::Empty); - } - - #[test] - fn pair_snippet_snapshot() { - let source = Source::new("a = x\nb = y"); - let snippet = Snippet::Pair { - first: Label { - region: region(1, 5, 1, 6), - text: "first use".into(), - }, - second: Label { - region: region(2, 5, 2, 6), - text: "second use".into(), - }, - }; - insta::assert_snapshot!(source.snippet_doc(&snippet).render(80, false)); - } -} diff --git a/crates/nash-report/src/json.rs b/crates/nash-report/src/json.rs index ee1bd9a7..663c5b5a 100644 --- a/crates/nash-report/src/json.rs +++ b/crates/nash-report/src/json.rs @@ -1,16 +1,15 @@ -//! Elm's `Reporting/Error.hs` JSON schema and complete styled messages. +//! Structured diagnostics in the Elm compile-error envelope. -use crate::{Doc, ModuleReports, Report, Snippet, Source}; +use crate::{Doc, ModuleReports, Report, Severity}; use nash_region::Region; use serde_json::{Value, json}; /// Elm's `toJson`. Report ordering is established by `ModuleReports::sort`. pub fn module_to_json(module: &ModuleReports) -> Value { - let source = Source::new(&module.source); json!({ "path": module.path, "name": module.name, - "problems": module.reports.iter().map(|report| report_to_json(&source, report)).collect::>(), + "problems": module.reports.iter().map(report_to_json).collect::>(), }) } @@ -24,17 +23,31 @@ pub fn compile_warnings(modules: &[ModuleReports]) -> Value { json!({"type": "compile-warnings", "errors": modules.iter().map(module_to_json).collect::>()}) } -fn report_to_json(source: &Source<'_>, report: &Report) -> Value { - let message = match report.snippet { - Snippet::None => Doc::stack([report.before.clone(), report.after.clone()]), - _ => Doc::vcat([ - report.before.clone(), - Doc::Empty, - source.snippet_doc(&report.snippet), - report.after.clone(), - ]), - }; - json!({"title": report.title, "region": encode_region(report.region), "message": message.encode()}) +pub fn report_to_json(report: &Report) -> Value { + let labels: Vec<_> = report + .primary_label + .iter() + .map(|text| { + json!({ + "region": encode_region(report.region), "text": text, "primary": true, + }) + }) + .chain(report.labels.iter().map(|label| { + json!({ + "region": encode_region(label.region), "text": label.text, "primary": false, + }) + })) + .collect(); + json!({ + "code": report.code, + "title": report.title, + "severity": match report.severity { Severity::Error => "error", Severity::Warning => "warning" }, + "region": encode_region(report.region), + "message": Doc::stack([report.before.clone(), report.after.clone()]).encode(), + "labels": labels, + "suggestions": report.suggestions, + "related": report.related.iter().map(module_to_json).collect::>(), + }) } /// Elm's one-based, half-open source region schema. @@ -48,7 +61,7 @@ pub fn encode_region(region: Region) -> Value { #[cfg(test)] mod tests { use super::*; - use crate::{Doc, Label, Snippet}; + use crate::{Doc, Label}; use nash_region::Position; fn region(sr: usize, sc: usize, er: usize, ec: usize) -> Region { @@ -84,7 +97,7 @@ mod tests { encode_region(region(2, 5, 2, 12)) ); assert_eq!(value.as_object().unwrap().len(), 3); - assert_eq!(value["problems"][0].as_object().unwrap().len(), 3); + assert_eq!(value["problems"][0]["suggestions"], json!(["found"])); insta::assert_snapshot!(serde_json::to_string_pretty(&value).unwrap()); } @@ -97,9 +110,9 @@ mod tests { Doc::text("First."), Doc::text("Second."), ); - report.snippet = Snippet::None; + report = report.without_source(); assert_eq!( - report_to_json(&Source::new(""), &report)["message"], + report_to_json(&report)["message"], serde_json::json!(["First.\n\nSecond."]) ); } @@ -119,9 +132,7 @@ mod tests { Doc::text("Both names occur here:"), Doc::text("Choose another name."), ); - insta::assert_snapshot!( - serde_json::to_string_pretty(&report_to_json(&Source::new("x\nx"), &report)).unwrap() - ); + insta::assert_snapshot!(serde_json::to_string_pretty(&report_to_json(&report)).unwrap()); } #[test] @@ -136,3 +147,58 @@ mod tests { ); } } + +#[cfg(test)] +mod structured_tests { + use super::*; + use crate::Label; + #[test] + fn labels_codes_and_related_reports_are_structured() { + let mut report = Report::snippet( + "OLD TITLE", + Region::zero(), + None, + Doc::text("Problem."), + Doc::text("Hint."), + ) + .with_code("nash::type::mismatch") + .with_suggestions(vec!["replacement".into()]); + report.title = "NEW TITLE".into(); + report.primary_label = Some("failing argument".into()); + for text in ["annotation", "earlier argument"] { + report.labels.push(Label { + region: Region::zero(), + text: text.into(), + }); + } + report.related.push(ModuleReports { + name: "Other".into(), + path: "Other.nash".into(), + source: "other".into(), + reports: vec![ + Report::snippet( + "RELATED", + Region::zero(), + None, + Doc::text("Origin."), + Doc::Empty, + ) + .with_code("nash::type::origin"), + ], + }); + let value = report_to_json(&report); + assert_eq!(value["code"], "nash::type::mismatch"); + assert_eq!(value["title"], "NEW TITLE"); + assert_eq!(value["labels"].as_array().unwrap().len(), 3); + assert_eq!(value["labels"][0]["primary"], true); + assert_eq!(value["labels"][1]["text"], "annotation"); + assert_eq!(value["labels"][2]["primary"], false); + assert_eq!(value["related"][0]["path"], "Other.nash"); + assert_eq!( + value["related"][0]["problems"][0]["code"], + "nash::type::origin" + ); + assert_eq!(value["suggestions"], json!(["replacement"])); + assert_eq!(value["message"], json!(["Problem.\n\nHint."])); + } +} diff --git a/crates/nash-report/src/lib.rs b/crates/nash-report/src/lib.rs index 8ec477ec..523c736e 100644 --- a/crates/nash-report/src/lib.rs +++ b/crates/nash-report/src/lib.rs @@ -1,4 +1,4 @@ -//! Error reports: Elm's `Reporting/*` prose as miette diagnostics. +//! Concise, source-aware diagnostics shared by terminal, JSON, and LSP. //! //! Each phase's error data (`nash_parse::error`, `nash_can::Error`, ...) //! is turned into an owned `Report` that outlives the module arena. A @@ -27,14 +27,19 @@ pub use code::Source; pub use doc::Doc; pub use render::{Rendered, handler, render_plain}; -/// Elm's `Reporting.Report.Report` with the snippet placement split out -/// so miette can draw the code. +/// An owned diagnostic with one primary span, arbitrary secondary labels, and +/// related reports that can refer to other source files. #[derive(Clone, Debug)] pub struct Report { + pub code: &'static str, pub title: String, pub severity: Severity, pub region: Region, - pub snippet: Snippet, + /// Text on the primary region, or None for a report without a source label. + pub primary_label: Option, + pub labels: Vec