From 1389bdadba1eb617fc13d537f6a17a1f3c9c9af3 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Fri, 26 Dec 2025 12:24:35 -0300 Subject: [PATCH] refactor: Improve diagnostics --- Makefile | 3 + crates/plotnik-lib/src/diagnostics/message.rs | 92 +++++---- crates/plotnik-lib/src/diagnostics/mod.rs | 6 +- crates/plotnik-lib/src/diagnostics/tests.rs | 4 +- crates/plotnik-lib/src/parser/core.rs | 25 ++- .../src/parser/grammar/expressions.rs | 4 +- .../plotnik-lib/src/parser/grammar/fields.rs | 22 +-- .../plotnik-lib/src/parser/grammar/items.rs | 7 +- .../src/parser/grammar/structures.rs | 38 ++-- .../parser/tests/grammar/definitions_tests.rs | 4 +- .../parser/tests/recovery/coverage_tests.rs | 24 ++- .../parser/tests/recovery/incomplete_tests.rs | 62 ++++-- .../parser/tests/recovery/unclosed_tests.rs | 42 ++-- .../parser/tests/recovery/unexpected_tests.rs | 136 +++++++++---- .../parser/tests/recovery/validation_tests.rs | 186 +++++++++++------- .../plotnik-lib/src/query/alt_kinds_tests.rs | 10 + .../src/query/dependencies_tests.rs | 74 +++++-- crates/plotnik-lib/src/query/query_tests.rs | 6 +- .../src/query/symbol_table_tests.rs | 4 +- .../plotnik-lib/src/query/type_check/infer.rs | 21 +- .../plotnik-lib/src/query/type_check_tests.rs | 12 +- docs/lang-reference.md | 38 ++-- docs/type-system.md | 48 ++--- 23 files changed, 559 insertions(+), 309 deletions(-) diff --git a/Makefile b/Makefile index c4998e3f..b7c60e37 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,10 @@ test: @cargo nextest run --no-fail-fast --hide-progress-bar --status-level none --failure-output final shot: + @# See AGENTS.md for diagnostic guidelines + @cargo nextest run --no-fail-fast --hide-progress-bar --status-level none --failure-output final || true @cargo insta accept + @cargo nextest run --no-fail-fast --hide-progress-bar --status-level none --failure-output final coverage-lines: @cargo llvm-cov --package plotnik-lib --text --show-missing-lines 2>/dev/null | grep '\.rs: [0-9]' | sed 's|.*/crates/|crates/|' diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index 29eafc9c..86cd74f9 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -129,37 +129,57 @@ impl DiagnosticKind { matches!(self, Self::UnnamedDef) } + /// Default hint for this kind, automatically included in diagnostics. + /// Call sites can add additional hints for context-specific information. + pub fn default_hint(&self) -> Option<&'static str> { + match self { + Self::ExpectedSubtype => Some("e.g., `expression/binary_expression`"), + Self::ExpectedTypeName => Some("e.g., `::MyType` or `::string`"), + Self::ExpectedFieldName => Some("e.g., `!value`"), + Self::EmptyTree => Some("use `(_)` to match any named node, or `_` for any node"), + Self::TreeSitterSequenceSyntax => Some("use `{...}` for sequences"), + Self::MixedAltBranches => { + Some("use all labels for a tagged union, or none for a merged struct") + } + Self::RecursionNoEscape => { + Some("add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]`") + } + Self::DirectRecursion => { + Some("recursive references must consume input before recursing") + } + _ => None, + } + } + /// Base message for this diagnostic kind, used when no custom message is provided. pub fn fallback_message(&self) -> &'static str { match self { - // Unclosed delimiters - clear about what's missing + // Unclosed delimiters Self::UnclosedTree => "missing closing `)`", Self::UnclosedSequence => "missing closing `}`", Self::UnclosedAlternation => "missing closing `]`", - // Expected token errors - specific about what's needed + // Expected token errors Self::ExpectedExpression => "expected an expression", - Self::ExpectedTypeName => "expected type name after `::`", - Self::ExpectedCaptureName => "expected name after `@`", + Self::ExpectedTypeName => "expected type name", + Self::ExpectedCaptureName => "expected capture name", Self::ExpectedFieldName => "expected field name", - Self::ExpectedSubtype => "expected subtype after `/`", - - // Invalid syntax - explain what's wrong - Self::EmptyTree => "empty parentheses are not allowed", - Self::BareIdentifier => "bare identifier is not a valid expression", - Self::InvalidSeparator => "separators are not needed", - Self::InvalidFieldEquals => "use `:` for field constraints, not `=`", - Self::InvalidSupertypeSyntax => "supertype syntax not allowed on references", - Self::InvalidTypeAnnotationSyntax => "use `::` for type annotations, not `:`", - Self::ErrorTakesNoArguments => "`(ERROR)` cannot have child nodes", + Self::ExpectedSubtype => "expected subtype name", + + // Invalid syntax + Self::EmptyTree => "empty `()` is not allowed", + Self::BareIdentifier => "bare identifier is not valid", + Self::InvalidSeparator => "unexpected separator", + Self::InvalidFieldEquals => "use `:` instead of `=`", + Self::InvalidSupertypeSyntax => "references cannot have supertypes", + Self::InvalidTypeAnnotationSyntax => "use `::` for type annotations", + Self::ErrorTakesNoArguments => "`(ERROR)` cannot have children", Self::RefCannotHaveChildren => "references cannot have children", - Self::ErrorMissingOutsideParens => { - "`ERROR` and `MISSING` must be wrapped in parentheses" - } - Self::UnsupportedPredicate => "predicates like `#match?` are not supported", + Self::ErrorMissingOutsideParens => "special node requires parentheses", + Self::UnsupportedPredicate => "predicates are not supported", Self::UnexpectedToken => "unexpected token", - Self::CaptureWithoutTarget => "`@` must follow an expression to capture", - Self::LowercaseBranchLabel => "branch labels must be capitalized", + Self::CaptureWithoutTarget => "capture has no target", + Self::LowercaseBranchLabel => "branch label must start with uppercase", // Naming convention violations Self::CaptureNameHasDots => "capture names cannot contain `.`", @@ -172,23 +192,25 @@ impl DiagnosticKind { Self::FieldNameHasHyphens => "field names cannot contain `-`", Self::FieldNameUppercase => "field names must be lowercase", Self::TypeNameInvalidChars => "type names cannot contain `.` or `-`", - Self::TreeSitterSequenceSyntax => "Tree-sitter sequence syntax", + Self::TreeSitterSequenceSyntax => "tree-sitter sequence syntax", // Semantic errors - Self::DuplicateDefinition => "name already defined", + Self::DuplicateDefinition => "duplicate definition", Self::UndefinedReference => "undefined reference", Self::MixedAltBranches => "cannot mix labeled and unlabeled branches", - Self::RecursionNoEscape => "infinite recursion: cycle has no escape path", + Self::RecursionNoEscape => "infinite recursion: no escape path", Self::DirectRecursion => "infinite recursion: cycle consumes no input", - Self::FieldSequenceValue => "field must match exactly one node", + Self::FieldSequenceValue => "field cannot match a sequence", // Type inference - Self::IncompatibleTypes => "incompatible types in alternation branches", + Self::IncompatibleTypes => "incompatible types", Self::MultiCaptureQuantifierNoName => { - "quantified expression with multiple captures requires `@name`" + "quantified expression with multiple captures requires a struct capture" } Self::UnusedBranchLabels => "branch labels have no effect without capture", - Self::StrictDimensionalityViolation => "quantifier requires row capture", + Self::StrictDimensionalityViolation => { + "quantifier with captures requires a struct capture" + } Self::DuplicateCaptureInScope => "duplicate capture in scope", Self::IncompatibleCaptureTypes => "incompatible capture types", Self::IncompatibleStructShapes => "incompatible struct shapes", @@ -201,7 +223,7 @@ impl DiagnosticKind { Self::InvalidChildType => "node type not valid as child", // Structural - Self::UnnamedDef => "definitions must be named", + Self::UnnamedDef => "definition must be named", } } @@ -212,9 +234,7 @@ impl DiagnosticKind { Self::RefCannotHaveChildren => { "`{}` is a reference and cannot have children".to_string() } - Self::FieldSequenceValue => { - "field `{}` must match exactly one node, not a sequence".to_string() - } + Self::FieldSequenceValue => "field `{}` cannot match a sequence".to_string(), // Semantic errors with name context Self::DuplicateDefinition => "`{}` is already defined".to_string(), @@ -249,15 +269,13 @@ impl DiagnosticKind { } // Type annotation specifics - Self::InvalidTypeAnnotationSyntax => { - "type annotations use `::`, not `:` — {}".to_string() - } + Self::InvalidTypeAnnotationSyntax => "use `::` for type annotations: {}".to_string(), - // Named def - Self::UnnamedDef => "definitions must be named — {}".to_string(), + // Named def (no custom message needed; suggestion goes in hint) + Self::UnnamedDef => self.fallback_message().to_string(), // Standard pattern: fallback + context - _ => format!("{}; {{}}", self.fallback_message()), + _ => format!("{}: {{}}", self.fallback_message()), } } diff --git a/crates/plotnik-lib/src/diagnostics/mod.rs b/crates/plotnik-lib/src/diagnostics/mod.rs index 77c9291d..bc50f797 100644 --- a/crates/plotnik-lib/src/diagnostics/mod.rs +++ b/crates/plotnik-lib/src/diagnostics/mod.rs @@ -245,7 +245,11 @@ impl<'a> DiagnosticBuilder<'a> { self } - pub fn emit(self) { + pub fn emit(mut self) { + // Prepend default hint if one exists for this kind + if let Some(default_hint) = self.message.kind.default_hint() { + self.message.hints.insert(0, default_hint.to_string()); + } self.diagnostics.messages.push(self.message); } } diff --git a/crates/plotnik-lib/src/diagnostics/tests.rs b/crates/plotnik-lib/src/diagnostics/tests.rs index 98b9bb62..b40a8097 100644 --- a/crates/plotnik-lib/src/diagnostics/tests.rs +++ b/crates/plotnik-lib/src/diagnostics/tests.rs @@ -89,7 +89,7 @@ fn builder_with_fix() { let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" - error: use `:` for field constraints, not `=`; fixable + error: use `:` instead of `=`: fixable | 1 | hello world | ^^^^^ @@ -207,7 +207,7 @@ fn printer_zero_width_span() { let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" - error: expected an expression; zero width error + error: expected an expression: zero width error | 1 | hello | ^ diff --git a/crates/plotnik-lib/src/parser/core.rs b/crates/plotnik-lib/src/parser/core.rs index 363bf458..8897ade3 100644 --- a/crates/plotnik-lib/src/parser/core.rs +++ b/crates/plotnik-lib/src/parser/core.rs @@ -260,7 +260,7 @@ impl<'src, 'diag> Parser<'src, 'diag> { true } - fn bump_as_error(&mut self) { + pub(super) fn bump_as_error(&mut self) { if !self.eof() { self.start_node(SyntaxKind::Error); self.bump(); @@ -298,11 +298,32 @@ impl<'src, 'diag> Parser<'src, 'diag> { .emit(); } + pub(super) fn error_with_hint(&mut self, kind: DiagnosticKind, hint: impl Into) { + let Some((range, suppression)) = self.get_error_ranges() else { + return; + }; + self.diagnostics + .report(self.source_id, kind, range) + .hint(hint) + .suppression_range(suppression) + .emit(); + } + pub(super) fn error_and_bump(&mut self, kind: DiagnosticKind) { self.error(kind); self.bump_as_error(); } + pub(super) fn error_and_bump_with_hint( + &mut self, + kind: DiagnosticKind, + hint: impl Into, + ) { + self.error_with_hint(kind, hint); + self.bump_as_error(); + } + + #[allow(dead_code)] pub(super) fn error_and_bump_msg(&mut self, kind: DiagnosticKind, message: impl Into) { self.error_msg(kind, message); self.bump_as_error(); @@ -359,7 +380,6 @@ impl<'src, 'diag> Parser<'src, 'diag> { pub(super) fn error_unclosed_delimiter( &mut self, kind: DiagnosticKind, - message: impl Into, related_msg: impl Into, open_range: TextRange, ) { @@ -371,7 +391,6 @@ impl<'src, 'diag> Parser<'src, 'diag> { let full_range = TextRange::new(open_range.start(), current.end()); self.diagnostics .report(self.source_id, kind, full_range) - .message(message) .related_to(self.source_id, open_range, related_msg) .emit(); } diff --git a/crates/plotnik-lib/src/parser/grammar/expressions.rs b/crates/plotnik-lib/src/parser/grammar/expressions.rs index 0448958f..850eb18c 100644 --- a/crates/plotnik-lib/src/parser/grammar/expressions.rs +++ b/crates/plotnik-lib/src/parser/grammar/expressions.rs @@ -22,7 +22,7 @@ impl Parser<'_, '_> { return false; } - self.error_and_bump_msg( + self.error_and_bump_with_hint( DiagnosticKind::UnexpectedToken, "try `(node)`, `[a b]`, `{a b}`, `\"literal\"`, or `_`", ); @@ -65,7 +65,7 @@ impl Parser<'_, '_> { self.error_and_bump(DiagnosticKind::ErrorMissingOutsideParens); } _ => { - self.error_and_bump_msg(DiagnosticKind::UnexpectedToken, "not a valid expression"); + self.error_and_bump(DiagnosticKind::UnexpectedToken); } } diff --git a/crates/plotnik-lib/src/parser/grammar/fields.rs b/crates/plotnik-lib/src/parser/grammar/fields.rs index 25fc56c4..680050a6 100644 --- a/crates/plotnik-lib/src/parser/grammar/fields.rs +++ b/crates/plotnik-lib/src/parser/grammar/fields.rs @@ -42,10 +42,7 @@ impl Parser<'_, '_> { self.bump(); self.validate_type_name(text, span); } else { - self.error_msg( - DiagnosticKind::ExpectedTypeName, - "e.g., `::MyType` or `::string`", - ); + self.error(DiagnosticKind::ExpectedTypeName); } self.finish_node(); @@ -84,7 +81,7 @@ impl Parser<'_, '_> { self.expect(SyntaxKind::Negation, "'!' for negated field"); if !self.currently_is(SyntaxKind::Id) { - self.error_msg(DiagnosticKind::ExpectedFieldName, "e.g., `!value`"); + self.error(DiagnosticKind::ExpectedFieldName); self.finish_node(); return; } @@ -110,10 +107,13 @@ impl Parser<'_, '_> { } // Bare identifiers are not valid expressions; trees require parentheses - self.error_and_bump_msg( - DiagnosticKind::BareIdentifier, - "wrap in parentheses: `(identifier)`", - ); + let span = self.current_span(); + let text = token_text(self.source, &self.tokens[self.pos]); + self.diagnostics + .report(self.source_id, DiagnosticKind::BareIdentifier, span) + .fix("wrap in parentheses", format!("({})", text)) + .emit(); + self.bump_as_error(); } /// Field constraint: `field_name: expr` @@ -134,7 +134,7 @@ impl Parser<'_, '_> { if self.currently_is_one_of(EXPR_FIRST_TOKENS) { self.parse_expr_no_suffix(); } else { - self.error_msg(DiagnosticKind::ExpectedExpression, "after `field:`"); + self.error(DiagnosticKind::ExpectedExpression); } self.finish_node(); @@ -158,7 +158,7 @@ impl Parser<'_, '_> { if self.currently_is_one_of(EXPR_FIRST_TOKENS) { self.parse_expr(); } else { - self.error_msg(DiagnosticKind::ExpectedExpression, "after `field =`"); + self.error(DiagnosticKind::ExpectedExpression); } self.finish_node(); diff --git a/crates/plotnik-lib/src/parser/grammar/items.rs b/crates/plotnik-lib/src/parser/grammar/items.rs index 9a096233..6ba6390c 100644 --- a/crates/plotnik-lib/src/parser/grammar/items.rs +++ b/crates/plotnik-lib/src/parser/grammar/items.rs @@ -30,7 +30,7 @@ impl Parser<'_, '_> { let def_text = &self.source[usize::from(start)..usize::from(end)]; self.diagnostics .report(self.source_id, DiagnosticKind::UnnamedDef, span) - .message(format!("give it a name like `Name = {}`", def_text.trim())) + .hint(format!("give it a name like `Name = {}`", def_text.trim())) .emit(); } } @@ -83,10 +83,7 @@ impl Parser<'_, '_> { if self.currently_is_one_of(EXPR_FIRST_TOKENS) { self.parse_expr(); } else { - self.error_msg( - DiagnosticKind::ExpectedExpression, - "after `=` in definition", - ); + self.error(DiagnosticKind::ExpectedExpression); } self.finish_node(); diff --git a/crates/plotnik-lib/src/parser/grammar/structures.rs b/crates/plotnik-lib/src/parser/grammar/structures.rs index 4f892006..c0a243af 100644 --- a/crates/plotnik-lib/src/parser/grammar/structures.rs +++ b/crates/plotnik-lib/src/parser/grammar/structures.rs @@ -24,7 +24,9 @@ impl Parser<'_, '_> { match self.current() { SyntaxKind::ParenClose => { self.start_node_at(checkpoint, SyntaxKind::Tree); - self.error(DiagnosticKind::EmptyTree); + self.diagnostics + .report(self.source_id, DiagnosticKind::EmptyTree, open_paren_span) + .emit(); // Fall through to close } SyntaxKind::Underscore => { @@ -55,7 +57,6 @@ impl Parser<'_, '_> { DiagnosticKind::TreeSitterSequenceSyntax, open_paren_span, ) - .hint("use `{...}` for sequences") .emit(); } } @@ -94,10 +95,7 @@ impl Parser<'_, '_> { self.bump_string_tokens(); } _ => { - self.error_msg( - DiagnosticKind::ExpectedSubtype, - "e.g., `expression/binary_expression`", - ); + self.error(DiagnosticKind::ExpectedSubtype); } } } @@ -182,27 +180,21 @@ impl Parser<'_, '_> { fn parse_children(&mut self, until: SyntaxKind, recovery: TokenSet) { loop { if self.eof() { - let (construct, delim, kind) = match until { - SyntaxKind::ParenClose => ("tree", "`)`", DiagnosticKind::UnclosedTree), - SyntaxKind::BraceClose => ("sequence", "`}`", DiagnosticKind::UnclosedSequence), + let (construct, kind) = match until { + SyntaxKind::ParenClose => ("node", DiagnosticKind::UnclosedTree), + SyntaxKind::BraceClose => ("sequence", DiagnosticKind::UnclosedSequence), _ => panic!( "parse_children: unexpected delimiter {:?} (only ParenClose/BraceClose supported)", until ), }; - let msg = format!("expected {delim}"); let open = self.delimiter_stack.last().unwrap_or_else(|| { panic!( "parse_children: unclosed {construct} at EOF but delimiter_stack is empty \ (caller must push delimiter before calling)" ) }); - self.error_unclosed_delimiter( - kind, - msg, - format!("{construct} started here"), - open.span, - ); + self.error_unclosed_delimiter(kind, format!("{construct} started here"), open.span); break; } if self.has_fatal_error() { @@ -226,9 +218,9 @@ impl Parser<'_, '_> { if self.currently_is_one_of(recovery) { break; } - self.error_and_bump_msg( + self.error_and_bump_with_hint( DiagnosticKind::UnexpectedToken, - "not valid inside a node — try `(child)` or close with `)`", + "try `(child)` or close with `)`", ); } } @@ -250,7 +242,6 @@ impl Parser<'_, '_> { fn parse_alt_children(&mut self) { loop { if self.eof() { - let msg = "expected `]`"; let open = self.delimiter_stack.last().unwrap_or_else(|| { panic!( "parse_alt_children: unclosed alternation at EOF but delimiter_stack is empty \ @@ -259,7 +250,6 @@ impl Parser<'_, '_> { }); self.error_unclosed_delimiter( DiagnosticKind::UnclosedAlternation, - msg, "alternation started here", open.span, ); @@ -296,9 +286,9 @@ impl Parser<'_, '_> { if self.currently_is_one_of(ALT_RECOVERY_TOKENS) { break; } - self.error_and_bump_msg( + self.error_and_bump_with_hint( DiagnosticKind::UnexpectedToken, - "not valid inside alternation — try `(node)` or close with `]`", + "try `(node)` or close with `]`", ); } } @@ -317,7 +307,7 @@ impl Parser<'_, '_> { if self.currently_is_one_of(EXPR_FIRST_TOKENS) { self.parse_expr(); } else { - self.error_msg(DiagnosticKind::ExpectedExpression, "after `Label:`"); + self.error(DiagnosticKind::ExpectedExpression); } self.finish_node(); @@ -345,7 +335,7 @@ impl Parser<'_, '_> { if self.currently_is_one_of(EXPR_FIRST_TOKENS) { self.parse_expr(); } else { - self.error_msg(DiagnosticKind::ExpectedExpression, "after `label:`"); + self.error(DiagnosticKind::ExpectedExpression); } self.finish_node(); diff --git a/crates/plotnik-lib/src/parser/tests/grammar/definitions_tests.rs b/crates/plotnik-lib/src/parser/tests/grammar/definitions_tests.rs index 642444c0..4424fbf8 100644 --- a/crates/plotnik-lib/src/parser/tests/grammar/definitions_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/grammar/definitions_tests.rs @@ -171,10 +171,12 @@ fn named_def_then_expression() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definitions must be named — give it a name like `Name = (program (Expr) @value)` + error: definition must be named | 2 | (program (Expr) @value) | ^^^^^^^^^^^^^^^^^^^^^^^ + | + help: give it a name like `Name = (program (Expr) @value)` "); } diff --git a/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs b/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs index 40b81548..60a29153 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs @@ -144,15 +144,23 @@ fn named_def_missing_equals_with_garbage() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | Q = Expr ^^^ (identifier) | ^^^^ + | + help: wrap in parentheses + | + 1 - Q = Expr ^^^ (identifier) + 1 + Q = (Expr) ^^^ (identifier) + | - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | Q = Expr ^^^ (identifier) | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -166,15 +174,23 @@ fn named_def_missing_equals_recovers_to_next_def() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | Broken ^^^ | ^^^^^^ + | + help: wrap in parentheses + | + 1 - Broken ^^^ + 1 + (Broken) ^^^ + | - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | Broken ^^^ | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } diff --git a/crates/plotnik-lib/src/parser/tests/recovery/incomplete_tests.rs b/crates/plotnik-lib/src/parser/tests/recovery/incomplete_tests.rs index 3f61c647..d8ad83d3 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/incomplete_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/incomplete_tests.rs @@ -10,7 +10,7 @@ fn missing_capture_name() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected name after `@` + error: expected capture name | 1 | (identifier) @ | ^ @@ -26,7 +26,7 @@ fn missing_field_value() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected an expression; after `field:` + error: expected an expression | 1 | (call name:) | ^ @@ -40,7 +40,7 @@ fn named_def_eof_after_equals() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected an expression; after `=` in definition + error: expected an expression | 1 | Expr = | ^ @@ -56,10 +56,12 @@ fn missing_type_name() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected type name after `::`; e.g., `::MyType` or `::string` + error: expected type name | 1 | (identifier) @name :: | ^ + | + help: e.g., `::MyType` or `::string` "); } @@ -72,10 +74,12 @@ fn missing_negated_field_name() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected field name; e.g., `!value` + error: expected field name | 1 | (call !) | ^ + | + help: e.g., `!value` "); } @@ -88,10 +92,12 @@ fn missing_subtype() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected subtype after `/`; e.g., `expression/binary_expression` + error: expected subtype name | 1 | (expression/) | ^ + | + help: e.g., `expression/binary_expression` "); } @@ -104,7 +110,7 @@ fn tagged_branch_missing_expression() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected an expression; after `Label:` + error: expected an expression | 1 | [Label:] | ^ @@ -118,10 +124,12 @@ fn type_annotation_missing_name_at_eof() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected type name after `::`; e.g., `::MyType` or `::string` + error: expected type name | 1 | (a) @x :: | ^ + | + help: e.g., `::MyType` or `::string` "); } @@ -132,10 +140,12 @@ fn type_annotation_missing_name_with_bracket() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected type name after `::`; e.g., `::MyType` or `::string` + error: expected type name | 1 | [(a) @x :: ] | ^ + | + help: e.g., `::MyType` or `::string` "); } @@ -148,10 +158,12 @@ fn type_annotation_invalid_token_after() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected type name after `::`; e.g., `::MyType` or `::string` + error: expected type name | 1 | (identifier) @name :: ( | ^ + | + help: e.g., `::MyType` or `::string` "); } @@ -164,7 +176,7 @@ fn field_value_is_garbage() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected an expression; after `field:` + error: expected an expression | 1 | (call name: %%%) | ^^^ @@ -180,7 +192,7 @@ fn capture_with_invalid_char() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected name after `@` + error: expected capture name | 1 | (identifier) @123 | ^^^ @@ -194,7 +206,7 @@ fn bare_capture_at_eof_triggers_sync() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `@` must follow an expression to capture + error: capture has no target | 1 | @ | ^ @@ -210,7 +222,7 @@ fn bare_capture_at_root() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `@` must follow an expression to capture + error: capture has no target | 1 | @name | ^ @@ -226,10 +238,12 @@ fn capture_at_start_of_alternation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: unexpected token; not valid inside alternation — try `(node)` or close with `]` + error: unexpected token | 1 | [@x (a)] | ^ + | + help: try `(node)` or close with `]` "); } @@ -242,15 +256,21 @@ fn mixed_valid_invalid_captures() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `@` must follow an expression to capture + error: capture has no target | 1 | (a) @ok @ @name | ^ - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | (a) @ok @ @name | ^^^^ + | + help: wrap in parentheses + | + 1 - (a) @ok @ @name + 1 + (a) @ok @ @(name) + | "); } @@ -263,7 +283,7 @@ fn field_equals_typo_missing_value() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: use `:` for field constraints, not `=`; this isn't a definition + error: use `:` instead of `=`: this isn't a definition | 1 | (call name = ) | ^ @@ -274,7 +294,7 @@ fn field_equals_typo_missing_value() { 1 + (call name : ) | - error: expected an expression; after `field =` + error: expected an expression | 1 | (call name = ) | ^ @@ -288,7 +308,7 @@ fn lowercase_branch_label_missing_expression() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be capitalized; branch labels map to enum variants + error: branch label must start with uppercase: branch labels map to enum variants | 1 | [label:] | ^^^^^ @@ -299,7 +319,7 @@ fn lowercase_branch_label_missing_expression() { 1 + [Label:] | - error: expected an expression; after `label:` + error: expected an expression | 1 | [label:] | ^ diff --git a/crates/plotnik-lib/src/parser/tests/recovery/unclosed_tests.rs b/crates/plotnik-lib/src/parser/tests/recovery/unclosed_tests.rs index 0b27aacd..8537b281 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/unclosed_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/unclosed_tests.rs @@ -10,12 +10,12 @@ fn missing_paren() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | (identifier | -^^^^^^^^^^ | | - | tree started here + | node started here "); } @@ -28,7 +28,7 @@ fn missing_bracket() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `]`; expected `]` + error: missing closing `]` | 1 | [(identifier) (string) | -^^^^^^^^^^^^^^^^^^^^^ @@ -46,7 +46,7 @@ fn missing_brace() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `}`; expected `}` + error: missing closing `}` | 1 | {(a) (b) | -^^^^^^^ @@ -64,12 +64,12 @@ fn nested_unclosed() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | (a (b (c) | -^^^^^ | | - | tree started here + | node started here "); } @@ -82,12 +82,12 @@ fn deeply_nested_unclosed() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | (a (b (c (d | -^ | | - | tree started here + | node started here "); } @@ -100,12 +100,12 @@ fn unclosed_alternation_nested() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | [(a) (b | -^ | | - | tree started here + | node started here "); } @@ -118,10 +118,12 @@ fn empty_parens() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: empty parentheses are not allowed + error: empty `()` is not allowed | 1 | () - | ^ + | ^ + | + help: use `(_)` to match any named node, or `_` for any node "); } @@ -135,10 +137,10 @@ fn unclosed_tree_shows_open_location() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | (call - | ^ tree started here + | ^ node started here | _| | | 2 | | (identifier) @@ -157,7 +159,7 @@ fn unclosed_alternation_shows_open_location() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `]`; expected `]` + error: missing closing `]` | 1 | [ | ^ alternation started here @@ -180,7 +182,7 @@ fn unclosed_sequence_shows_open_location() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `}`; expected `}` + error: missing closing `}` | 1 | { | ^ sequence started here @@ -199,12 +201,12 @@ fn unclosed_double_quote_string() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | (call "foo) | -^^^^^^^^^^ | | - | tree started here + | node started here "#); } @@ -215,11 +217,11 @@ fn unclosed_single_quote_string() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: missing closing `)`; expected `)` + error: missing closing `)` | 1 | (call 'foo) | -^^^^^^^^^^ | | - | tree started here + | node started here "); } diff --git a/crates/plotnik-lib/src/parser/tests/recovery/unexpected_tests.rs b/crates/plotnik-lib/src/parser/tests/recovery/unexpected_tests.rs index ce8e6779..8a325961 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/unexpected_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/unexpected_tests.rs @@ -10,10 +10,12 @@ fn unexpected_token() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | (identifier) ^^^ (string) | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -26,10 +28,12 @@ fn multiple_consecutive_garbage() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | ^^^ $$$ %%% (ok) | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -42,10 +46,12 @@ fn garbage_at_start() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | ^^^ (a) | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -58,10 +64,12 @@ fn only_garbage() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | ^^^ $$$ | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -74,10 +82,12 @@ fn garbage_inside_alternation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: unexpected token; not valid inside alternation — try `(node)` or close with `]` + error: unexpected token | 1 | [(a) ^^^ (b)] | ^^^ + | + help: try `(node)` or close with `]` "); } @@ -90,7 +100,7 @@ fn garbage_inside_node() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: expected name after `@` + error: expected capture name | 1 | (a (b) @@@ (c)) (d) | ^ @@ -106,15 +116,19 @@ fn xml_tag_garbage() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 |
(identifier)
| ^^^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 |
(identifier)
| ^^^^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -127,10 +141,12 @@ fn xml_self_closing() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 |
(a) | ^^^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -143,20 +159,28 @@ fn predicate_unsupported() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: predicates like `#match?` are not supported + error: predicates are not supported | 1 | (a (#eq? @x "foo") b) | ^^^^ - error: unexpected token; not valid inside a node — try `(child)` or close with `)` + error: unexpected token | 1 | (a (#eq? @x "foo") b) | ^ + | + help: try `(child)` or close with `)` - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | (a (#eq? @x "foo") b) | ^ + | + help: wrap in parentheses + | + 1 - (a (#eq? @x "foo") b) + 1 + (a (#eq? @x "foo") (b)) + | "#); } @@ -169,15 +193,21 @@ fn predicate_match() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: predicates like `#match?` are not supported + error: predicates are not supported | 1 | (identifier) #match? @name "test" | ^^^^^^^ - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | (identifier) #match? @name "test" | ^^^^ + | + help: wrap in parentheses + | + 1 - (identifier) #match? @name "test" + 1 + (identifier) #match? @(name) "test" + | "#); } @@ -188,15 +218,17 @@ fn predicate_in_tree() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: predicates like `#match?` are not supported + error: predicates are not supported | 1 | (function #eq? @name "test") | ^^^^ - error: unexpected token; not valid inside a node — try `(child)` or close with `)` + error: unexpected token | 1 | (function #eq? @name "test") | ^ + | + help: try `(child)` or close with `)` "#); } @@ -209,10 +241,12 @@ fn predicate_in_alternation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: unexpected token; not valid inside alternation — try `(node)` or close with `]` + error: unexpected token | 1 | [(a) #eq? (b)] | ^^^^ + | + help: try `(node)` or close with `]` "); } @@ -225,7 +259,7 @@ fn predicate_in_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: predicates like `#match?` are not supported + error: predicates are not supported | 1 | {(a) #set! (b)} | ^^^^^ @@ -243,15 +277,23 @@ fn multiline_garbage_recovery() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: unexpected token; not valid inside a node — try `(child)` or close with `)` + error: unexpected token | 2 | ^^^ | ^^^ + | + help: try `(child)` or close with `)` - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 3 | b) | ^ + | + help: wrap in parentheses + | + 3 - b) + 3 + (b)) + | "); } @@ -264,10 +306,12 @@ fn top_level_garbage_recovery() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | Expr = (a) ^^^ Expr2 = (b) | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -284,15 +328,19 @@ fn multiple_definitions_with_garbage_between() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 2 | ^^^ | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 4 | $$$ | ^^^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -305,15 +353,19 @@ fn alternation_recovery_to_capture() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: unexpected token; not valid inside alternation — try `(node)` or close with `]` + error: unexpected token | 1 | [^^^ @name] | ^^^ + | + help: try `(node)` or close with `]` - error: unexpected token; not valid inside alternation — try `(node)` or close with `]` + error: unexpected token | 1 | [^^^ @name] | ^ + | + help: try `(node)` or close with `]` "); } @@ -326,10 +378,12 @@ fn comma_between_defs() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | A = (a), B = (b) | ^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -340,10 +394,12 @@ fn bare_colon_in_tree() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: unexpected token; not valid inside a node — try `(child)` or close with `)` + error: unexpected token | 1 | (a : (b)) | ^ + | + help: try `(child)` or close with `)` "); } @@ -354,15 +410,17 @@ fn paren_close_inside_alternation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; expected closing ']' for alternation + error: unexpected token: expected closing ']' for alternation | 1 | [(a) ) (b)] | ^ - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | [(a) ) (b)] | ^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -373,15 +431,17 @@ fn bracket_close_inside_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; expected closing '}' for sequence + error: unexpected token: expected closing '}' for sequence | 1 | {(a) ] (b)} | ^ - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | {(a) ] (b)} | ^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -392,15 +452,17 @@ fn paren_close_inside_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; expected closing '}' for sequence + error: unexpected token: expected closing '}' for sequence | 1 | {(a) ) (b)} | ^ - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | {(a) ) (b)} | ^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -411,10 +473,12 @@ fn single_colon_type_annotation_followed_by_non_id() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | (a) @x : (b) | ^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } @@ -425,9 +489,11 @@ fn single_colon_type_annotation_at_eof() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r#" - error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` + error: unexpected token | 1 | (a) @x : | ^ + | + help: try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` "#); } diff --git a/crates/plotnik-lib/src/parser/tests/recovery/validation_tests.rs b/crates/plotnik-lib/src/parser/tests/recovery/validation_tests.rs index e3cd8ca8..135eb469 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/validation_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/validation_tests.rs @@ -59,7 +59,7 @@ fn reference_with_supertype_syntax_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: supertype syntax not allowed on references + error: references cannot have supertypes | 1 | (RefName/subtype) | ^ @@ -81,6 +81,8 @@ fn mixed_tagged_and_untagged() { | ------ ^^^ | | | tagged branch here + | + help: use all labels for a tagged union, or none for a merged struct "); } @@ -93,7 +95,7 @@ fn error_with_unexpected_content() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `(ERROR)` cannot have child nodes + error: `(ERROR)` cannot have children | 1 | (ERROR (something)) | ^ @@ -109,7 +111,7 @@ fn bare_error_keyword() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `ERROR` and `MISSING` must be wrapped in parentheses + error: special node requires parentheses | 1 | ERROR | ^^^^^ @@ -125,7 +127,7 @@ fn bare_missing_keyword() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `ERROR` and `MISSING` must be wrapped in parentheses + error: special node requires parentheses | 1 | MISSING | ^^^^^^^ @@ -178,10 +180,16 @@ fn bare_upper_ident_not_followed_by_equals_is_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | Expr | ^^^^ + | + help: wrap in parentheses + | + 1 - Expr + 1 + (Expr) + | "); } @@ -194,10 +202,16 @@ fn named_def_missing_equals() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | Expr (identifier) | ^^^^ + | + help: wrap in parentheses + | + 1 - Expr (identifier) + 1 + (Expr) (identifier) + | "); } @@ -212,15 +226,19 @@ fn unnamed_def_not_allowed_in_middle() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definitions must be named — give it a name like `Name = (first)` + error: definition must be named | 1 | (first) | ^^^^^^^ + | + help: give it a name like `Name = (first)` - error: definitions must be named — give it a name like `Name = (last)` + error: definition must be named | 3 | (last) | ^^^^^^ + | + help: give it a name like `Name = (last)` "); } @@ -235,20 +253,26 @@ fn multiple_unnamed_defs_errors_for_all_but_last() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definitions must be named — give it a name like `Name = (first)` + error: definition must be named | 1 | (first) | ^^^^^^^ + | + help: give it a name like `Name = (first)` - error: definitions must be named — give it a name like `Name = (second)` + error: definition must be named | 2 | (second) | ^^^^^^^^ + | + help: give it a name like `Name = (second)` - error: definitions must be named — give it a name like `Name = (third)` + error: definition must be named | 3 | (third) | ^^^^^^^ + | + help: give it a name like `Name = (third)` "); } @@ -261,20 +285,26 @@ fn capture_space_after_dot_is_anchor() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definitions must be named — give it a name like `Name = (identifier) @foo` + error: definition must be named | 1 | (identifier) @foo . (other) | ^^^^^^^^^^^^^^^^^ + | + help: give it a name like `Name = (identifier) @foo` - error: definitions must be named — give it a name like `Name = .` + error: definition must be named | 1 | (identifier) @foo . (other) | ^ + | + help: give it a name like `Name = .` - error: definitions must be named — give it a name like `Name = (other)` + error: definition must be named | 1 | (identifier) @foo . (other) | ^^^^^^^ + | + help: give it a name like `Name = (other)` "); } @@ -285,7 +315,7 @@ fn def_name_lowercase_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definition names must start uppercase; definitions map to types + error: definition names must start uppercase: definitions map to types | 1 | lowercase = (x) | ^^^^^^^^^ @@ -307,7 +337,7 @@ fn def_name_snake_case_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definition names must start uppercase; definitions map to types + error: definition names must start uppercase: definitions map to types | 1 | my_expr = (identifier) | ^^^^^^^ @@ -329,7 +359,7 @@ fn def_name_kebab_case_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definition names must start uppercase; definitions map to types + error: definition names must start uppercase: definitions map to types | 1 | my-expr = (identifier) | ^^^^^^^ @@ -351,7 +381,7 @@ fn def_name_dotted_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definition names must start uppercase; definitions map to types + error: definition names must start uppercase: definitions map to types | 1 | my.expr = (identifier) | ^^^^^^^ @@ -371,7 +401,7 @@ fn def_name_with_underscores_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definition names must be PascalCase; definitions map to types + error: definition names must be PascalCase: definitions map to types | 1 | Some_Thing = (x) | ^^^^^^^^^^ @@ -391,7 +421,7 @@ fn def_name_with_hyphens_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: definition names must be PascalCase; definitions map to types + error: definition names must be PascalCase: definitions map to types | 1 | Some-Thing = (x) | ^^^^^^^^^^ @@ -413,7 +443,7 @@ fn capture_name_pascal_case_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names must be lowercase; captures become struct fields + error: capture names must be lowercase: captures become struct fields | 1 | (a) @Name | ^^^^ @@ -435,7 +465,7 @@ fn capture_name_pascal_case_with_hyphens_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `-`; captures become struct fields + error: capture names cannot contain `-`: captures become struct fields | 1 | (a) @My-Name | ^^^^^^^ @@ -457,7 +487,7 @@ fn capture_name_with_hyphens_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `-`; captures become struct fields + error: capture names cannot contain `-`: captures become struct fields | 1 | (a) @my-name | ^^^^^^^ @@ -479,7 +509,7 @@ fn capture_dotted_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `.`; captures become struct fields + error: capture names cannot contain `.`: captures become struct fields | 1 | (identifier) @foo.bar | ^^^^^^^ @@ -501,7 +531,7 @@ fn capture_dotted_multiple_parts() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `.`; captures become struct fields + error: capture names cannot contain `.`: captures become struct fields | 1 | (identifier) @foo.bar.baz | ^^^^^^^^^^^ @@ -523,7 +553,7 @@ fn capture_dotted_followed_by_field() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `.`; captures become struct fields + error: capture names cannot contain `.`: captures become struct fields | 1 | (node) @foo.bar name: (other) | ^^^^^^^ @@ -545,7 +575,7 @@ fn capture_space_after_dot_breaks_chain() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `.`; captures become struct fields + error: capture names cannot contain `.`: captures become struct fields | 1 | (identifier) @foo. bar | ^^^^ @@ -556,10 +586,16 @@ fn capture_space_after_dot_breaks_chain() { 1 + (identifier) @foo_ bar | - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | (identifier) @foo. bar | ^^^ + | + help: wrap in parentheses + | + 1 - (identifier) @foo. bar + 1 + (identifier) @foo. (bar) + | "); } @@ -572,7 +608,7 @@ fn capture_hyphenated_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `-`; captures become struct fields + error: capture names cannot contain `-`: captures become struct fields | 1 | (identifier) @foo-bar | ^^^^^^^ @@ -594,7 +630,7 @@ fn capture_hyphenated_multiple() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `-`; captures become struct fields + error: capture names cannot contain `-`: captures become struct fields | 1 | (identifier) @foo-bar-baz | ^^^^^^^^^^^ @@ -616,7 +652,7 @@ fn capture_mixed_dots_and_hyphens() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: capture names cannot contain `.`; captures become struct fields + error: capture names cannot contain `.`: captures become struct fields | 1 | (identifier) @foo.bar-baz | ^^^^^^^^^^^ @@ -638,7 +674,7 @@ fn field_name_pascal_case_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: field names must be lowercase; field names become struct fields + error: field names must be lowercase: field names become struct fields | 1 | (call Name: (a)) | ^^^^ @@ -658,7 +694,7 @@ fn field_name_with_dots_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: field names cannot contain `.`; field names become struct fields + error: field names cannot contain `.`: field names become struct fields | 1 | (call foo.bar: (x)) | ^^^^^^^ @@ -678,7 +714,7 @@ fn field_name_with_hyphens_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: field names cannot contain `-`; field names become struct fields + error: field names cannot contain `-`: field names become struct fields | 1 | (call foo-bar: (x)) | ^^^^^^^ @@ -700,7 +736,7 @@ fn negated_field_with_upper_ident_parses() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: field names must be lowercase; field names become struct fields + error: field names must be lowercase: field names become struct fields | 1 | (call !Arguments) | ^^^^^^^^^ @@ -722,7 +758,7 @@ fn branch_label_snake_case_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be PascalCase; branch labels map to enum variants + error: branch labels must be PascalCase: branch labels map to enum variants | 1 | [My_branch: (a) Other: (b)] | ^^^^^^^^^ @@ -744,7 +780,7 @@ fn branch_label_kebab_case_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be PascalCase; branch labels map to enum variants + error: branch labels must be PascalCase: branch labels map to enum variants | 1 | [My-branch: (a) Other: (b)] | ^^^^^^^^^ @@ -766,7 +802,7 @@ fn branch_label_dotted_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be PascalCase; branch labels map to enum variants + error: branch labels must be PascalCase: branch labels map to enum variants | 1 | [My.branch: (a) Other: (b)] | ^^^^^^^^^ @@ -786,7 +822,7 @@ fn branch_label_with_underscores_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be PascalCase; branch labels map to enum variants + error: branch labels must be PascalCase: branch labels map to enum variants | 1 | [Some_Label: (x)] | ^^^^^^^^^^ @@ -806,7 +842,7 @@ fn branch_label_with_hyphens_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be PascalCase; branch labels map to enum variants + error: branch labels must be PascalCase: branch labels map to enum variants | 1 | [Some-Label: (x)] | ^^^^^^^^^^ @@ -831,7 +867,7 @@ fn lowercase_branch_label() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be capitalized; branch labels map to enum variants + error: branch label must start with uppercase: branch labels map to enum variants | 2 | left: (a) | ^^^^ @@ -842,7 +878,7 @@ fn lowercase_branch_label() { 2 + Left: (a) | - error: branch labels must be capitalized; branch labels map to enum variants + error: branch label must start with uppercase: branch labels map to enum variants | 3 | right: (b) | ^^^^^ @@ -864,7 +900,7 @@ fn lowercase_branch_label_suggests_capitalized() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be capitalized; branch labels map to enum variants + error: branch label must start with uppercase: branch labels map to enum variants | 1 | [first: (a) Second: (b)] | ^^^^^ @@ -884,7 +920,7 @@ fn mixed_case_branch_labels() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: branch labels must be capitalized; branch labels map to enum variants + error: branch label must start with uppercase: branch labels map to enum variants | 1 | [foo: (a) Bar: (b)] | ^^^ @@ -906,7 +942,7 @@ fn type_annotation_dotted_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type names cannot contain `.` or `-`; type annotations map to types + error: type names cannot contain `.` or `-`: type annotations map to types | 1 | (a) @x :: My.Type | ^^^^^^^ @@ -928,7 +964,7 @@ fn type_annotation_kebab_suggests_pascal() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type names cannot contain `.` or `-`; type annotations map to types + error: type names cannot contain `.` or `-`: type annotations map to types | 1 | (a) @x :: My-Type | ^^^^^^^ @@ -948,7 +984,7 @@ fn type_name_with_dots_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type names cannot contain `.` or `-`; type annotations map to types + error: type names cannot contain `.` or `-`: type annotations map to types | 1 | (x) @name :: Some.Type | ^^^^^^^^^ @@ -968,7 +1004,7 @@ fn type_name_with_hyphens_error() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type names cannot contain `.` or `-`; type annotations map to types + error: type names cannot contain `.` or `-`: type annotations map to types | 1 | (x) @name :: Some-Type | ^^^^^^^^^ @@ -988,7 +1024,7 @@ fn comma_in_node_children() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `,` + error: unexpected separator: plotnik uses whitespace, not `,` | 1 | (node (a), (b)) | ^ @@ -1008,7 +1044,7 @@ fn comma_in_alternation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `,` + error: unexpected separator: plotnik uses whitespace, not `,` | 1 | [(a), (b), (c)] | ^ @@ -1019,7 +1055,7 @@ fn comma_in_alternation() { 1 + [(a) (b), (c)] | - error: separators are not needed; plotnik uses whitespace, not `,` + error: unexpected separator: plotnik uses whitespace, not `,` | 1 | [(a), (b), (c)] | ^ @@ -1039,7 +1075,7 @@ fn comma_in_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `,` + error: unexpected separator: plotnik uses whitespace, not `,` | 1 | {(a), (b)} | ^ @@ -1059,7 +1095,7 @@ fn pipe_in_alternation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `|` + error: unexpected separator: plotnik uses whitespace, not `|` | 1 | [(a) | (b) | (c)] | ^ @@ -1070,7 +1106,7 @@ fn pipe_in_alternation() { 1 + [(a) (b) | (c)] | - error: separators are not needed; plotnik uses whitespace, not `|` + error: unexpected separator: plotnik uses whitespace, not `|` | 1 | [(a) | (b) | (c)] | ^ @@ -1092,7 +1128,7 @@ fn pipe_between_branches() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `|` + error: unexpected separator: plotnik uses whitespace, not `|` | 1 | [(a) | (b)] | ^ @@ -1112,7 +1148,7 @@ fn pipe_in_tree() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `|` + error: unexpected separator: plotnik uses whitespace, not `|` | 1 | (a | b) | ^ @@ -1123,10 +1159,16 @@ fn pipe_in_tree() { 1 + (a b) | - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | (a | b) | ^ + | + help: wrap in parentheses + | + 1 - (a | b) + 1 + (a | (b)) + | "); } @@ -1137,7 +1179,7 @@ fn pipe_in_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: separators are not needed; plotnik uses whitespace, not `|` + error: unexpected separator: plotnik uses whitespace, not `|` | 1 | {(a) | (b)} | ^ @@ -1157,7 +1199,7 @@ fn field_equals_typo() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: use `:` for field constraints, not `=`; this isn't a definition + error: use `:` instead of `=`: this isn't a definition | 1 | (node name = (identifier)) | ^ @@ -1177,7 +1219,7 @@ fn field_equals_typo_no_space() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: use `:` for field constraints, not `=`; this isn't a definition + error: use `:` instead of `=`: this isn't a definition | 1 | (node name=(identifier)) | ^ @@ -1197,7 +1239,7 @@ fn field_equals_typo_no_expression() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: use `:` for field constraints, not `=`; this isn't a definition + error: use `:` instead of `=`: this isn't a definition | 1 | (call name=) | ^ @@ -1219,7 +1261,7 @@ fn field_equals_typo_in_tree() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: use `:` for field constraints, not `=`; this isn't a definition + error: use `:` instead of `=`: this isn't a definition | 1 | (call name = (identifier)) | ^ @@ -1239,7 +1281,7 @@ fn single_colon_type_annotation() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type annotations use `::`, not `:` — single `:` looks like a field + error: use `::` for type annotations: single `:` looks like a field | 1 | (identifier) @name : Type | ^ @@ -1258,7 +1300,7 @@ fn single_colon_type_annotation_no_space() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type annotations use `::`, not `:` — single `:` looks like a field + error: use `::` for type annotations: single `:` looks like a field | 1 | (identifier) @name:Type | ^ @@ -1279,7 +1321,7 @@ fn single_colon_type_annotation_with_space() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: type annotations use `::`, not `:` — single `:` looks like a field + error: use `::` for type annotations: single `:` looks like a field | 1 | (a) @x : Type | ^ @@ -1298,15 +1340,21 @@ fn single_colon_primitive_type() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: `@` must follow an expression to capture + error: capture has no target | 1 | @val : string | ^ - error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + error: bare identifier is not valid | 1 | @val : string | ^^^^^^ + | + help: wrap in parentheses + | + 1 - @val : string + 1 + @val : (string) + | "); } @@ -1318,7 +1366,7 @@ fn treesitter_sequence_syntax_warning() { let res = Query::expect_warning(input); insta::assert_snapshot!(res, @r" - warning: Tree-sitter sequence syntax + warning: tree-sitter sequence syntax | 1 | Test = ((a) (b)) | ^ @@ -1334,7 +1382,7 @@ fn treesitter_sequence_single_child_warning() { let res = Query::expect_warning(input); insta::assert_snapshot!(res, @r" - warning: Tree-sitter sequence syntax + warning: tree-sitter sequence syntax | 1 | Test = ((a)) | ^ diff --git a/crates/plotnik-lib/src/query/alt_kinds_tests.rs b/crates/plotnik-lib/src/query/alt_kinds_tests.rs index 0c39d963..3dc5deba 100644 --- a/crates/plotnik-lib/src/query/alt_kinds_tests.rs +++ b/crates/plotnik-lib/src/query/alt_kinds_tests.rs @@ -47,6 +47,8 @@ fn mixed_alternation_tagged_first() { | - ^^^ | | | tagged branch here + | + help: use all labels for a tagged union, or none for a merged struct "); } @@ -68,6 +70,8 @@ fn mixed_alternation_untagged_first() { | ^^^ 4 | B: (b) | - tagged branch here + | + help: use all labels for a tagged union, or none for a merged struct "); } @@ -84,6 +88,8 @@ fn nested_mixed_alternation() { | - ^^^ | | | tagged branch here + | + help: use all labels for a tagged union, or none for a merged struct "); } @@ -100,6 +106,8 @@ fn multiple_mixed_alternations() { | - ^^^ | | | tagged branch here + | + help: use all labels for a tagged union, or none for a merged struct error: cannot mix labeled and unlabeled branches | @@ -107,6 +115,8 @@ fn multiple_mixed_alternations() { | - ^^^ | | | tagged branch here + | + help: use all labels for a tagged union, or none for a merged struct "); } diff --git a/crates/plotnik-lib/src/query/dependencies_tests.rs b/crates/plotnik-lib/src/query/dependencies_tests.rs index 73385b6f..91ea9bd4 100644 --- a/crates/plotnik-lib/src/query/dependencies_tests.rs +++ b/crates/plotnik-lib/src/query/dependencies_tests.rs @@ -26,12 +26,14 @@ fn invalid_recursion_with_plus() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | E = (call (E)+) | ^ | | | E references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -48,6 +50,8 @@ fn invalid_unguarded_recursion_in_alternation() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -65,12 +69,14 @@ fn invalid_mandatory_recursion_in_tree_child() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | E = (call (E)) | ^ | | | E references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -81,12 +87,14 @@ fn invalid_mandatory_recursion_in_field() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | E = (call body: (E)) | ^ | | | E references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -97,12 +105,14 @@ fn invalid_mandatory_recursion_in_capture() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | E = (call (E) @inner) | ^ | | | E references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -113,12 +123,14 @@ fn invalid_mandatory_recursion_in_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | E = (call {(a) (E)}) | ^ | | | E references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -138,7 +150,7 @@ fn invalid_mutual_recursion_without_base_case() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo (B)) | - references B (completing cycle) @@ -147,6 +159,8 @@ fn invalid_mutual_recursion_without_base_case() { | | | | | references A | B is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -170,7 +184,7 @@ fn invalid_three_way_mutual_recursion() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (a (B)) | - references B @@ -181,6 +195,8 @@ fn invalid_three_way_mutual_recursion() { | | | | | references A | C is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -206,7 +222,7 @@ fn invalid_diamond_dependency_recursion() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (a [(B) (C)]) | - references C (completing cycle) @@ -218,6 +234,8 @@ fn invalid_diamond_dependency_recursion() { | C is defined here 4 | D = (d (A)) | - references A + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -231,7 +249,7 @@ fn invalid_mutual_recursion_via_field() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo body: (B)) | - references B (completing cycle) @@ -240,6 +258,8 @@ fn invalid_mutual_recursion_via_field() { | | | | | references A | B is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -253,7 +273,7 @@ fn invalid_mutual_recursion_via_capture() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo (B) @cap) | - references B (completing cycle) @@ -262,6 +282,8 @@ fn invalid_mutual_recursion_via_capture() { | | | | | references A | B is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -275,7 +297,7 @@ fn invalid_mutual_recursion_via_sequence() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo {(x) (B)}) | - references B (completing cycle) @@ -284,6 +306,8 @@ fn invalid_mutual_recursion_via_sequence() { | | | | | references A | B is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -306,7 +330,7 @@ fn invalid_mutual_recursion_with_plus_quantifier() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo (B)+) | - references B (completing cycle) @@ -315,6 +339,8 @@ fn invalid_mutual_recursion_with_plus_quantifier() { | | | | | references A | B is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -349,6 +375,8 @@ fn invalid_direct_left_recursion_in_alternation() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -365,6 +393,8 @@ fn invalid_direct_right_recursion_in_alternation() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -381,6 +411,8 @@ fn invalid_direct_left_recursion_in_tagged_alternation() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -399,6 +431,8 @@ fn invalid_unguarded_left_recursion_branch() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -417,6 +451,8 @@ fn invalid_unguarded_left_recursion_with_wildcard_alt() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -435,6 +471,8 @@ fn invalid_unguarded_left_recursion_with_tree_alt() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -455,12 +493,14 @@ fn invalid_mandatory_recursion_direct_child() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo (A)) | ^ | | | A references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -481,12 +521,14 @@ fn invalid_mandatory_recursion_nested_plus() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo (A)+) | ^ | | | A references itself + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -508,6 +550,8 @@ fn invalid_simple_unguarded_recursion() { | ^ | | | references itself + | + help: recursive references must consume input before recursing "); } @@ -530,5 +574,7 @@ fn invalid_unguarded_mutual_recursion_chain() { | | | | | references A | B is defined here + | + help: recursive references must consume input before recursing "); } diff --git a/crates/plotnik-lib/src/query/query_tests.rs b/crates/plotnik-lib/src/query/query_tests.rs index 2d0e1ed1..ecbeebbd 100644 --- a/crates/plotnik-lib/src/query/query_tests.rs +++ b/crates/plotnik-lib/src/query/query_tests.rs @@ -129,7 +129,7 @@ fn invalid_three_way_mutual_recursion_across_files() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path --> c.ptk:1:9 | 1 | C = (c (A)) @@ -147,6 +147,8 @@ fn invalid_three_way_mutual_recursion_across_files() { | 1 | B = (b (C)) | - references C (completing cycle) + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } @@ -160,7 +162,7 @@ fn multifile_field_with_ref_to_seq_error() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field `name` must match exactly one node, not a sequence + error: field `name` cannot match a sequence --> main.ptk:1:17 | 1 | Q = (call name: (X)) diff --git a/crates/plotnik-lib/src/query/symbol_table_tests.rs b/crates/plotnik-lib/src/query/symbol_table_tests.rs index f842358c..7615708a 100644 --- a/crates/plotnik-lib/src/query/symbol_table_tests.rs +++ b/crates/plotnik-lib/src/query/symbol_table_tests.rs @@ -78,7 +78,7 @@ fn mutual_recursion() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: infinite recursion: cycle has no escape path + error: infinite recursion: no escape path | 1 | A = (foo (B)) | - references B (completing cycle) @@ -87,6 +87,8 @@ fn mutual_recursion() { | | | | | references A | B is defined here + | + help: add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]` "); } diff --git a/crates/plotnik-lib/src/query/type_check/infer.rs b/crates/plotnik-lib/src/query/type_check/infer.rs index 4c8cabe5..39a8ebb4 100644 --- a/crates/plotnik-lib/src/query/type_check/infer.rs +++ b/crates/plotnik-lib/src/query/type_check/infer.rs @@ -524,10 +524,10 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { quant.text_range(), ) .message(format!( - "quantifier `{}` contains captures ({}) but no row capture", + "quantifier `{}` contains captures ({}) but has no struct capture", op, captures_str )) - .hint("wrap as `{...}* @rows`") + .hint(format!("add a struct capture: `{{...}}{} @name`", op)) .emit(); } @@ -552,29 +552,34 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { } fn report_unify_error(&mut self, range: TextRange, err: &UnifyError) { - let (kind, msg) = match err { + let (kind, msg, hint) = match err { UnifyError::ScalarInUntagged => ( DiagnosticKind::IncompatibleTypes, - "scalar type in untagged alternation; use tagged alternation instead".to_string(), + "scalar type in untagged alternation".to_string(), + Some("use tagged alternation if branches need different types"), ), UnifyError::IncompatibleTypes { field } => ( DiagnosticKind::IncompatibleCaptureTypes, self.interner.resolve(*field).to_string(), + Some("all branches must produce the same type for merged captures"), ), UnifyError::IncompatibleStructs { field } => ( DiagnosticKind::IncompatibleStructShapes, self.interner.resolve(*field).to_string(), + Some("use tagged alternation if branches need different fields"), ), UnifyError::IncompatibleArrayElements { field } => ( DiagnosticKind::IncompatibleCaptureTypes, self.interner.resolve(*field).to_string(), + Some("array element types must be compatible across branches"), ), }; - self.diag - .report(self.source_id, kind, range) - .message(msg) - .emit(); + let mut builder = self.diag.report(self.source_id, kind, range).message(msg); + if let Some(h) = hint { + builder = builder.hint(h); + } + builder.emit(); } } diff --git a/crates/plotnik-lib/src/query/type_check_tests.rs b/crates/plotnik-lib/src/query/type_check_tests.rs index c1d8bab8..0377c234 100644 --- a/crates/plotnik-lib/src/query/type_check_tests.rs +++ b/crates/plotnik-lib/src/query/type_check_tests.rs @@ -541,12 +541,12 @@ fn error_star_with_internal_captures_no_row() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: quantifier `*` contains captures (`@a`, `@b`) but no row capture + error: quantifier `*` contains captures (`@a`, `@b`) but has no struct capture | 1 | Bad = {(a) @a (b) @b}* | ^^^^^^^^^^^^^^^^ | - help: wrap as `{...}* @rows` + help: add a struct capture: `{...}* @name` "); } @@ -559,12 +559,12 @@ fn error_plus_with_internal_capture_no_row() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: quantifier `+` contains captures (`@c`) but no row capture + error: quantifier `+` contains captures (`@c`) but has no struct capture | 1 | Bad = {(c) @c}+ | ^^^^^^^^^ | - help: wrap as `{...}* @rows` + help: add a struct capture: `{...}+ @name` "); } @@ -577,12 +577,12 @@ fn error_named_node_with_capture_quantified() { let res = Query::expect_invalid(input); insta::assert_snapshot!(res, @r" - error: quantifier `*` contains captures (`@name`) but no row capture + error: quantifier `*` contains captures (`@name`) but has no struct capture | 1 | Bad = (func (identifier) @name)* | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | - help: wrap as `{...}* @rows` + help: add a struct capture: `{...}* @name` "); } diff --git a/docs/lang-reference.md b/docs/lang-reference.md index d90d387b..20e90341 100644 --- a/docs/lang-reference.md +++ b/docs/lang-reference.md @@ -195,18 +195,18 @@ The pattern is 4 levels deep, but the output is flat. You're extracting specific ### Strict Dimensionality -**Quantifiers (`*`, `+`) containing internal captures require an explicit row capture.** +**Quantifiers (`*`, `+`) containing internal captures require a struct capture.** ``` -// ERROR: internal capture without row structure +// ERROR: internal capture without struct capture (method_definition name: (identifier) @name)* -// OK: explicit row capture +// OK: struct capture on the group { (method_definition name: (identifier) @name) @method }* @methods → { methods: { method: Node, name: Node }[] } ``` -This prevents association loss—each row is a distinct object, not parallel arrays that lose per-iteration grouping. See [Type System: Strict Dimensionality](type-system.md#1-strict-dimensionality). +This prevents association loss—each struct is a distinct object, not parallel arrays that lose per-iteration grouping. See [Type System: Strict Dimensionality](type-system.md#1-strict-dimensionality). ### The Node Type @@ -232,13 +232,13 @@ Quantifiers determine whether a field is singular, optional, or an array: | `(x)* @a` | `a: T[]` | zero or more (scalar list) | | `(x)+ @a` | `a: [T, ...T[]]` | one or more (scalar list) | -Scalar lists work when the quantified pattern has **no internal captures**. For patterns with internal captures, use row lists: +Node arrays work when the quantified pattern has **no internal captures**. For patterns with internal captures, use struct arrays: -| Pattern | Output Type | Meaning | -| -------------- | ---------------- | ------------------------------------ | -| `{...}* @rows` | `rows: T[]` | zero or more rows | -| `{...}+ @rows` | `rows: [T, ...]` | one or more rows | -| `{...}? @row` | `row?: T` | optional row (bubbles if uncaptured) | +| Pattern | Output Type | Meaning | +| --------------- | ----------------- | --------------------------------------- | +| `{...}* @items` | `items: T[]` | zero or more structs | +| `{...}+ @items` | `items: [T, ...]` | one or more structs | +| `{...}? @item` | `item?: T` | optional struct (bubbles if uncaptured) | ### Creating Nested Structure @@ -300,15 +300,15 @@ interface FunctionDeclaration { ### Summary -| Pattern | Output | -| ----------------------- | ----------------------------------- | -| `@name` | Field in current scope | -| `(x)? @a` | Optional field | -| `(x)* @a` | Scalar array (no internal captures) | -| `{...}* @rows` | Row array (with internal captures) | -| `{...} @x` / `[...] @x` | Nested object (new scope) | -| `@x :: string` | String value | -| `@x :: T` | Custom type name | +| Pattern | Output | +| ----------------------- | ------------------------------------- | +| `@name` | Field in current scope | +| `(x)? @a` | Optional field | +| `(x)* @a` | Node array (no internal captures) | +| `{...}* @items` | Struct array (with internal captures) | +| `{...} @x` / `[...] @x` | Nested object (new scope) | +| `@x :: string` | String value | +| `@x :: T` | Custom type name | --- diff --git a/docs/type-system.md b/docs/type-system.md index b24f33a1..f2a7f987 100644 --- a/docs/type-system.md +++ b/docs/type-system.md @@ -10,7 +10,7 @@ Two principles guide the type system: 1. **Flat structure**: Captures bubble up to the nearest scope boundary. -2. **Strict dimensionality**: Quantifiers (`*`, `+`) containing captures require an explicit row capture. The alternative could be creating parallel arrays, but it's hard to maintain the per-iteration association for `a[i]` and `b[i]`. +2. **Strict dimensionality**: Quantifiers (`*`, `+`) containing captures require a struct capture. The alternative—parallel arrays—loses per-iteration association between `a[i]` and `b[i]`. ### Why Transparent Scoping @@ -35,16 +35,16 @@ This is the core rule that prevents association loss. ### The Rule -**Any quantified pattern (`*`, `+`) containing captures must have an explicit row capture.** +**Any quantified pattern (`*`, `+`) containing captures must have a struct capture.** -| Pattern | Status | Reason | -| --------------------------------- | ------- | ------------------------------------------ | -| `(identifier)* @ids` | ✓ Valid | No internal captures → scalar list | -| `{ (a) @a (b) @b }* @rows` | ✓ Valid | Internal captures + row capture → row list | -| `{ (a) @a (b) @b }*` | ✗ Error | Internal captures, no row capture | -| `(func (id) @name)*` | ✗ Error | Internal capture, no row structure | -| `(func (id) @name)* @funcs` | ✗ Error | `@funcs` captures nodes, not rows | -| `(Item)*` where Item has captures | ✗ Error | Transitive: definition's captures count | +| Pattern | Status | Reason | +| --------------------------------- | ------- | ------------------------------------------------- | +| `(identifier)* @ids` | ✓ Valid | No internal captures → node array | +| `{ (a) @a (b) @b }* @items` | ✓ Valid | Internal captures + struct capture → struct array | +| `{ (a) @a (b) @b }*` | ✗ Error | Internal captures, no struct capture | +| `(func (id) @name)*` | ✗ Error | Internal capture, no struct capture | +| `(func (id) @name)* @funcs` | ✗ Error | `@funcs` captures nodes, not structs | +| `(Item)*` where Item has captures | ✗ Error | Transitive: definition's captures count | ### Transitive Application @@ -58,13 +58,13 @@ Item = (pair (key) @k (value) @v) (Item)* // ✗ Error (pair (key) @k (value) @v)* // ✗ Error (same thing) -// Fix: wrap in row capture +// Fix: add struct capture { (Item) @item }* @items // ✓ Valid ``` The compiler expands definitions before validating strict dimensionality. This prevents a loophole where extracting a pattern into a definition would bypass the rule. -### Scalar Lists +### Node Arrays When the quantified pattern has **no internal captures**, the outer capture collects nodes directly: @@ -78,7 +78,7 @@ When the quantified pattern has **no internal captures**, the outer capture coll Use case: collecting simple tokens (identifiers, keywords, literals). -### Row Lists +### Struct Arrays When the quantified pattern **has internal captures**, wrap in a sequence and capture the sequence: @@ -93,10 +93,10 @@ When the quantified pattern **has internal captures**, wrap in a sequence and ca For node patterns with internal captures, wrap explicitly: ``` -// ERROR: internal capture without row structure +// ERROR: internal capture without struct capture (parameter (identifier) @name)* -// OK: explicit row +// OK: struct capture on the group { (parameter (identifier) @name) @param }* @params → { params: { param: Node, name: string }[] } ``` @@ -188,29 +188,29 @@ Quantifiers determine whether a field is singular, optional, or an array: | `(A)* @a` | `a: T[]` | zero or more | | `(A)+ @a` | `a: [T, ...T[]]` | one or more | -### Row Cardinality +### Struct Array Cardinality -When using row lists, the outer quantifier determines list cardinality: +When using struct arrays, the outer quantifier determines cardinality: ``` -{ (a) @a (b) @b }* @rows → rows: { a: T, b: T }[] -{ (a) @a (b) @b }+ @rows → rows: [{ a: T, b: T }, ...] -{ (a) @a (b) @b }? @row → row?: { a: T, b: T } +{ (a) @a (b) @b }* @items → items: { a: T, b: T }[] +{ (a) @a (b) @b }+ @items → items: [{ a: T, b: T }, ...] +{ (a) @a (b) @b }? @item → item?: { a: T, b: T } ``` ### Nested Quantifiers -Within a row, inner quantifiers apply to fields: +Within each struct, inner quantifiers apply to fields: ``` { - (decorator)* @decs // Array field within each row - (function) @fn // Singular field within each row + (decorator)* @decs // Array field within each struct + (function) @fn // Singular field within each struct }* @items → { items: { decs: Node[], fn: Node }[] } ``` -Each row has its own `decs` array—no cross-row mixing. +Each struct has its own `decs` array—no cross-struct mixing. ## 5. Type Unification in Alternations