diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index b30d1646..940632b6 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -1,5 +1,218 @@ use rowan::TextRange; +/// Diagnostic kinds ordered by priority (highest priority first). +/// +/// When two diagnostics have overlapping spans, the higher-priority one +/// suppresses the lower-priority one. This prevents cascading error noise. +/// +/// Priority rationale: +/// - Unclosed delimiters cause massive cascading errors downstream +/// - Expected token errors are root causes the user should fix first +/// - Invalid syntax usage is a specific mistake at a location +/// - Naming validation errors are convention violations +/// - Semantic errors assume valid syntax +/// - Structural observations are often consequences of earlier errors +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DiagnosticKind { + // These cause cascading errors throughout the rest of the file + UnclosedTree, + UnclosedSequence, + UnclosedAlternation, + + // User omitted something required - root cause errors + ExpectedExpression, + ExpectedTypeName, + ExpectedCaptureName, + ExpectedFieldName, + ExpectedSubtype, + + // User wrote something that doesn't belong + EmptyTree, + BareIdentifier, + InvalidSeparator, + InvalidFieldEquals, + InvalidSupertypeSyntax, + InvalidTypeAnnotationSyntax, + ErrorTakesNoArguments, + RefCannotHaveChildren, + ErrorMissingOutsideParens, + UnsupportedPredicate, + UnexpectedToken, + CaptureWithoutTarget, + LowercaseBranchLabel, + + // Convention violations - fixable with suggestions + CaptureNameHasDots, + CaptureNameHasHyphens, + CaptureNameUppercase, + DefNameLowercase, + DefNameHasSeparators, + BranchLabelHasSeparators, + FieldNameHasDots, + FieldNameHasHyphens, + FieldNameUppercase, + TypeNameInvalidChars, + + // Valid syntax, invalid semantics + DuplicateDefinition, + UndefinedReference, + MixedAltBranches, + RecursionNoEscape, + FieldSequenceValue, + + // Often consequences of earlier errors + UnnamedDefNotLast, +} + +impl DiagnosticKind { + /// Default severity for this kind. Can be overridden by policy. + pub fn default_severity(&self) -> Severity { + Severity::Error + } + + /// Whether this kind suppresses `other` when spans overlap. + /// + /// Uses enum discriminant ordering: lower position = higher priority. + /// A higher-priority diagnostic suppresses lower-priority ones in the same span. + pub fn suppresses(&self, other: &DiagnosticKind) -> bool { + self < other + } + + /// Structural errors are Unclosed* - they cause cascading errors but + /// should be suppressed by root-cause errors at the same position. + pub fn is_structural_error(&self) -> bool { + matches!( + self, + Self::UnclosedTree | Self::UnclosedSequence | Self::UnclosedAlternation + ) + } + + /// Root cause errors - user omitted something required. + /// These suppress structural errors at the same position. + pub fn is_root_cause_error(&self) -> bool { + matches!( + self, + Self::ExpectedExpression + | Self::ExpectedTypeName + | Self::ExpectedCaptureName + | Self::ExpectedFieldName + | Self::ExpectedSubtype + ) + } + + /// Consequence errors - often caused by earlier parse errors. + /// These get suppressed when any root-cause or structural error exists. + pub fn is_consequence_error(&self) -> bool { + matches!(self, Self::UnnamedDefNotLast) + } + + /// 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 + Self::UnclosedTree => "missing closing `)`", + Self::UnclosedSequence => "missing closing `}`", + Self::UnclosedAlternation => "missing closing `]`", + + // Expected token errors - specific about what's needed + Self::ExpectedExpression => "expected an expression", + Self::ExpectedTypeName => "expected type name after `::`", + Self::ExpectedCaptureName => "expected name after `@`", + 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::RefCannotHaveChildren => "references cannot have children", + Self::ErrorMissingOutsideParens => { + "`ERROR` and `MISSING` must be wrapped in parentheses" + } + Self::UnsupportedPredicate => "predicates like `#match?` are not supported", + Self::UnexpectedToken => "unexpected token", + Self::CaptureWithoutTarget => "`@` must follow an expression to capture", + Self::LowercaseBranchLabel => "branch labels must be capitalized", + + // Naming convention violations + Self::CaptureNameHasDots => "capture names cannot contain `.`", + Self::CaptureNameHasHyphens => "capture names cannot contain `-`", + Self::CaptureNameUppercase => "capture names must be lowercase", + Self::DefNameLowercase => "definition names must start uppercase", + Self::DefNameHasSeparators => "definition names must be PascalCase", + Self::BranchLabelHasSeparators => "branch labels must be PascalCase", + Self::FieldNameHasDots => "field names cannot contain `.`", + Self::FieldNameHasHyphens => "field names cannot contain `-`", + Self::FieldNameUppercase => "field names must be lowercase", + Self::TypeNameInvalidChars => "type names cannot contain `.` or `-`", + + // Semantic errors + Self::DuplicateDefinition => "name already defined", + Self::UndefinedReference => "undefined reference", + Self::MixedAltBranches => "cannot mix labeled and unlabeled branches", + Self::RecursionNoEscape => "infinite recursion detected", + Self::FieldSequenceValue => "field must match exactly one node", + + // Structural + Self::UnnamedDefNotLast => "only the last definition can be unnamed", + } + } + + /// Template for custom messages. Contains `{}` placeholder for caller-provided detail. + pub fn custom_message(&self) -> String { + match self { + // Special formatting for references + Self::RefCannotHaveChildren => { + "`{}` is a reference and cannot have children".to_string() + } + Self::FieldSequenceValue => { + "field `{}` must match exactly one node, not a sequence".to_string() + } + + // Semantic errors with name context + Self::DuplicateDefinition => "`{}` is already defined".to_string(), + Self::UndefinedReference => "`{}` is not defined".to_string(), + + // Recursion with cycle path + Self::RecursionNoEscape => "infinite recursion: {}".to_string(), + + // Alternation mixing + Self::MixedAltBranches => "cannot mix labeled and unlabeled branches: {}".to_string(), + + // Unclosed with context + Self::UnclosedTree | Self::UnclosedSequence | Self::UnclosedAlternation => { + format!("{}; {{}}", self.fallback_message()) + } + + // Type annotation specifics + Self::InvalidTypeAnnotationSyntax => { + "type annotations use `::`, not `:` — {}".to_string() + } + + // Named def ordering + Self::UnnamedDefNotLast => "only the last definition can be unnamed — {}".to_string(), + + // Standard pattern: fallback + context + _ => format!("{}; {{}}", self.fallback_message()), + } + } + + /// Render the final message. + /// + /// - `None` → returns `fallback_message()` + /// - `Some(detail)` → returns `custom_message()` with `{}` replaced by detail + pub fn message(&self, msg: Option<&str>) -> String { + match msg { + None => self.fallback_message().to_string(), + Some(detail) => self.custom_message().replace("{}", detail), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Severity { #[default] @@ -48,40 +261,45 @@ impl RelatedInfo { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DiagnosticMessage { - pub(crate) severity: Severity, + pub(crate) kind: DiagnosticKind, + /// The range shown to the user (underlined in output). pub(crate) range: TextRange, + /// The range used for suppression logic. Errors within another error's + /// suppression_range may be suppressed. Defaults to `range` but can be + /// set to a parent context (e.g., enclosing tree span) for better cascading + /// error suppression. + pub(crate) suppression_range: TextRange, pub(crate) message: String, pub(crate) fix: Option, pub(crate) related: Vec, } impl DiagnosticMessage { - pub(crate) fn error(range: TextRange, message: impl Into) -> Self { + pub(crate) fn new(kind: DiagnosticKind, range: TextRange, message: impl Into) -> Self { Self { - severity: Severity::Error, + kind, range, + suppression_range: range, message: message.into(), fix: None, related: Vec::new(), } } - pub(crate) fn warning(range: TextRange, message: impl Into) -> Self { - Self { - severity: Severity::Warning, - range, - message: message.into(), - fix: None, - related: Vec::new(), - } + pub(crate) fn with_default_message(kind: DiagnosticKind, range: TextRange) -> Self { + Self::new(kind, range, kind.fallback_message()) + } + + pub(crate) fn severity(&self) -> Severity { + self.kind.default_severity() } pub(crate) fn is_error(&self) -> bool { - self.severity == Severity::Error + self.severity() == Severity::Error } pub(crate) fn is_warning(&self) -> bool { - self.severity == Severity::Warning + self.severity() == Severity::Warning } } @@ -90,7 +308,7 @@ impl std::fmt::Display for DiagnosticMessage { write!( f, "{} at {}..{}: {}", - self.severity, + self.severity(), u32::from(self.range.start()), u32::from(self.range.end()), self.message diff --git a/crates/plotnik-lib/src/diagnostics/mod.rs b/crates/plotnik-lib/src/diagnostics/mod.rs index b265d4d4..eee2f302 100644 --- a/crates/plotnik-lib/src/diagnostics/mod.rs +++ b/crates/plotnik-lib/src/diagnostics/mod.rs @@ -6,7 +6,7 @@ mod tests; use rowan::TextRange; -pub use message::Severity; +pub use message::{DiagnosticKind, Severity}; pub use printer::DiagnosticsPrinter; use message::{DiagnosticMessage, Fix, RelatedInfo}; @@ -29,17 +29,29 @@ impl Diagnostics { } } + /// Create a diagnostic with the given kind and span. + /// + /// Uses the kind's default message. Call `.message()` on the builder to override. + pub fn report(&mut self, kind: DiagnosticKind, range: TextRange) -> DiagnosticBuilder<'_> { + DiagnosticBuilder { + diagnostics: self, + message: DiagnosticMessage::with_default_message(kind, range), + } + } + + /// Create an error diagnostic (legacy API, prefer `report()`). pub fn error(&mut self, msg: impl Into, range: TextRange) -> DiagnosticBuilder<'_> { DiagnosticBuilder { diagnostics: self, - message: DiagnosticMessage::error(range, msg), + message: DiagnosticMessage::new(DiagnosticKind::UnexpectedToken, range, msg), } } + /// Create a warning diagnostic (legacy API, prefer `report()`). pub fn warning(&mut self, msg: impl Into, range: TextRange) -> DiagnosticBuilder<'_> { DiagnosticBuilder { diagnostics: self, - message: DiagnosticMessage::warning(range, msg), + message: DiagnosticMessage::new(DiagnosticKind::UnexpectedToken, range, msg), } } @@ -67,8 +79,94 @@ impl Diagnostics { self.messages.iter().filter(|d| d.is_warning()).count() } - pub fn printer<'a>(&'a self, source: &'a str) -> DiagnosticsPrinter<'a> { - DiagnosticsPrinter::new(&self.messages, source) + /// Returns diagnostics with cascading errors suppressed. + /// + /// Suppression rules: + /// 1. Containment: when error A's suppression_range contains error B's display range, + /// and A has higher priority, suppress B (only for structural errors) + /// 2. Same position: when spans start at the same position, root-cause errors suppress structural ones + /// 3. Consequence errors (UnnamedDefNotLast) suppressed when any other error exists + /// 4. Adjacent: when error A ends exactly where error B starts, A suppresses B + pub(crate) fn filtered(&self) -> Vec { + if self.messages.is_empty() { + return Vec::new(); + } + + let mut suppressed = vec![false; self.messages.len()]; + + // Rule 3: Suppress consequence errors if any non-consequence error exists + let has_primary_error = self.messages.iter().any(|m| !m.kind.is_consequence_error()); + if has_primary_error { + for (i, msg) in self.messages.iter().enumerate() { + if msg.kind.is_consequence_error() { + suppressed[i] = true; + } + } + } + + // O(n²) but n is typically small (< 100 diagnostics) + for (i, a) in self.messages.iter().enumerate() { + for (j, b) in self.messages.iter().enumerate() { + if i == j || suppressed[i] || suppressed[j] { + continue; + } + + // Rule 1: Structural error containment + // Only unclosed delimiters can suppress distant errors, because they cause + // cascading parse failures throughout the tree + let contains = a.suppression_range.start() <= b.range.start() + && b.range.end() <= a.suppression_range.end(); + if contains && a.kind.is_structural_error() && a.kind.suppresses(&b.kind) { + suppressed[j] = true; + continue; + } + + // Rule 2: Same start position + if a.range.start() == b.range.start() { + // Root cause errors (Expected*) suppress structural errors (Unclosed*) + // even though structural errors have higher enum priority. This is because + // ExpectedExpression is the actual mistake; UnclosedTree is a consequence. + if a.kind.is_root_cause_error() && b.kind.is_structural_error() { + suppressed[j] = true; + continue; + } + if a.kind.suppresses(&b.kind) { + suppressed[j] = true; + continue; + } + } + + // Rule 4: Adjacent position - when A ends exactly where B starts, + // B is likely a consequence of A (e.g., `@x` where `@` is unexpected + // and `x` would be reported as bare identifier). + // Priority doesn't matter here - position determines causality. + if a.range.end() == b.range.start() { + suppressed[j] = true; + } + } + } + + self.messages + .iter() + .enumerate() + .filter(|(i, _)| !suppressed[*i]) + .map(|(_, m)| m.clone()) + .collect() + } + + /// Raw access to all diagnostics (for debugging/testing). + #[allow(dead_code)] + pub(crate) fn raw(&self) -> &[DiagnosticMessage] { + &self.messages + } + + pub fn printer<'a>(&self, source: &'a str) -> DiagnosticsPrinter<'a> { + DiagnosticsPrinter::new(self.messages.clone(), source) + } + + /// Printer that uses filtered diagnostics (cascading errors suppressed). + pub fn filtered_printer<'a>(&self, source: &'a str) -> DiagnosticsPrinter<'a> { + DiagnosticsPrinter::new(self.filtered(), source) } pub fn render(&self, source: &str) -> String { @@ -79,17 +177,44 @@ impl Diagnostics { self.printer(source).colored(colored).render() } + pub fn render_filtered(&self, source: &str) -> String { + self.filtered_printer(source).render() + } + + pub fn render_filtered_colored(&self, source: &str, colored: bool) -> String { + self.filtered_printer(source).colored(colored).render() + } + pub fn extend(&mut self, other: Diagnostics) { self.messages.extend(other.messages); } } impl<'a> DiagnosticBuilder<'a> { + /// Provide custom detail for this diagnostic, rendered using the kind's template. + pub fn message(mut self, msg: impl Into) -> Self { + let detail = msg.into(); + self.message.message = self.message.kind.message(Some(&detail)); + self + } + pub fn related_to(mut self, msg: impl Into, range: TextRange) -> Self { self.message.related.push(RelatedInfo::new(range, msg)); self } + /// Set the suppression range for this diagnostic. + /// + /// The suppression range is used to suppress cascading errors. Errors whose + /// display range falls within another error's suppression range may be + /// suppressed if the containing error has higher priority. + /// + /// Typically set to the parent context span (e.g., enclosing tree). + pub fn suppression_range(mut self, range: TextRange) -> Self { + self.message.suppression_range = range; + self + } + pub fn fix(mut self, description: impl Into, replacement: impl Into) -> Self { self.message.fix = Some(Fix::new(replacement, description)); self diff --git a/crates/plotnik-lib/src/diagnostics/printer.rs b/crates/plotnik-lib/src/diagnostics/printer.rs index fe05e936..4c86a424 100644 --- a/crates/plotnik-lib/src/diagnostics/printer.rs +++ b/crates/plotnik-lib/src/diagnostics/printer.rs @@ -8,14 +8,14 @@ use rowan::TextRange; use super::message::{DiagnosticMessage, Severity}; pub struct DiagnosticsPrinter<'a> { - diagnostics: &'a [DiagnosticMessage], + diagnostics: Vec, source: &'a str, path: Option<&'a str>, colored: bool, } impl<'a> DiagnosticsPrinter<'a> { - pub(crate) fn new(diagnostics: &'a [DiagnosticMessage], source: &'a str) -> Self { + pub(crate) fn new(diagnostics: Vec, source: &'a str) -> Self { Self { diagnostics, source, @@ -50,11 +50,9 @@ impl<'a> DiagnosticsPrinter<'a> { for (i, diag) in self.diagnostics.iter().enumerate() { let range = adjust_range(diag.range, self.source.len()); - let mut snippet = Snippet::source(self.source).line_start(1).annotation( - AnnotationKind::Primary - .span(range.clone()) - .label(&diag.message), - ); + let mut snippet = Snippet::source(self.source) + .line_start(1) + .annotation(AnnotationKind::Primary.span(range.clone())); if let Some(p) = self.path { snippet = snippet.path(p); @@ -68,7 +66,7 @@ impl<'a> DiagnosticsPrinter<'a> { ); } - let level = severity_to_level(diag.severity); + let level = severity_to_level(diag.severity()); let title_group = level.primary_title(&diag.message).element(snippet); let mut report: Vec = vec![title_group]; @@ -84,7 +82,7 @@ impl<'a> DiagnosticsPrinter<'a> { } if i > 0 { - w.write_char('\n')?; + w.write_str("\n\n")?; } write!(w, "{}", renderer.render(&report))?; } diff --git a/crates/plotnik-lib/src/diagnostics/tests.rs b/crates/plotnik-lib/src/diagnostics/tests.rs index 37e68726..0f921aeb 100644 --- a/crates/plotnik-lib/src/diagnostics/tests.rs +++ b/crates/plotnik-lib/src/diagnostics/tests.rs @@ -9,46 +9,64 @@ fn severity_display() { } #[test] -fn error_builder() { +fn report_with_default_message() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("test error", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::ExpectedTypeName, + TextRange::new(0.into(), 5.into()), + ) .emit(); assert_eq!(diagnostics.len(), 1); assert!(diagnostics.has_errors()); - assert!(!diagnostics.has_warnings()); } #[test] -fn warning_builder() { +fn report_with_custom_message() { let mut diagnostics = Diagnostics::new(); diagnostics - .warning("test warning", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::ExpectedTypeName, + TextRange::new(0.into(), 5.into()), + ) + .message("expected type name after '::' (e.g., ::MyType)") + .emit(); + + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics.has_errors()); +} + +#[test] +fn error_builder_legacy() { + let mut diagnostics = Diagnostics::new(); + diagnostics + .error("test error", TextRange::new(0.into(), 5.into())) .emit(); assert_eq!(diagnostics.len(), 1); - assert!(!diagnostics.has_errors()); - assert!(diagnostics.has_warnings()); + assert!(diagnostics.has_errors()); } #[test] fn builder_with_related() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("primary", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 5.into()), + ) + .message("primary") .related_to("related info", TextRange::new(6.into(), 10.into())) .emit(); assert_eq!(diagnostics.len(), 1); let result = diagnostics.printer("hello world!").render(); insta::assert_snapshot!(result, @r" - error: primary + error: missing closing `)`; primary | 1 | hello world! | ^^^^^ ---- related info - | | - | primary "); } @@ -56,16 +74,20 @@ fn builder_with_related() { fn builder_with_fix() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("fixable", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::InvalidFieldEquals, + TextRange::new(0.into(), 5.into()), + ) + .message("fixable") .fix("apply this fix", "fixed") .emit(); let result = diagnostics.printer("hello world").render(); insta::assert_snapshot!(result, @r" - error: fixable + error: use `:` for field constraints, not `=`; fixable | 1 | hello world - | ^^^^^ fixable + | ^^^^^ | help: apply this fix | @@ -79,7 +101,11 @@ fn builder_with_fix() { fn builder_with_all_options() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("main error", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 5.into()), + ) + .message("main error") .related_to("see also", TextRange::new(6.into(), 11.into())) .related_to("and here", TextRange::new(12.into(), 17.into())) .fix("try this", "HELLO") @@ -87,13 +113,12 @@ fn builder_with_all_options() { let result = diagnostics.printer("hello world stuff!").render(); insta::assert_snapshot!(result, @r" - error: main error + error: missing closing `)`; main error | 1 | hello world stuff! | ^^^^^ ----- ----- and here - | | | - | | see also - | main error + | | + | see also | help: try this | @@ -107,7 +132,11 @@ fn builder_with_all_options() { fn printer_colored() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("test", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::EmptyTree, + TextRange::new(0.into(), 5.into()), + ) + .message("test") .emit(); let result = diagnostics.printer("hello").colored(true).render(); @@ -126,16 +155,20 @@ fn printer_empty_diagnostics() { fn printer_with_path() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("test error", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::UndefinedReference, + TextRange::new(0.into(), 5.into()), + ) + .message("test error") .emit(); let result = diagnostics.printer("hello world").path("test.pql").render(); insta::assert_snapshot!(result, @r" - error: test error + error: `test error` is not defined --> test.pql:1:1 | 1 | hello world - | ^^^^^ test error + | ^^^^^ "); } @@ -143,15 +176,19 @@ fn printer_with_path() { fn printer_zero_width_span() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("zero width error", TextRange::empty(0.into())) + .report( + DiagnosticKind::ExpectedExpression, + TextRange::empty(0.into()), + ) + .message("zero width error") .emit(); let result = diagnostics.printer("hello").render(); insta::assert_snapshot!(result, @r" - error: zero width error + error: expected an expression; zero width error | 1 | hello - | ^ zero width error + | ^ "); } @@ -159,18 +196,20 @@ fn printer_zero_width_span() { fn printer_related_zero_width() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("primary", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 5.into()), + ) + .message("primary") .related_to("zero width related", TextRange::empty(6.into())) .emit(); let result = diagnostics.printer("hello world!").render(); insta::assert_snapshot!(result, @r" - error: primary + error: missing closing `)`; primary | 1 | hello world! | ^^^^^ - zero width related - | | - | primary "); } @@ -178,55 +217,247 @@ fn printer_related_zero_width() { fn printer_multiple_diagnostics() { let mut diagnostics = Diagnostics::new(); diagnostics - .error("first error", TextRange::new(0.into(), 5.into())) + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 5.into()), + ) + .message("first error") .emit(); diagnostics - .error("second error", TextRange::new(6.into(), 10.into())) + .report( + DiagnosticKind::UndefinedReference, + TextRange::new(6.into(), 10.into()), + ) + .message("second error") .emit(); let result = diagnostics.printer("hello world!").render(); insta::assert_snapshot!(result, @r" - error: first error + error: missing closing `)`; first error | 1 | hello world! - | ^^^^^ first error - error: second error + | ^^^^^ + + error: `second error` is not defined | 1 | hello world! - | ^^^^ second error + | ^^^^ "); } #[test] -fn printer_warning() { +fn diagnostics_collection_methods() { let mut diagnostics = Diagnostics::new(); diagnostics - .warning("a warning", TextRange::new(0.into(), 5.into())) + .report(DiagnosticKind::UnclosedTree, TextRange::empty(0.into())) + .emit(); + diagnostics + .report( + DiagnosticKind::UndefinedReference, + TextRange::empty(1.into()), + ) .emit(); - let result = diagnostics.printer("hello").render(); - insta::assert_snapshot!(result, @r" - warning: a warning - | - 1 | hello - | ^^^^^ a warning - "); + assert!(!diagnostics.is_empty()); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics.has_errors()); + assert_eq!(diagnostics.error_count(), 2); } #[test] -fn diagnostics_collection_methods() { +fn diagnostic_kind_default_severity() { + assert_eq!( + DiagnosticKind::UnclosedTree.default_severity(), + Severity::Error + ); + assert_eq!( + DiagnosticKind::UnnamedDefNotLast.default_severity(), + Severity::Error + ); +} + +#[test] +fn diagnostic_kind_suppression_order() { + // Higher priority (earlier in enum) suppresses lower priority (later in enum) + assert!(DiagnosticKind::UnclosedTree.suppresses(&DiagnosticKind::UnnamedDefNotLast)); + assert!(DiagnosticKind::UnclosedTree.suppresses(&DiagnosticKind::UndefinedReference)); + assert!(DiagnosticKind::ExpectedExpression.suppresses(&DiagnosticKind::UnnamedDefNotLast)); + + // Same kind doesn't suppress itself + assert!(!DiagnosticKind::UnclosedTree.suppresses(&DiagnosticKind::UnclosedTree)); + + // Lower priority doesn't suppress higher priority + assert!(!DiagnosticKind::UnnamedDefNotLast.suppresses(&DiagnosticKind::UnclosedTree)); +} + +#[test] +fn diagnostic_kind_fallback_messages() { + assert_eq!( + DiagnosticKind::UnclosedTree.fallback_message(), + "missing closing `)`" + ); + assert_eq!( + DiagnosticKind::UnclosedSequence.fallback_message(), + "missing closing `}`" + ); + assert_eq!( + DiagnosticKind::UnclosedAlternation.fallback_message(), + "missing closing `]`" + ); + assert_eq!( + DiagnosticKind::ExpectedExpression.fallback_message(), + "expected an expression" + ); +} + +#[test] +fn diagnostic_kind_custom_messages() { + assert_eq!( + DiagnosticKind::UnclosedTree.custom_message(), + "missing closing `)`; {}" + ); + assert_eq!( + DiagnosticKind::UndefinedReference.custom_message(), + "`{}` is not defined" + ); +} + +#[test] +fn diagnostic_kind_message_rendering() { + // No custom message → fallback + assert_eq!( + DiagnosticKind::UnclosedTree.message(None), + "missing closing `)`" + ); + // With custom message → template applied + assert_eq!( + DiagnosticKind::UnclosedTree.message(Some("expected `)`")), + "missing closing `)`; expected `)`" + ); + assert_eq!( + DiagnosticKind::UndefinedReference.message(Some("Foo")), + "`Foo` is not defined" + ); +} + +// === Filtering/suppression tests === + +#[test] +fn filtered_no_suppression_disjoint_spans() { let mut diagnostics = Diagnostics::new(); + // Two errors at different positions - both should show diagnostics - .error("error", TextRange::empty(0.into())) + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 5.into()), + ) .emit(); diagnostics - .warning("warning", TextRange::empty(1.into())) + .report( + DiagnosticKind::UndefinedReference, + TextRange::new(10.into(), 15.into()), + ) .emit(); - assert!(!diagnostics.is_empty()); - assert_eq!(diagnostics.len(), 2); - assert!(diagnostics.has_errors()); - assert!(diagnostics.has_warnings()); - assert_eq!(diagnostics.error_count(), 1); - assert_eq!(diagnostics.warning_count(), 1); + let filtered = diagnostics.filtered(); + assert_eq!(filtered.len(), 2); +} + +#[test] +fn filtered_suppresses_lower_priority_contained() { + let mut diagnostics = Diagnostics::new(); + // Higher priority error (UnclosedTree) contains lower priority (UnnamedDefNotLast) + diagnostics + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 20.into()), + ) + .emit(); + diagnostics + .report( + DiagnosticKind::UnnamedDefNotLast, + TextRange::new(5.into(), 15.into()), + ) + .emit(); + + let filtered = diagnostics.filtered(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].kind, DiagnosticKind::UnclosedTree); +} + +#[test] +fn filtered_consequence_suppressed_by_structural() { + let mut diagnostics = Diagnostics::new(); + // Consequence error (UnnamedDefNotLast) suppressed when structural error (UnclosedTree) exists + diagnostics + .report( + DiagnosticKind::UnnamedDefNotLast, + TextRange::new(0.into(), 20.into()), + ) + .emit(); + diagnostics + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(5.into(), 15.into()), + ) + .emit(); + + let filtered = diagnostics.filtered(); + // Only UnclosedTree remains - consequence errors suppressed when primary errors exist + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].kind, DiagnosticKind::UnclosedTree); +} + +#[test] +fn filtered_same_span_higher_priority_wins() { + let mut diagnostics = Diagnostics::new(); + // Two errors at exact same span + diagnostics + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 10.into()), + ) + .emit(); + diagnostics + .report( + DiagnosticKind::UnnamedDefNotLast, + TextRange::new(0.into(), 10.into()), + ) + .emit(); + + let filtered = diagnostics.filtered(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].kind, DiagnosticKind::UnclosedTree); +} + +#[test] +fn filtered_empty_diagnostics() { + let diagnostics = Diagnostics::new(); + let filtered = diagnostics.filtered(); + assert!(filtered.is_empty()); +} + +#[test] +fn render_filtered() { + let mut diagnostics = Diagnostics::new(); + // Add overlapping errors where one should be suppressed + diagnostics + .report( + DiagnosticKind::UnclosedTree, + TextRange::new(0.into(), 20.into()), + ) + .message("unclosed tree") + .emit(); + diagnostics + .report( + DiagnosticKind::UnnamedDefNotLast, + TextRange::new(5.into(), 15.into()), + ) + .message("unnamed def") + .emit(); + + let result = diagnostics.render_filtered("(function_declaration"); + // Should only show the unclosed tree error + assert!(result.contains("unclosed tree")); + assert!(!result.contains("unnamed def")); } diff --git a/crates/plotnik-lib/src/parser/ast_tests.rs b/crates/plotnik-lib/src/parser/ast_tests.rs index 40c5bc7f..eb18a1f6 100644 --- a/crates/plotnik-lib/src/parser/ast_tests.rs +++ b/crates/plotnik-lib/src/parser/ast_tests.rs @@ -261,12 +261,12 @@ fn complex_example() { fn ast_with_errors() { let query = Query::try_from("(call (Undefined))").unwrap(); assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: undefined reference: `Undefined` + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `Undefined` is not defined | 1 | (call (Undefined)) - | ^^^^^^^^^ undefined reference: `Undefined` - "#); + | ^^^^^^^^^ + "); } #[test] diff --git a/crates/plotnik-lib/src/parser/core.rs b/crates/plotnik-lib/src/parser/core.rs index 2d459d6c..a451f03f 100644 --- a/crates/plotnik-lib/src/parser/core.rs +++ b/crates/plotnik-lib/src/parser/core.rs @@ -6,7 +6,7 @@ use super::ast::Root; use super::cst::token_sets::ROOT_EXPR_FIRST; use super::cst::{SyntaxKind, SyntaxNode, TokenSet}; use super::lexer::{Token, token_text}; -use crate::diagnostics::Diagnostics; +use crate::diagnostics::{DiagnosticKind, Diagnostics}; use crate::Error; @@ -254,22 +254,45 @@ impl<'src> Parser<'src> { if self.eat(kind) { return true; } - self.error(format!("expected {}", what)); + let msg = format!("expected {}", what); + self.error_msg(DiagnosticKind::UnexpectedToken, &msg); false } - pub(super) fn error(&mut self, message: impl Into) { + /// Returns the suppression span for the current context. + /// + /// If inside a delimiter (tree/seq/alt), returns a span from the delimiter's + /// start to the end of source. This ensures all errors within the same + /// delimiter context can suppress each other based on priority. + /// At root level, returns the current token's span. + pub(super) fn current_suppression_span(&self) -> TextRange { + self.delimiter_stack + .last() + .map(|d| { + let source_end = TextSize::from(self.source.len() as u32); + TextRange::new(d.span.start(), source_end) + }) + .unwrap_or_else(|| self.current_span()) + } + + fn error_impl(&mut self, kind: DiagnosticKind, message: Option<&str>) { let range = self.current_span(); let pos = range.start(); if self.last_diagnostic_pos == Some(pos) { return; } self.last_diagnostic_pos = Some(pos); - self.diagnostics.error(message, range).emit(); + + let suppression = self.current_suppression_span(); + let builder = self.diagnostics.report(kind, range); + let builder = match message { + Some(msg) => builder.message(msg), + None => builder, + }; + builder.suppression_range(suppression).emit(); } - pub(super) fn error_and_bump(&mut self, message: &str) { - self.error(message); + fn bump_as_error(&mut self) { if !self.eof() { self.start_node(SyntaxKind::Error); self.bump(); @@ -277,15 +300,40 @@ impl<'src> Parser<'src> { } } + /// Emit diagnostic with default message for the kind. + pub(super) fn error(&mut self, kind: DiagnosticKind) { + self.error_impl(kind, None); + } + + /// Emit diagnostic with custom message detail. + pub(super) fn error_msg(&mut self, kind: DiagnosticKind, message: &str) { + self.error_impl(kind, Some(message)); + } + + pub(super) fn error_and_bump(&mut self, kind: DiagnosticKind) { + self.error(kind); + self.bump_as_error(); + } + + pub(super) fn error_and_bump_msg(&mut self, kind: DiagnosticKind, message: &str) { + self.error_msg(kind, message); + self.bump_as_error(); + } + #[allow(dead_code)] - pub(super) fn error_recover(&mut self, message: &str, recovery: TokenSet) { + pub(super) fn error_recover( + &mut self, + kind: DiagnosticKind, + message: &str, + recovery: TokenSet, + ) { if self.at_set(recovery) || self.should_stop() { - self.error(message); + self.error_msg(kind, message); return; } self.start_node(SyntaxKind::Error); - self.error(message); + self.error_msg(kind, message); while !self.at_set(recovery) && !self.should_stop() { self.bump(); } @@ -352,21 +400,27 @@ impl<'src> Parser<'src> { self.delimiter_stack.pop() } - pub(super) fn error_with_related( + /// Error for unclosed delimiters - uses full span from opening to current position. + /// This enables proper cascading error suppression. + pub(super) fn error_unclosed_delimiter( &mut self, + kind: DiagnosticKind, message: impl Into, related_msg: impl Into, - related_range: TextRange, + open_range: TextRange, ) { - let range = self.current_span(); - let pos = range.start(); + let current = self.current_span(); + let pos = current.start(); if self.last_diagnostic_pos == Some(pos) { return; } self.last_diagnostic_pos = Some(pos); + // Full span from opening delimiter to current position for suppression + let full_range = TextRange::new(open_range.start(), current.end()); self.diagnostics - .error(message, range) - .related_to(related_msg, related_range) + .report(kind, full_range) + .message(message) + .related_to(related_msg, open_range) .emit(); } @@ -381,6 +435,7 @@ impl<'src> Parser<'src> { pub(super) fn error_with_fix( &mut self, + kind: DiagnosticKind, range: TextRange, message: impl Into, fix_description: impl Into, @@ -392,7 +447,8 @@ impl<'src> Parser<'src> { } self.last_diagnostic_pos = Some(pos); self.diagnostics - .error(message, range) + .report(kind, range) + .message(message) .fix(fix_description, fix_replacement) .emit(); } diff --git a/crates/plotnik-lib/src/parser/grammar.rs b/crates/plotnik-lib/src/parser/grammar.rs index 86d0b807..5513b4b9 100644 --- a/crates/plotnik-lib/src/parser/grammar.rs +++ b/crates/plotnik-lib/src/parser/grammar.rs @@ -13,6 +13,7 @@ use super::cst::token_sets::{ }; use super::cst::{SyntaxKind, TokenSet}; use super::lexer::token_text; +use crate::diagnostics::DiagnosticKind; impl Parser<'_> { pub fn parse_root(&mut self) { @@ -43,13 +44,8 @@ impl Parser<'_> { for span in &unnamed_def_spans[..unnamed_def_spans.len() - 1] { let def_text = &self.source[usize::from(span.start())..usize::from(span.end())]; self.diagnostics - .error( - format!( - "unnamed definition must be last in file; add a name: `Name = {}`", - def_text.trim() - ), - *span, - ) + .report(DiagnosticKind::UnnamedDefNotLast, *span) + .message(format!("give it a name like `Name = {}`", def_text.trim())) .emit(); } } @@ -74,7 +70,10 @@ impl Parser<'_> { if EXPR_FIRST.contains(self.peek()) { self.parse_expr(); } else { - self.error("expected expression after '=' in named definition"); + self.error_msg( + DiagnosticKind::ExpectedExpression, + "after `=` in definition", + ); } self.finish_node(); @@ -88,16 +87,15 @@ impl Parser<'_> { self.parse_expr(); true } else if kind == SyntaxKind::At { - self.error_and_bump("capture '@' must follow an expression to capture"); + self.error_and_bump(DiagnosticKind::CaptureWithoutTarget); false } else if kind == SyntaxKind::Predicate { - self.error_and_bump( - "tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported", - ); + self.error_and_bump(DiagnosticKind::UnsupportedPredicate); false } else { - self.error_and_bump( - "unexpected token; expected an expression like (node), [choice], {sequence}, \"literal\", or _", + self.error_and_bump_msg( + DiagnosticKind::UnexpectedToken, + "try `(node)`, `[a b]`, `{a b}`, `\"literal\"`, or `_`", ); false } @@ -136,12 +134,10 @@ impl Parser<'_> { SyntaxKind::Negation => self.parse_negated_field(), SyntaxKind::Id => self.parse_tree_or_field(), SyntaxKind::KwError | SyntaxKind::KwMissing => { - self.error_and_bump( - "ERROR and MISSING must be inside parentheses: (ERROR) or (MISSING ...)", - ); + self.error_and_bump(DiagnosticKind::ErrorMissingOutsideParens); } _ => { - self.error_and_bump("unexpected token; expected an expression"); + self.error_and_bump_msg(DiagnosticKind::UnexpectedToken, "not a valid expression"); } } @@ -166,7 +162,7 @@ impl Parser<'_> { match self.peek() { SyntaxKind::ParenClose => { self.start_node_at(checkpoint, SyntaxKind::Tree); - self.error("empty tree expression - expected node type or children"); + self.error(DiagnosticKind::EmptyTree); self.pop_delimiter(); self.bump(); // consume ')' self.finish_node(); @@ -191,7 +187,7 @@ impl Parser<'_> { if self.peek() == SyntaxKind::Slash { if is_ref { self.start_node_at(checkpoint, SyntaxKind::Tree); - self.error("references cannot use supertype syntax (/)"); + self.error(DiagnosticKind::InvalidSupertypeSyntax); is_ref = false; } self.bump(); @@ -203,8 +199,9 @@ impl Parser<'_> { self.bump_string_tokens(); } _ => { - self.error( - "expected subtype after '/' (e.g., expression/binary_expression)", + self.error_msg( + DiagnosticKind::ExpectedSubtype, + "e.g., `expression/binary_expression`", ); } } @@ -214,7 +211,7 @@ impl Parser<'_> { self.start_node_at(checkpoint, SyntaxKind::Tree); self.bump(); if self.peek() != SyntaxKind::ParenClose { - self.error("(ERROR) takes no arguments"); + self.error(DiagnosticKind::ErrorTakesNoArguments); self.parse_children(SyntaxKind::ParenClose, TREE_RECOVERY); } self.pop_delimiter(); @@ -258,10 +255,8 @@ impl Parser<'_> { if let Some(name) = &ref_name { self.diagnostics - .error( - format!("reference `{}` cannot contain children", name), - children_span, - ) + .report(DiagnosticKind::RefCannotHaveChildren, children_span) + .message(name) .emit(); } } else if is_ref { @@ -286,22 +281,27 @@ impl Parser<'_> { fn parse_children(&mut self, until: SyntaxKind, recovery: TokenSet) { loop { if self.eof() { - let (construct, delim) = match until { - SyntaxKind::ParenClose => ("tree", "')'"), - SyntaxKind::BraceClose => ("sequence", "'}'"), + let (construct, delim, kind) = match until { + SyntaxKind::ParenClose => ("tree", "`)`", DiagnosticKind::UnclosedTree), + SyntaxKind::BraceClose => ("sequence", "`}`", DiagnosticKind::UnclosedSequence), _ => panic!( "parse_children: unexpected delimiter {:?} (only ParenClose/BraceClose supported)", until ), }; - let msg = format!("unclosed {construct}; expected {delim}"); + 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_with_related(msg, format!("{construct} started here"), open.span); + self.error_unclosed_delimiter( + kind, + msg, + format!("{construct} started here"), + open.span, + ); break; } if self.has_fatal_error() { @@ -320,16 +320,15 @@ impl Parser<'_> { continue; } if kind == SyntaxKind::Predicate { - self.error_and_bump( - "tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported", - ); + self.error_and_bump(DiagnosticKind::UnsupportedPredicate); continue; } if recovery.contains(kind) { break; } - self.error_and_bump( - "unexpected token; expected a child expression or closing delimiter", + self.error_and_bump_msg( + DiagnosticKind::UnexpectedToken, + "not valid inside a node — try `(child)` or close with `)`", ); } } @@ -351,14 +350,19 @@ impl Parser<'_> { fn parse_alt_children(&mut self) { loop { if self.eof() { - let msg = "unclosed alternation; expected ']'"; + 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 \ (caller must push delimiter before calling)" ) }); - self.error_with_related(msg, "alternation started here", open.span); + self.error_unclosed_delimiter( + DiagnosticKind::UnclosedAlternation, + msg, + "alternation started here", + open.span, + ); break; } if self.has_fatal_error() { @@ -393,8 +397,9 @@ impl Parser<'_> { if ALT_RECOVERY.contains(kind) { break; } - self.error_and_bump( - "unexpected token; expected a child expression or closing delimiter", + self.error_and_bump_msg( + DiagnosticKind::UnexpectedToken, + "not valid inside alternation — try `(node)` or close with `]`", ); } } @@ -414,7 +419,7 @@ impl Parser<'_> { if EXPR_FIRST.contains(self.peek()) { self.parse_expr(); } else { - self.error("expected expression after branch label"); + self.error_msg(DiagnosticKind::ExpectedExpression, "after `Label:`"); } self.finish_node(); @@ -429,9 +434,10 @@ impl Parser<'_> { let capitalized = capitalize_first(label_text); self.error_with_fix( + DiagnosticKind::LowercaseBranchLabel, span, - "tagged alternation labels must be Capitalized (they map to enum variants)", - format!("capitalize as `{}`", capitalized), + "branch labels map to enum variants", + format!("use `{}`", capitalized), capitalized, ); @@ -442,7 +448,7 @@ impl Parser<'_> { if EXPR_FIRST.contains(self.peek()) { self.parse_expr(); } else { - self.error("expected expression after branch label"); + self.error_msg(DiagnosticKind::ExpectedExpression, "after `label:`"); } self.finish_node(); @@ -494,7 +500,7 @@ impl Parser<'_> { self.bump(); // consume At if self.peek() != SyntaxKind::Id { - self.error("expected capture name after '@'"); + self.error(DiagnosticKind::ExpectedCaptureName); return; } @@ -524,7 +530,10 @@ impl Parser<'_> { self.bump(); self.validate_type_name(text, span); } else { - self.error("expected type name after '::' (e.g., ::MyType or ::string)"); + self.error_msg( + DiagnosticKind::ExpectedTypeName, + "e.g., `::MyType` or `::string`", + ); } self.finish_node(); @@ -540,9 +549,10 @@ impl Parser<'_> { let span = self.current_span(); self.error_with_fix( + DiagnosticKind::InvalidTypeAnnotationSyntax, span, - "single colon is not valid for type annotations", - "use '::'", + "single `:` looks like a field", + "use `::`", "::", ); @@ -569,7 +579,7 @@ impl Parser<'_> { self.expect(SyntaxKind::Negation, "'!' for negated field"); if self.peek() != SyntaxKind::Id { - self.error("expected field name after '!' (e.g., !value)"); + self.error_msg(DiagnosticKind::ExpectedFieldName, "e.g., `!value`"); self.finish_node(); return; } @@ -590,8 +600,9 @@ impl Parser<'_> { self.parse_field_equals_typo(); } else { // Bare identifiers are not valid expressions; trees require parentheses - self.error_and_bump( - "bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier)", + self.error_and_bump_msg( + DiagnosticKind::BareIdentifier, + "wrap in parentheses: `(identifier)`", ); } } @@ -615,7 +626,7 @@ impl Parser<'_> { if EXPR_FIRST.contains(self.peek()) { self.parse_expr_no_suffix(); } else { - self.error("expected expression after field name"); + self.error_msg(DiagnosticKind::ExpectedExpression, "after `field:`"); } self.finish_node(); @@ -629,9 +640,10 @@ impl Parser<'_> { self.peek(); let span = self.current_span(); self.error_with_fix( + DiagnosticKind::InvalidFieldEquals, span, - "'=' is not valid for field constraints", - "use ':'", + "this isn't a definition", + "use `:`", ":", ); self.bump(); @@ -639,7 +651,7 @@ impl Parser<'_> { if EXPR_FIRST.contains(self.peek()) { self.parse_expr(); } else { - self.error("expected expression after field name"); + self.error_msg(DiagnosticKind::ExpectedExpression, "after `field =`"); } self.finish_node(); @@ -659,12 +671,10 @@ impl Parser<'_> { ), }; self.error_with_fix( + DiagnosticKind::InvalidSeparator, span, - format!( - "'{}' is not valid syntax; plotnik uses whitespace for separation", - char_name - ), - "remove separator", + format!("plotnik uses whitespace, not `{}`", char_name), + "remove", "", ); self.skip_token(); @@ -694,9 +704,10 @@ impl Parser<'_> { let suggested = name.replace(['.', '-'], "_"); let suggested = to_snake_case(&suggested); self.error_with_fix( + DiagnosticKind::CaptureNameHasDots, span, - "capture names cannot contain dots", - format!("captures become struct fields; use @{} instead", suggested), + "captures become struct fields", + format!("use `@{}`", suggested), suggested, ); return; @@ -706,9 +717,10 @@ impl Parser<'_> { let suggested = name.replace('-', "_"); let suggested = to_snake_case(&suggested); self.error_with_fix( + DiagnosticKind::CaptureNameHasHyphens, span, - "capture names cannot contain hyphens", - format!("captures become struct fields; use @{} instead", suggested), + "captures become struct fields", + format!("use `@{}`", suggested), suggested, ); return; @@ -717,12 +729,10 @@ impl Parser<'_> { if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { let suggested = to_snake_case(name); self.error_with_fix( + DiagnosticKind::CaptureNameUppercase, span, - "capture names must start with lowercase", - format!( - "capture names must be snake_case; use @{} instead", - suggested - ), + "captures become struct fields", + format!("use `@{}`", suggested), suggested, ); } @@ -733,12 +743,10 @@ impl Parser<'_> { if !name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { let suggested = to_pascal_case(name); self.error_with_fix( + DiagnosticKind::DefNameLowercase, span, - "definition names must start with uppercase", - format!( - "definition names must be PascalCase; use {} instead", - suggested - ), + "definitions map to types", + format!("use `{}`", suggested), suggested, ); return; @@ -747,12 +755,10 @@ impl Parser<'_> { if name.contains('_') || name.contains('-') || name.contains('.') { let suggested = to_pascal_case(name); self.error_with_fix( + DiagnosticKind::DefNameHasSeparators, span, - "definition names cannot contain separators", - format!( - "definition names must be PascalCase; use {} instead", - suggested - ), + "definitions map to types", + format!("use `{}`", suggested), suggested, ); } @@ -763,12 +769,10 @@ impl Parser<'_> { if name.contains('_') || name.contains('-') || name.contains('.') { let suggested = to_pascal_case(name); self.error_with_fix( + DiagnosticKind::BranchLabelHasSeparators, span, - "branch labels cannot contain separators", - format!( - "branch labels must be PascalCase; use {}: instead", - suggested - ), + "branch labels map to enum variants", + format!("use `{}:`", suggested), format!("{}:", suggested), ); } @@ -780,9 +784,10 @@ impl Parser<'_> { let suggested = name.replace(['.', '-'], "_"); let suggested = to_snake_case(&suggested); self.error_with_fix( + DiagnosticKind::FieldNameHasDots, span, - "field names cannot contain dots", - format!("field names must be snake_case; use {}: instead", suggested), + "field names become struct fields", + format!("use `{}:`", suggested), format!("{}:", suggested), ); return; @@ -792,9 +797,10 @@ impl Parser<'_> { let suggested = name.replace('-', "_"); let suggested = to_snake_case(&suggested); self.error_with_fix( + DiagnosticKind::FieldNameHasHyphens, span, - "field names cannot contain hyphens", - format!("field names must be snake_case; use {}: instead", suggested), + "field names become struct fields", + format!("use `{}:`", suggested), format!("{}:", suggested), ); return; @@ -803,9 +809,10 @@ impl Parser<'_> { if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { let suggested = to_snake_case(name); self.error_with_fix( + DiagnosticKind::FieldNameUppercase, span, - "field names must start with lowercase", - format!("field names must be snake_case; use {}: instead", suggested), + "field names become struct fields", + format!("use `{}:`", suggested), format!("{}:", suggested), ); } @@ -816,12 +823,10 @@ impl Parser<'_> { if name.contains('.') || name.contains('-') { let suggested = to_pascal_case(name); self.error_with_fix( + DiagnosticKind::TypeNameInvalidChars, span, - "type names cannot contain dots or hyphens", - format!( - "type names cannot contain separators; use ::{} instead", - suggested - ), + "type annotations map to types", + format!("use `::{}`", suggested), format!("::{}", suggested), ); } 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 f1418da7..52c33c7f 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs @@ -131,18 +131,15 @@ fn named_def_missing_equals_with_garbage() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | Expr ^^^ (identifier) - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - | - 1 | Expr ^^^ (identifier) - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unnamed definition must be last in file; add a name: `Name = Expr` + | ^^^^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | Expr ^^^ (identifier) - | ^^^^ unnamed definition must be last in file; add a name: `Name = Expr` + | ^^^ "#); } @@ -156,14 +153,15 @@ fn named_def_missing_equals_recovers_to_next_def() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | Broken ^^^ - | ^^^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^^^^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | Broken ^^^ - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "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 ea5c05fe..9bf981a2 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/incomplete_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/incomplete_tests.rs @@ -10,10 +10,10 @@ fn missing_capture_name() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected capture name after '@' + error: expected name after `@` | 1 | (identifier) @ - | ^ expected capture name after '@' + | ^ "); } @@ -26,10 +26,10 @@ fn missing_field_value() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected expression after field name + error: expected an expression; after `field:` | 1 | (call name:) - | ^ expected expression after field name + | ^ "); } @@ -40,10 +40,10 @@ fn named_def_eof_after_equals() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected expression after '=' in named definition + error: expected an expression; after `=` in definition | 1 | Expr = - | ^ expected expression after '=' in named definition + | ^ "); } @@ -56,10 +56,10 @@ fn missing_type_name() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected type name after '::' (e.g., ::MyType or ::string) + error: expected type name after `::`; e.g., `::MyType` or `::string` | 1 | (identifier) @name :: - | ^ expected type name after '::' (e.g., ::MyType or ::string) + | ^ "); } @@ -72,10 +72,10 @@ fn missing_negated_field_name() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected field name after '!' (e.g., !value) + error: expected field name; e.g., `!value` | 1 | (call !) - | ^ expected field name after '!' (e.g., !value) + | ^ "); } @@ -88,10 +88,10 @@ fn missing_subtype() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected subtype after '/' (e.g., expression/binary_expression) + error: expected subtype after `/`; e.g., `expression/binary_expression` | 1 | (expression/) - | ^ expected subtype after '/' (e.g., expression/binary_expression) + | ^ "); } @@ -104,10 +104,10 @@ fn tagged_branch_missing_expression() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected expression after branch label + error: expected an expression; after `Label:` | 1 | [Label:] - | ^ expected expression after branch label + | ^ "); } @@ -118,10 +118,10 @@ fn type_annotation_missing_name_at_eof() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected type name after '::' (e.g., ::MyType or ::string) + error: expected type name after `::`; e.g., `::MyType` or `::string` | 1 | (a) @x :: - | ^ expected type name after '::' (e.g., ::MyType or ::string) + | ^ "); } @@ -132,10 +132,10 @@ fn type_annotation_missing_name_with_bracket() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected type name after '::' (e.g., ::MyType or ::string) + error: expected type name after `::`; e.g., `::MyType` or `::string` | 1 | [(a) @x :: ] - | ^ expected type name after '::' (e.g., ::MyType or ::string) + | ^ "); } @@ -148,20 +148,10 @@ fn type_annotation_invalid_token_after() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected type name after '::' (e.g., ::MyType or ::string) + error: expected type name after `::`; e.g., `::MyType` or `::string` | 1 | (identifier) @name :: ( - | ^ expected type name after '::' (e.g., ::MyType or ::string) - error: unclosed tree; expected ')' - | - 1 | (identifier) @name :: ( - | -^ unclosed tree; expected ')' - | | - | tree started here - error: unnamed definition must be last in file; add a name: `Name = (identifier) @name ::` - | - 1 | (identifier) @name :: ( - | ^^^^^^^^^^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (identifier) @name ::` + | ^ "); } @@ -174,10 +164,10 @@ fn field_value_is_garbage() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected expression after field name + error: expected an expression; after `field:` | 1 | (call name: %%%) - | ^^^ expected expression after field name + | ^^^ "); } @@ -190,10 +180,10 @@ fn capture_with_invalid_char() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected capture name after '@' + error: expected name after `@` | 1 | (identifier) @123 - | ^^^ expected capture name after '@' + | ^^^ "); } @@ -204,10 +194,10 @@ fn bare_capture_at_eof_triggers_sync() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture '@' must follow an expression to capture + error: `@` must follow an expression to capture | 1 | @ - | ^ capture '@' must follow an expression to capture + | ^ "); } @@ -220,14 +210,10 @@ fn bare_capture_at_root() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture '@' must follow an expression to capture + error: `@` must follow an expression to capture | 1 | @name - | ^ capture '@' must follow an expression to capture - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - | - 1 | @name - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "); } @@ -240,14 +226,10 @@ fn capture_at_start_of_alternation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter + error: unexpected token; not valid inside alternation — try `(node)` or close with `]` | 1 | [@x (a)] - | ^ unexpected token; expected a child expression or closing delimiter - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - | - 1 | [@x (a)] - | ^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "); } @@ -260,18 +242,15 @@ fn mixed_valid_invalid_captures() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture '@' must follow an expression to capture - | - 1 | (a) @ok @ @name - | ^ capture '@' must follow an expression to capture - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + error: `@` must follow an expression to capture | 1 | (a) @ok @ @name - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unnamed definition must be last in file; add a name: `Name = (a) @ok` + | ^ + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | (a) @ok @ @name - | ^^^^^^^ unnamed definition must be last in file; add a name: `Name = (a) @ok` + | ^^^^ "); } @@ -284,20 +263,21 @@ fn field_equals_typo_missing_value() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '=' is not valid for field constraints + error: use `:` for field constraints, not `=`; this isn't a definition | 1 | (call name = ) - | ^ '=' is not valid for field constraints + | ^ | - help: use ':' + help: use `:` | 1 - (call name = ) 1 + (call name : ) | - error: expected expression after field name + + error: expected an expression; after `field =` | 1 | (call name = ) - | ^ expected expression after field name + | ^ "); } @@ -308,19 +288,20 @@ fn lowercase_branch_label_missing_expression() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: tagged alternation labels must be Capitalized (they map to enum variants) + error: branch labels must be capitalized; branch labels map to enum variants | 1 | [label:] - | ^^^^^ tagged alternation labels must be Capitalized (they map to enum variants) + | ^^^^^ | - help: capitalize as `Label` + help: use `Label` | 1 - [label:] 1 + [Label:] | - error: expected expression after branch label + + error: expected an expression; after `label:` | 1 | [label:] - | ^ expected expression after branch 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 792e7ebb..deeb308b 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/unclosed_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/unclosed_tests.rs @@ -10,10 +10,10 @@ fn missing_paren() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed tree; expected ')' + error: missing closing `)`; expected `)` | 1 | (identifier - | - ^ unclosed tree; expected ')' + | -^^^^^^^^^^ | | | tree started here "); @@ -28,10 +28,10 @@ fn missing_bracket() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed alternation; expected ']' + error: missing closing `]`; expected `]` | 1 | [(identifier) (string) - | - ^ unclosed alternation; expected ']' + | -^^^^^^^^^^^^^^^^^^^^^ | | | alternation started here "); @@ -46,10 +46,10 @@ fn missing_brace() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed sequence; expected '}' + error: missing closing `}`; expected `}` | 1 | {(a) (b) - | - ^ unclosed sequence; expected '}' + | -^^^^^^^ | | | sequence started here "); @@ -64,10 +64,10 @@ fn nested_unclosed() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed tree; expected ')' + error: missing closing `)`; expected `)` | 1 | (a (b (c) - | - ^ unclosed tree; expected ')' + | -^^^^^ | | | tree started here "); @@ -82,10 +82,10 @@ fn deeply_nested_unclosed() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed tree; expected ')' + error: missing closing `)`; expected `)` | 1 | (a (b (c (d - | - ^ unclosed tree; expected ')' + | -^ | | | tree started here "); @@ -100,10 +100,10 @@ fn unclosed_alternation_nested() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed tree; expected ')' + error: missing closing `)`; expected `)` | 1 | [(a) (b - | - ^ unclosed tree; expected ')' + | -^ | | | tree started here "); @@ -118,10 +118,10 @@ fn empty_parens() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: empty tree expression - expected node type or children + error: empty parentheses are not allowed | 1 | () - | ^ empty tree expression - expected node type or children + | ^ "); } @@ -135,12 +135,14 @@ fn unclosed_tree_shows_open_location() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed tree; expected ')' + error: missing closing `)`; expected `)` | - 1 | (call - | - tree started here - 2 | (identifier) - | ^ unclosed tree; expected ')' + 1 | (call + | ^ tree started here + | _| + | | + 2 | | (identifier) + | |_________________^ "); } @@ -155,13 +157,15 @@ fn unclosed_alternation_shows_open_location() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed alternation; expected ']' + error: missing closing `]`; expected `]` | - 1 | [ - | - alternation started here - 2 | (a) - 3 | (b) - | ^ unclosed alternation; expected ']' + 1 | [ + | ^ alternation started here + | _| + | | + 2 | | (a) + 3 | | (b) + | |________^ "); } @@ -176,13 +180,15 @@ fn unclosed_sequence_shows_open_location() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unclosed sequence; expected '}' + error: missing closing `}`; expected `}` | - 1 | { - | - sequence started here - 2 | (a) - 3 | (b) - | ^ unclosed sequence; expected '}' + 1 | { + | ^ sequence started here + | _| + | | + 2 | | (a) + 3 | | (b) + | |________^ "); } @@ -193,14 +199,10 @@ fn unclosed_double_quote_string() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected a child expression or closing delimiter + error: missing closing `)`; expected `)` | 1 | (call "foo) - | ^^^^^ unexpected token; expected a child expression or closing delimiter - error: unclosed tree; expected ')' - | - 1 | (call "foo) - | - ^ unclosed tree; expected ')' + | -^^^^^^^^^^ | | | tree started here "#); @@ -213,14 +215,10 @@ fn unclosed_single_quote_string() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter - | - 1 | (call 'foo) - | ^^^^^ unexpected token; expected a child expression or closing delimiter - error: unclosed tree; expected ')' + error: missing closing `)`; expected `)` | 1 | (call 'foo) - | - ^ unclosed tree; expected ')' + | -^^^^^^^^^^ | | | tree 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 a6fd39e0..540d8a31 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/unexpected_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/unexpected_tests.rs @@ -10,14 +10,10 @@ fn unexpected_token() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | (identifier) ^^^ (string) - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unnamed definition must be last in file; add a name: `Name = (identifier)` - | - 1 | (identifier) ^^^ (string) - | ^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (identifier)` + | ^^^ "#); } @@ -30,10 +26,10 @@ fn multiple_consecutive_garbage() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | ^^^ $$$ %%% (ok) - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^ "#); } @@ -46,10 +42,10 @@ fn garbage_at_start() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | ^^^ (a) - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^ "#); } @@ -62,10 +58,10 @@ fn only_garbage() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | ^^^ $$$ - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^ "#); } @@ -78,10 +74,10 @@ fn garbage_inside_alternation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter + error: unexpected token; not valid inside alternation — try `(node)` or close with `]` | 1 | [(a) ^^^ (b)] - | ^^^ unexpected token; expected a child expression or closing delimiter + | ^^^ "); } @@ -94,18 +90,10 @@ fn garbage_inside_node() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: expected capture name after '@' + error: expected name after `@` | 1 | (a (b) @@@ (c)) (d) - | ^ expected capture name after '@' - error: unexpected token; expected a child expression or closing delimiter - | - 1 | (a (b) @@@ (c)) (d) - | ^ unexpected token; expected a child expression or closing delimiter - error: unnamed definition must be last in file; add a name: `Name = (a (b) @@@ (c))` - | - 1 | (a (b) @@@ (c)) (d) - | ^^^^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (a (b) @@@ (c))` + | ^ "); } @@ -118,14 +106,15 @@ fn xml_tag_garbage() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 |
(identifier)
- | ^^^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^^^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 |
(identifier)
- | ^^^^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^^^^ "#); } @@ -138,10 +127,10 @@ fn xml_self_closing() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 |
(a) - | ^^^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^^^ "#); } @@ -154,22 +143,20 @@ fn predicate_unsupported() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported - | - 1 | (a (#eq? @x "foo") b) - | ^^^^ tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported - error: unexpected token; expected a child expression or closing delimiter + error: predicates like `#match?` are not supported | 1 | (a (#eq? @x "foo") b) - | ^ unexpected token; expected a child expression or closing delimiter - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^^^^ + + error: unexpected token; not valid inside a node — try `(child)` or close with `)` | 1 | (a (#eq? @x "foo") b) - | ^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | (a (#eq? @x "foo") b) - | ^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "#); } @@ -182,22 +169,15 @@ fn predicate_match() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported + error: predicates like `#match?` are not supported | 1 | (identifier) #match? @name "test" - | ^^^^^^^ tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - | - 1 | (identifier) #match? @name "test" - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unnamed definition must be last in file; add a name: `Name = (identifier)` - | - 1 | (identifier) #match? @name "test" - | ^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (identifier)` - error: unnamed definition must be last in file; add a name: `Name = name` + | ^^^^^^^ + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | (identifier) #match? @name "test" - | ^^^^ unnamed definition must be last in file; add a name: `Name = name` + | ^^^^ "#); } @@ -208,18 +188,15 @@ fn predicate_in_tree() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported - | - 1 | (function #eq? @name "test") - | ^^^^ tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported - error: unexpected token; expected a child expression or closing delimiter + error: predicates like `#match?` are not supported | 1 | (function #eq? @name "test") - | ^ unexpected token; expected a child expression or closing delimiter - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^^^^ + + error: unexpected token; not valid inside a node — try `(child)` or close with `)` | 1 | (function #eq? @name "test") - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "#); } @@ -232,10 +209,10 @@ fn predicate_in_alternation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter + error: unexpected token; not valid inside alternation — try `(node)` or close with `]` | 1 | [(a) #eq? (b)] - | ^^^^ unexpected token; expected a child expression or closing delimiter + | ^^^^ "); } @@ -248,10 +225,10 @@ fn predicate_in_sequence() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported + error: predicates like `#match?` are not supported | 1 | {(a) #set! (b)} - | ^^^^^ tree-sitter predicates (#eq?, #match?, #set!, etc.) are not supported + | ^^^^^ "); } @@ -266,14 +243,15 @@ fn multiline_garbage_recovery() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter + error: unexpected token; not valid inside a node — try `(child)` or close with `)` | 2 | ^^^ - | ^^^ unexpected token; expected a child expression or closing delimiter - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^^^ + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 3 | b) - | ^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "); } @@ -286,10 +264,10 @@ fn top_level_garbage_recovery() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | Expr = (a) ^^^ Expr2 = (b) - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^ "#); } @@ -306,14 +284,15 @@ fn multiple_definitions_with_garbage_between() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 2 | ^^^ - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 4 | $$$ - | ^^^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^^^ "#); } @@ -326,18 +305,15 @@ fn alternation_recovery_to_capture() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter - | - 1 | [^^^ @name] - | ^^^ unexpected token; expected a child expression or closing delimiter - error: unexpected token; expected a child expression or closing delimiter + error: unexpected token; not valid inside alternation — try `(node)` or close with `]` | 1 | [^^^ @name] - | ^ unexpected token; expected a child expression or closing delimiter - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^^^ + + error: unexpected token; not valid inside alternation — try `(node)` or close with `]` | 1 | [^^^ @name] - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "); } @@ -350,10 +326,10 @@ fn comma_between_defs() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | A = (a), B = (b) - | ^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + | ^ "#); } @@ -364,10 +340,10 @@ fn bare_colon_in_tree() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unexpected token; expected a child expression or closing delimiter + error: unexpected token; not valid inside a node — try `(child)` or close with `)` | 1 | (a : (b)) - | ^ unexpected token; expected a child expression or closing delimiter + | ^ "); } @@ -378,18 +354,15 @@ fn paren_close_inside_alternation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: expected closing ']' for alternation - | - 1 | [(a) ) (b)] - | ^ expected closing ']' for alternation - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; expected closing ']' for alternation | 1 | [(a) ) (b)] - | ^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unnamed definition must be last in file; add a name: `Name = [(a)` + | ^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | [(a) ) (b)] - | ^^^^ unnamed definition must be last in file; add a name: `Name = [(a)` + | ^ "#); } @@ -400,18 +373,15 @@ fn bracket_close_inside_sequence() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: expected closing '}' for sequence + error: unexpected token; expected closing '}' for sequence | 1 | {(a) ] (b)} - | ^ expected closing '}' for sequence - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - | - 1 | {(a) ] (b)} - | ^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unnamed definition must be last in file; add a name: `Name = {(a)` + | ^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | {(a) ] (b)} - | ^^^^ unnamed definition must be last in file; add a name: `Name = {(a)` + | ^ "#); } @@ -422,18 +392,15 @@ fn paren_close_inside_sequence() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: expected closing '}' for sequence - | - 1 | {(a) ) (b)} - | ^ expected closing '}' for sequence - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; expected closing '}' for sequence | 1 | {(a) ) (b)} - | ^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unnamed definition must be last in file; add a name: `Name = {(a)` + | ^ + + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | {(a) ) (b)} - | ^^^^ unnamed definition must be last in file; add a name: `Name = {(a)` + | ^ "#); } @@ -444,14 +411,10 @@ fn single_colon_type_annotation_followed_by_non_id() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - | - 1 | (a) @x : (b) - | ^ unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ - error: unnamed definition must be last in file; add a name: `Name = (a) @x` + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | (a) @x : (b) - | ^^^^^^ unnamed definition must be last in file; add a name: `Name = (a) @x` + | ^ "#); } @@ -462,9 +425,9 @@ fn single_colon_type_annotation_at_eof() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" - error: unexpected token; expected an expression like (node), [choice], {sequence}, "literal", or _ + error: unexpected token; try `(node)`, `[a b]`, `{a b}`, `"literal"`, or `_` | 1 | (a) @x : - | ^ unexpected token; expected an expression like (node), [choice], {sequence}, "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 fd4a4361..daa40531 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/validation_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/validation_tests.rs @@ -11,10 +11,10 @@ fn ref_with_children_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: reference `Expr` cannot contain children + error: `Expr` is a reference and cannot have children | 2 | (Expr (child)) - | ^^^^^^^ reference `Expr` cannot contain children + | ^^^^^^^ "); } @@ -28,10 +28,10 @@ fn ref_with_multiple_children_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: reference `Expr` cannot contain children + error: `Expr` is a reference and cannot have children | 2 | (Expr (a) (b) @cap) - | ^^^^^^^^^^^^ reference `Expr` cannot contain children + | ^^^^^^^^^^^^ "); } @@ -45,10 +45,10 @@ fn ref_with_field_children_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: reference `Expr` cannot contain children + error: `Expr` is a reference and cannot have children | 2 | (Expr name: (identifier)) - | ^^^^^^^^^^^^^^^^^^ reference `Expr` cannot contain children + | ^^^^^^^^^^^^^^^^^^ "); } @@ -59,10 +59,10 @@ fn reference_with_supertype_syntax_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: references cannot use supertype syntax (/) + error: supertype syntax not allowed on references | 1 | (RefName/subtype) - | ^ references cannot use supertype syntax (/) + | ^ "); } @@ -75,10 +75,10 @@ fn mixed_tagged_and_untagged() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: mixed tagged and untagged branches in alternation + error: cannot mix labeled and unlabeled branches | 1 | [Tagged: (a) (b) Another: (c)] - | ------ ^^^ mixed tagged and untagged branches in alternation + | ------ ^^^ | | | tagged branch here "); @@ -93,10 +93,10 @@ fn error_with_unexpected_content() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: (ERROR) takes no arguments + error: `(ERROR)` cannot have child nodes | 1 | (ERROR (something)) - | ^ (ERROR) takes no arguments + | ^ "); } @@ -109,10 +109,10 @@ fn bare_error_keyword() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: ERROR and MISSING must be inside parentheses: (ERROR) or (MISSING ...) + error: `ERROR` and `MISSING` must be wrapped in parentheses | 1 | ERROR - | ^^^^^ ERROR and MISSING must be inside parentheses: (ERROR) or (MISSING ...) + | ^^^^^ "); } @@ -125,10 +125,10 @@ fn bare_missing_keyword() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: ERROR and MISSING must be inside parentheses: (ERROR) or (MISSING ...) + error: `ERROR` and `MISSING` must be wrapped in parentheses | 1 | MISSING - | ^^^^^^^ ERROR and MISSING must be inside parentheses: (ERROR) or (MISSING ...) + | ^^^^^^^ "); } @@ -141,14 +141,15 @@ fn upper_ident_in_alternation_not_followed_by_colon() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: undefined reference: `Expr` + error: `Expr` is not defined | 1 | [(Expr) (Statement)] - | ^^^^ undefined reference: `Expr` - error: undefined reference: `Statement` + | ^^^^ + + error: `Statement` is not defined | 1 | [(Expr) (Statement)] - | ^^^^^^^^^ undefined reference: `Statement` + | ^^^^^^^^^ "); } @@ -161,10 +162,10 @@ fn upper_ident_not_followed_by_equals_is_expression() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: undefined reference: `Expr` + error: `Expr` is not defined | 1 | (Expr) - | ^^^^ undefined reference: `Expr` + | ^^^^ "); } @@ -177,10 +178,10 @@ fn bare_upper_ident_not_followed_by_equals_is_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | Expr - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^^^^ "); } @@ -193,14 +194,10 @@ fn named_def_missing_equals() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - | - 1 | Expr (identifier) - | ^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unnamed definition must be last in file; add a name: `Name = Expr` + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | Expr (identifier) - | ^^^^ unnamed definition must be last in file; add a name: `Name = Expr` + | ^^^^ "); } @@ -215,10 +212,10 @@ fn unnamed_def_not_allowed_in_middle() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unnamed definition must be last in file; add a name: `Name = (first)` + error: only the last definition can be unnamed — give it a name like `Name = (first)` | 1 | (first) - | ^^^^^^^ unnamed definition must be last in file; add a name: `Name = (first)` + | ^^^^^^^ "); } @@ -233,14 +230,15 @@ fn multiple_unnamed_defs_errors_for_all_but_last() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unnamed definition must be last in file; add a name: `Name = (first)` + error: only the last definition can be unnamed — give it a name like `Name = (first)` | 1 | (first) - | ^^^^^^^ unnamed definition must be last in file; add a name: `Name = (first)` - error: unnamed definition must be last in file; add a name: `Name = (second)` + | ^^^^^^^ + + error: only the last definition can be unnamed — give it a name like `Name = (second)` | 2 | (second) - | ^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (second)` + | ^^^^^^^^ "); } @@ -253,14 +251,15 @@ fn capture_space_after_dot_is_anchor() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: unnamed definition must be last in file; add a name: `Name = (identifier) @foo` + error: only the last definition can be unnamed — give it a name like `Name = (identifier) @foo` | 1 | (identifier) @foo . (other) - | ^^^^^^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (identifier) @foo` - error: unnamed definition must be last in file; add a name: `Name = .` + | ^^^^^^^^^^^^^^^^^ + + error: only the last definition can be unnamed — give it a name like `Name = .` | 1 | (identifier) @foo . (other) - | ^ unnamed definition must be last in file; add a name: `Name = .` + | ^ "); } @@ -271,12 +270,12 @@ fn def_name_lowercase_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: definition names must start with uppercase + error: definition names must start uppercase; definitions map to types | 1 | lowercase = (x) - | ^^^^^^^^^ definition names must start with uppercase + | ^^^^^^^^^ | - help: definition names must be PascalCase; use Lowercase instead + help: use `Lowercase` | 1 - lowercase = (x) 1 + Lowercase = (x) @@ -293,12 +292,12 @@ fn def_name_snake_case_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: definition names must start with uppercase + error: definition names must start uppercase; definitions map to types | 1 | my_expr = (identifier) - | ^^^^^^^ definition names must start with uppercase + | ^^^^^^^ | - help: definition names must be PascalCase; use MyExpr instead + help: use `MyExpr` | 1 - my_expr = (identifier) 1 + MyExpr = (identifier) @@ -315,12 +314,12 @@ fn def_name_kebab_case_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: definition names must start with uppercase + error: definition names must start uppercase; definitions map to types | 1 | my-expr = (identifier) - | ^^^^^^^ definition names must start with uppercase + | ^^^^^^^ | - help: definition names must be PascalCase; use MyExpr instead + help: use `MyExpr` | 1 - my-expr = (identifier) 1 + MyExpr = (identifier) @@ -337,12 +336,12 @@ fn def_name_dotted_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: definition names must start with uppercase + error: definition names must start uppercase; definitions map to types | 1 | my.expr = (identifier) - | ^^^^^^^ definition names must start with uppercase + | ^^^^^^^ | - help: definition names must be PascalCase; use MyExpr instead + help: use `MyExpr` | 1 - my.expr = (identifier) 1 + MyExpr = (identifier) @@ -357,12 +356,12 @@ fn def_name_with_underscores_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: definition names cannot contain separators + error: definition names must be PascalCase; definitions map to types | 1 | Some_Thing = (x) - | ^^^^^^^^^^ definition names cannot contain separators + | ^^^^^^^^^^ | - help: definition names must be PascalCase; use SomeThing instead + help: use `SomeThing` | 1 - Some_Thing = (x) 1 + SomeThing = (x) @@ -377,12 +376,12 @@ fn def_name_with_hyphens_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: definition names cannot contain separators + error: definition names must be PascalCase; definitions map to types | 1 | Some-Thing = (x) - | ^^^^^^^^^^ definition names cannot contain separators + | ^^^^^^^^^^ | - help: definition names must be PascalCase; use SomeThing instead + help: use `SomeThing` | 1 - Some-Thing = (x) 1 + SomeThing = (x) @@ -399,12 +398,12 @@ fn capture_name_pascal_case_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names must start with lowercase + error: capture names must be lowercase; captures become struct fields | 1 | (a) @Name - | ^^^^ capture names must start with lowercase + | ^^^^ | - help: capture names must be snake_case; use @name instead + help: use `@name` | 1 - (a) @Name 1 + (a) @name @@ -421,12 +420,12 @@ fn capture_name_pascal_case_with_hyphens_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain hyphens + error: capture names cannot contain `-`; captures become struct fields | 1 | (a) @My-Name - | ^^^^^^^ capture names cannot contain hyphens + | ^^^^^^^ | - help: captures become struct fields; use @my_name instead + help: use `@my_name` | 1 - (a) @My-Name 1 + (a) @my_name @@ -443,12 +442,12 @@ fn capture_name_with_hyphens_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain hyphens + error: capture names cannot contain `-`; captures become struct fields | 1 | (a) @my-name - | ^^^^^^^ capture names cannot contain hyphens + | ^^^^^^^ | - help: captures become struct fields; use @my_name instead + help: use `@my_name` | 1 - (a) @my-name 1 + (a) @my_name @@ -465,12 +464,12 @@ fn capture_dotted_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain dots + error: capture names cannot contain `.`; captures become struct fields | 1 | (identifier) @foo.bar - | ^^^^^^^ capture names cannot contain dots + | ^^^^^^^ | - help: captures become struct fields; use @foo_bar instead + help: use `@foo_bar` | 1 - (identifier) @foo.bar 1 + (identifier) @foo_bar @@ -487,12 +486,12 @@ fn capture_dotted_multiple_parts() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain dots + error: capture names cannot contain `.`; captures become struct fields | 1 | (identifier) @foo.bar.baz - | ^^^^^^^^^^^ capture names cannot contain dots + | ^^^^^^^^^^^ | - help: captures become struct fields; use @foo_bar_baz instead + help: use `@foo_bar_baz` | 1 - (identifier) @foo.bar.baz 1 + (identifier) @foo_bar_baz @@ -509,20 +508,16 @@ fn capture_dotted_followed_by_field() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain dots + error: capture names cannot contain `.`; captures become struct fields | 1 | (node) @foo.bar name: (other) - | ^^^^^^^ capture names cannot contain dots + | ^^^^^^^ | - help: captures become struct fields; use @foo_bar instead + help: use `@foo_bar` | 1 - (node) @foo.bar name: (other) 1 + (node) @foo_bar name: (other) | - error: unnamed definition must be last in file; add a name: `Name = (node) @foo.bar` - | - 1 | (node) @foo.bar name: (other) - | ^^^^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (node) @foo.bar` "); } @@ -535,24 +530,21 @@ fn capture_space_after_dot_breaks_chain() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain dots + error: capture names cannot contain `.`; captures become struct fields | 1 | (identifier) @foo. bar - | ^^^^ capture names cannot contain dots + | ^^^^ | - help: captures become struct fields; use @foo_ instead + help: use `@foo_` | 1 - (identifier) @foo. bar 1 + (identifier) @foo_ bar | - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - | - 1 | (identifier) @foo. bar - | ^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unnamed definition must be last in file; add a name: `Name = (identifier) @foo.` + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | (identifier) @foo. bar - | ^^^^^^^^^^^^^^^^^^ unnamed definition must be last in file; add a name: `Name = (identifier) @foo.` + | ^^^ "); } @@ -565,12 +557,12 @@ fn capture_hyphenated_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain hyphens + error: capture names cannot contain `-`; captures become struct fields | 1 | (identifier) @foo-bar - | ^^^^^^^ capture names cannot contain hyphens + | ^^^^^^^ | - help: captures become struct fields; use @foo_bar instead + help: use `@foo_bar` | 1 - (identifier) @foo-bar 1 + (identifier) @foo_bar @@ -587,12 +579,12 @@ fn capture_hyphenated_multiple() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain hyphens + error: capture names cannot contain `-`; captures become struct fields | 1 | (identifier) @foo-bar-baz - | ^^^^^^^^^^^ capture names cannot contain hyphens + | ^^^^^^^^^^^ | - help: captures become struct fields; use @foo_bar_baz instead + help: use `@foo_bar_baz` | 1 - (identifier) @foo-bar-baz 1 + (identifier) @foo_bar_baz @@ -609,12 +601,12 @@ fn capture_mixed_dots_and_hyphens() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture names cannot contain dots + error: capture names cannot contain `.`; captures become struct fields | 1 | (identifier) @foo.bar-baz - | ^^^^^^^^^^^ capture names cannot contain dots + | ^^^^^^^^^^^ | - help: captures become struct fields; use @foo_bar_baz instead + help: use `@foo_bar_baz` | 1 - (identifier) @foo.bar-baz 1 + (identifier) @foo_bar_baz @@ -631,12 +623,12 @@ fn field_name_pascal_case_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field names must start with lowercase + error: field names must be lowercase; field names become struct fields | 1 | (call Name: (a)) - | ^^^^ field names must start with lowercase + | ^^^^ | - help: field names must be snake_case; use name: instead + help: use `name:` | 1 - (call Name: (a)) 1 + (call name:: (a)) @@ -651,12 +643,12 @@ fn field_name_with_dots_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field names cannot contain dots + error: field names cannot contain `.`; field names become struct fields | 1 | (call foo.bar: (x)) - | ^^^^^^^ field names cannot contain dots + | ^^^^^^^ | - help: field names must be snake_case; use foo_bar: instead + help: use `foo_bar:` | 1 - (call foo.bar: (x)) 1 + (call foo_bar:: (x)) @@ -671,12 +663,12 @@ fn field_name_with_hyphens_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field names cannot contain hyphens + error: field names cannot contain `-`; field names become struct fields | 1 | (call foo-bar: (x)) - | ^^^^^^^ field names cannot contain hyphens + | ^^^^^^^ | - help: field names must be snake_case; use foo_bar: instead + help: use `foo_bar:` | 1 - (call foo-bar: (x)) 1 + (call foo_bar:: (x)) @@ -693,12 +685,12 @@ fn negated_field_with_upper_ident_parses() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field names must start with lowercase + error: field names must be lowercase; field names become struct fields | 1 | (call !Arguments) - | ^^^^^^^^^ field names must start with lowercase + | ^^^^^^^^^ | - help: field names must be snake_case; use arguments: instead + help: use `arguments:` | 1 - (call !Arguments) 1 + (call !arguments:) @@ -715,12 +707,12 @@ fn branch_label_snake_case_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: branch labels cannot contain separators + error: branch labels must be PascalCase; branch labels map to enum variants | 1 | [My_branch: (a) Other: (b)] - | ^^^^^^^^^ branch labels cannot contain separators + | ^^^^^^^^^ | - help: branch labels must be PascalCase; use MyBranch: instead + help: use `MyBranch:` | 1 - [My_branch: (a) Other: (b)] 1 + [MyBranch:: (a) Other: (b)] @@ -737,12 +729,12 @@ fn branch_label_kebab_case_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: branch labels cannot contain separators + error: branch labels must be PascalCase; branch labels map to enum variants | 1 | [My-branch: (a) Other: (b)] - | ^^^^^^^^^ branch labels cannot contain separators + | ^^^^^^^^^ | - help: branch labels must be PascalCase; use MyBranch: instead + help: use `MyBranch:` | 1 - [My-branch: (a) Other: (b)] 1 + [MyBranch:: (a) Other: (b)] @@ -759,12 +751,12 @@ fn branch_label_dotted_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: branch labels cannot contain separators + error: branch labels must be PascalCase; branch labels map to enum variants | 1 | [My.branch: (a) Other: (b)] - | ^^^^^^^^^ branch labels cannot contain separators + | ^^^^^^^^^ | - help: branch labels must be PascalCase; use MyBranch: instead + help: use `MyBranch:` | 1 - [My.branch: (a) Other: (b)] 1 + [MyBranch:: (a) Other: (b)] @@ -779,12 +771,12 @@ fn branch_label_with_underscores_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: branch labels cannot contain separators + error: branch labels must be PascalCase; branch labels map to enum variants | 1 | [Some_Label: (x)] - | ^^^^^^^^^^ branch labels cannot contain separators + | ^^^^^^^^^^ | - help: branch labels must be PascalCase; use SomeLabel: instead + help: use `SomeLabel:` | 1 - [Some_Label: (x)] 1 + [SomeLabel:: (x)] @@ -799,12 +791,12 @@ fn branch_label_with_hyphens_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: branch labels cannot contain separators + error: branch labels must be PascalCase; branch labels map to enum variants | 1 | [Some-Label: (x)] - | ^^^^^^^^^^ branch labels cannot contain separators + | ^^^^^^^^^^ | - help: branch labels must be PascalCase; use SomeLabel: instead + help: use `SomeLabel:` | 1 - [Some-Label: (x)] 1 + [SomeLabel:: (x)] @@ -824,22 +816,23 @@ fn lowercase_branch_label() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: tagged alternation labels must be Capitalized (they map to enum variants) + error: branch labels must be capitalized; branch labels map to enum variants | 2 | left: (a) - | ^^^^ tagged alternation labels must be Capitalized (they map to enum variants) + | ^^^^ | - help: capitalize as `Left` + help: use `Left` | 2 - left: (a) 2 + Left: (a) | - error: tagged alternation labels must be Capitalized (they map to enum variants) + + error: branch labels must be capitalized; branch labels map to enum variants | 3 | right: (b) - | ^^^^^ tagged alternation labels must be Capitalized (they map to enum variants) + | ^^^^^ | - help: capitalize as `Right` + help: use `Right` | 3 - right: (b) 3 + Right: (b) @@ -856,12 +849,12 @@ fn lowercase_branch_label_suggests_capitalized() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: tagged alternation labels must be Capitalized (they map to enum variants) + error: branch labels must be capitalized; branch labels map to enum variants | 1 | [first: (a) Second: (b)] - | ^^^^^ tagged alternation labels must be Capitalized (they map to enum variants) + | ^^^^^ | - help: capitalize as `First` + help: use `First` | 1 - [first: (a) Second: (b)] 1 + [First: (a) Second: (b)] @@ -876,12 +869,12 @@ fn mixed_case_branch_labels() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: tagged alternation labels must be Capitalized (they map to enum variants) + error: branch labels must be capitalized; branch labels map to enum variants | 1 | [foo: (a) Bar: (b)] - | ^^^ tagged alternation labels must be Capitalized (they map to enum variants) + | ^^^ | - help: capitalize as `Foo` + help: use `Foo` | 1 - [foo: (a) Bar: (b)] 1 + [Foo: (a) Bar: (b)] @@ -898,12 +891,12 @@ fn type_annotation_dotted_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: type names cannot contain dots or hyphens + error: type names cannot contain `.` or `-`; type annotations map to types | 1 | (a) @x :: My.Type - | ^^^^^^^ type names cannot contain dots or hyphens + | ^^^^^^^ | - help: type names cannot contain separators; use ::MyType instead + help: use `::MyType` | 1 - (a) @x :: My.Type 1 + (a) @x :: ::MyType @@ -920,12 +913,12 @@ fn type_annotation_kebab_suggests_pascal() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: type names cannot contain dots or hyphens + error: type names cannot contain `.` or `-`; type annotations map to types | 1 | (a) @x :: My-Type - | ^^^^^^^ type names cannot contain dots or hyphens + | ^^^^^^^ | - help: type names cannot contain separators; use ::MyType instead + help: use `::MyType` | 1 - (a) @x :: My-Type 1 + (a) @x :: ::MyType @@ -940,12 +933,12 @@ fn type_name_with_dots_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: type names cannot contain dots or hyphens + error: type names cannot contain `.` or `-`; type annotations map to types | 1 | (x) @name :: Some.Type - | ^^^^^^^^^ type names cannot contain dots or hyphens + | ^^^^^^^^^ | - help: type names cannot contain separators; use ::SomeType instead + help: use `::SomeType` | 1 - (x) @name :: Some.Type 1 + (x) @name :: ::SomeType @@ -960,12 +953,12 @@ fn type_name_with_hyphens_error() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: type names cannot contain dots or hyphens + error: type names cannot contain `.` or `-`; type annotations map to types | 1 | (x) @name :: Some-Type - | ^^^^^^^^^ type names cannot contain dots or hyphens + | ^^^^^^^^^ | - help: type names cannot contain separators; use ::SomeType instead + help: use `::SomeType` | 1 - (x) @name :: Some-Type 1 + (x) @name :: ::SomeType @@ -980,12 +973,12 @@ fn comma_in_node_children() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: ',' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `,` | 1 | (node (a), (b)) - | ^ ',' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - (node (a), (b)) 1 + (node (a) (b)) @@ -1000,22 +993,23 @@ fn comma_in_alternation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: ',' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `,` | 1 | [(a), (b), (c)] - | ^ ',' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - [(a), (b), (c)] 1 + [(a) (b), (c)] | - error: ',' is not valid syntax; plotnik uses whitespace for separation + + error: separators are not needed; plotnik uses whitespace, not `,` | 1 | [(a), (b), (c)] - | ^ ',' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - [(a), (b), (c)] 1 + [(a), (b) (c)] @@ -1030,12 +1024,12 @@ fn comma_in_sequence() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: ',' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `,` | 1 | {(a), (b)} - | ^ ',' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - {(a), (b)} 1 + {(a) (b)} @@ -1050,22 +1044,23 @@ fn pipe_in_alternation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '|' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `|` | 1 | [(a) | (b) | (c)] - | ^ '|' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - [(a) | (b) | (c)] 1 + [(a) (b) | (c)] | - error: '|' is not valid syntax; plotnik uses whitespace for separation + + error: separators are not needed; plotnik uses whitespace, not `|` | 1 | [(a) | (b) | (c)] - | ^ '|' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - [(a) | (b) | (c)] 1 + [(a) | (b) (c)] @@ -1082,12 +1077,12 @@ fn pipe_between_branches() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '|' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `|` | 1 | [(a) | (b)] - | ^ '|' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - [(a) | (b)] 1 + [(a) (b)] @@ -1102,20 +1097,21 @@ fn pipe_in_tree() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '|' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `|` | 1 | (a | b) - | ^ '|' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - (a | b) 1 + (a b) | - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | (a | b) - | ^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ "); } @@ -1126,12 +1122,12 @@ fn pipe_in_sequence() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '|' is not valid syntax; plotnik uses whitespace for separation + error: separators are not needed; plotnik uses whitespace, not `|` | 1 | {(a) | (b)} - | ^ '|' is not valid syntax; plotnik uses whitespace for separation + | ^ | - help: remove separator + help: remove | 1 - {(a) | (b)} 1 + {(a) (b)} @@ -1146,12 +1142,12 @@ fn field_equals_typo() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '=' is not valid for field constraints + error: use `:` for field constraints, not `=`; this isn't a definition | 1 | (node name = (identifier)) - | ^ '=' is not valid for field constraints + | ^ | - help: use ':' + help: use `:` | 1 - (node name = (identifier)) 1 + (node name : (identifier)) @@ -1166,12 +1162,12 @@ fn field_equals_typo_no_space() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '=' is not valid for field constraints + error: use `:` for field constraints, not `=`; this isn't a definition | 1 | (node name=(identifier)) - | ^ '=' is not valid for field constraints + | ^ | - help: use ':' + help: use `:` | 1 - (node name=(identifier)) 1 + (node name:(identifier)) @@ -1186,20 +1182,16 @@ fn field_equals_typo_no_expression() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '=' is not valid for field constraints + error: use `:` for field constraints, not `=`; this isn't a definition | 1 | (call name=) - | ^ '=' is not valid for field constraints + | ^ | - help: use ':' + help: use `:` | 1 - (call name=) 1 + (call name:) | - error: expected expression after field name - | - 1 | (call name=) - | ^ expected expression after field name "); } @@ -1212,12 +1204,12 @@ fn field_equals_typo_in_tree() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: '=' is not valid for field constraints + error: use `:` for field constraints, not `=`; this isn't a definition | 1 | (call name = (identifier)) - | ^ '=' is not valid for field constraints + | ^ | - help: use ':' + help: use `:` | 1 - (call name = (identifier)) 1 + (call name : (identifier)) @@ -1232,12 +1224,12 @@ fn single_colon_type_annotation() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: single colon is not valid for type annotations + error: type annotations use `::`, not `:` — single `:` looks like a field | 1 | (identifier) @name : Type - | ^ single colon is not valid for type annotations + | ^ | - help: use '::' + help: use `::` | 1 | (identifier) @name :: Type | + @@ -1251,12 +1243,12 @@ fn single_colon_type_annotation_no_space() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: single colon is not valid for type annotations + error: type annotations use `::`, not `:` — single `:` looks like a field | 1 | (identifier) @name:Type - | ^ single colon is not valid for type annotations + | ^ | - help: use '::' + help: use `::` | 1 | (identifier) @name::Type | + @@ -1272,12 +1264,12 @@ fn single_colon_type_annotation_with_space() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: single colon is not valid for type annotations + error: type annotations use `::`, not `:` — single `:` looks like a field | 1 | (a) @x : Type - | ^ single colon is not valid for type annotations + | ^ | - help: use '::' + help: use `::` | 1 | (a) @x :: Type | + @@ -1291,25 +1283,19 @@ fn single_colon_primitive_type() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: capture '@' must follow an expression to capture + error: `@` must follow an expression to capture | 1 | @val : string - | ^ capture '@' must follow an expression to capture - error: expected ':' to separate field name from its value - | - 1 | @val : string - | ^ expected ':' to separate field name from its value - error: expected expression after field name - | - 1 | @val : string - | ^ expected expression after field name - error: bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) + | ^ + + error: unexpected token; expected ':' to separate field name from its value | 1 | @val : string - | ^^^^^^ bare identifier not allowed; nodes must be enclosed in parentheses, e.g., (identifier) - error: unnamed definition must be last in file; add a name: `Name = val` + | ^ + + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` | 1 | @val : string - | ^^^ unnamed definition must be last in file; add a name: `Name = val` + | ^^^^^^ "); } diff --git a/crates/plotnik-lib/src/query/alt_kinds.rs b/crates/plotnik-lib/src/query/alt_kinds.rs index af16df76..3c6a002c 100644 --- a/crates/plotnik-lib/src/query/alt_kinds.rs +++ b/crates/plotnik-lib/src/query/alt_kinds.rs @@ -9,6 +9,7 @@ use super::Query; use super::invariants::{ assert_alt_no_bare_exprs, assert_root_no_bare_exprs, ensure_both_branch_kinds, }; +use crate::diagnostics::DiagnosticKind; use crate::parser::{AltExpr, AltKind, Branch, Expr}; impl Query<'_> { @@ -78,10 +79,7 @@ impl Query<'_> { let untagged_range = branch_range(untagged_branch); self.alt_kind_diagnostics - .error( - "mixed tagged and untagged branches in alternation", - untagged_range, - ) + .report(DiagnosticKind::MixedAltBranches, untagged_range) .related_to("tagged branch here", tagged_range) .emit(); } diff --git a/crates/plotnik-lib/src/query/alt_kinds_tests.rs b/crates/plotnik-lib/src/query/alt_kinds_tests.rs index 4c1ee966..307806b5 100644 --- a/crates/plotnik-lib/src/query/alt_kinds_tests.rs +++ b/crates/plotnik-lib/src/query/alt_kinds_tests.rs @@ -35,10 +35,10 @@ fn mixed_alternation_tagged_first() { let query = Query::try_from("[A: (a) (b)]").unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: mixed tagged and untagged branches in alternation + error: cannot mix labeled and unlabeled branches | 1 | [A: (a) (b)] - | - ^^^ mixed tagged and untagged branches in alternation + | - ^^^ | | | tagged branch here "); @@ -57,10 +57,10 @@ fn mixed_alternation_untagged_first() { .unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: mixed tagged and untagged branches in alternation + error: cannot mix labeled and unlabeled branches | 3 | (a) - | ^^^ mixed tagged and untagged branches in alternation + | ^^^ 4 | B: (b) | - tagged branch here "); @@ -71,10 +71,10 @@ fn nested_mixed_alternation() { let query = Query::try_from("(call [A: (a) (b)])").unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: mixed tagged and untagged branches in alternation + error: cannot mix labeled and unlabeled branches | 1 | (call [A: (a) (b)]) - | - ^^^ mixed tagged and untagged branches in alternation + | - ^^^ | | | tagged branch here "); @@ -85,16 +85,17 @@ fn multiple_mixed_alternations() { let query = Query::try_from("(foo [A: (a) (b)] [C: (c) (d)])").unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: mixed tagged and untagged branches in alternation + error: cannot mix labeled and unlabeled branches | 1 | (foo [A: (a) (b)] [C: (c) (d)]) - | - ^^^ mixed tagged and untagged branches in alternation + | - ^^^ | | | tagged branch here - error: mixed tagged and untagged branches in alternation + + error: cannot mix labeled and unlabeled branches | 1 | (foo [A: (a) (b)] [C: (c) (d)]) - | - ^^^ mixed tagged and untagged branches in alternation + | - ^^^ | | | tagged branch here "); diff --git a/crates/plotnik-lib/src/query/dump.rs b/crates/plotnik-lib/src/query/dump.rs index 1f7ed97b..9f2f7219 100644 --- a/crates/plotnik-lib/src/query/dump.rs +++ b/crates/plotnik-lib/src/query/dump.rs @@ -30,7 +30,11 @@ mod test_helpers { } pub fn dump_diagnostics(&self) -> String { - self.diagnostics().render(self.source) + self.diagnostics().render_filtered(self.source) + } + + pub fn dump_diagnostics_raw(&self) -> String { + self.diagnostics_raw().render(self.source) } } } diff --git a/crates/plotnik-lib/src/query/mod.rs b/crates/plotnik-lib/src/query/mod.rs index 6d4491ff..c768deac 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -188,8 +188,11 @@ impl<'a> Query<'a> { .unwrap_or(ShapeCardinality::One) } - /// All diagnostics combined from all passes. - pub fn diagnostics(&self) -> Diagnostics { + /// All diagnostics combined from all passes (unfiltered). + /// + /// Use this for debugging or when you need to see all diagnostics + /// including cascading errors. + pub fn diagnostics_raw(&self) -> Diagnostics { let mut all = Diagnostics::new(); all.extend(self.parse_diagnostics.clone()); all.extend(self.alt_kind_diagnostics.clone()); @@ -199,6 +202,14 @@ impl<'a> Query<'a> { all } + /// All diagnostics combined from all passes. + /// + /// Returns diagnostics with cascading errors suppressed. + /// For raw access, use [`diagnostics_raw`](Self::diagnostics_raw). + pub fn diagnostics(&self) -> Diagnostics { + self.diagnostics_raw() + } + /// Query is valid if there are no error-severity diagnostics (warnings are allowed). pub fn is_valid(&self) -> bool { !self.parse_diagnostics.has_errors() diff --git a/crates/plotnik-lib/src/query/mod_tests.rs b/crates/plotnik-lib/src/query/mod_tests.rs index 50e2fd2c..883e1299 100644 --- a/crates/plotnik-lib/src/query/mod_tests.rs +++ b/crates/plotnik-lib/src/query/mod_tests.rs @@ -10,19 +10,41 @@ fn valid_query() { fn parse_error() { let q = Query::try_from("(unclosed").unwrap(); assert!(!q.is_valid()); - assert!(q.dump_diagnostics().contains("expected")); + insta::assert_snapshot!(q.dump_diagnostics(), @r" + error: missing closing `)`; expected `)` + | + 1 | (unclosed + | -^^^^^^^^ + | | + | tree started here + "); } #[test] fn resolution_error() { let q = Query::try_from("(call (Undefined))").unwrap(); assert!(!q.is_valid()); - assert!(q.dump_diagnostics().contains("undefined reference")); + insta::assert_snapshot!(q.dump_diagnostics(), @r" + error: `Undefined` is not defined + | + 1 | (call (Undefined)) + | ^^^^^^^^^ + "); } #[test] fn combined_errors() { let q = Query::try_from("(call (Undefined) extra)").unwrap(); assert!(!q.is_valid()); - assert!(!q.diagnostics().is_empty()); + insta::assert_snapshot!(q.dump_diagnostics(), @r" + error: bare identifier is not a valid expression; wrap in parentheses: `(identifier)` + | + 1 | (call (Undefined) extra) + | ^^^^^ + + error: `Undefined` is not defined + | + 1 | (call (Undefined) extra) + | ^^^^^^^^^ + "); } diff --git a/crates/plotnik-lib/src/query/recursion.rs b/crates/plotnik-lib/src/query/recursion.rs index 780ea9db..e23267e8 100644 --- a/crates/plotnik-lib/src/query/recursion.rs +++ b/crates/plotnik-lib/src/query/recursion.rs @@ -7,6 +7,7 @@ use indexmap::{IndexMap, IndexSet}; use rowan::TextRange; use super::Query; +use crate::diagnostics::DiagnosticKind; use crate::parser::{Def, Expr, SyntaxKind}; impl Query<'_> { @@ -207,13 +208,10 @@ impl Query<'_> { .map(|(r, _)| *r) .unwrap_or_else(|| TextRange::empty(0.into())); - let mut builder = self.recursion_diagnostics.error( - format!( - "recursive pattern can never match: cycle {} has no escape path", - cycle_str - ), - range, - ); + let mut builder = self + .recursion_diagnostics + .report(DiagnosticKind::RecursionNoEscape, range) + .message(format!("cycle {} has no escape path", cycle_str)); for (rel_range, rel_msg) in related { builder = builder.related_to(rel_msg, rel_range); diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index 59b14b5b..81fadca3 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -4,32 +4,36 @@ use indoc::indoc; #[test] fn escape_via_alternation() { let query = Query::try_from("E = [(x) (call (E))]").unwrap(); + assert!(query.is_valid()); } #[test] fn escape_via_optional() { let query = Query::try_from("E = (call (E)?)").unwrap(); + assert!(query.is_valid()); } #[test] fn escape_via_star() { let query = Query::try_from("E = (call (E)*)").unwrap(); + assert!(query.is_valid()); } #[test] fn no_escape_via_plus() { let query = Query::try_from("E = (call (E)+)").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `E` → `E` has no escape path + error: infinite recursion: cycle `E` → `E` has no escape path | 1 | E = (call (E)+) | ^ | | - | recursive pattern can never match: cycle `E` → `E` has no escape path | `E` references itself "); } @@ -37,6 +41,7 @@ fn no_escape_via_plus() { #[test] fn escape_via_empty_tree() { let query = Query::try_from("E = [(call) (E)]").unwrap(); + assert!(query.is_valid()); } @@ -50,14 +55,15 @@ fn lazy_quantifiers_same_as_greedy() { #[test] fn recursion_in_tree_child() { let query = Query::try_from("E = (call (E))").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `E` → `E` has no escape path + error: infinite recursion: cycle `E` → `E` has no escape path | 1 | E = (call (E)) | ^ | | - | recursive pattern can never match: cycle `E` → `E` has no escape path | `E` references itself "); } @@ -65,27 +71,55 @@ fn recursion_in_tree_child() { #[test] fn recursion_in_field() { let query = Query::try_from("E = (call body: (E))").unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `E` → `E` has no escape path + | + 1 | E = (call body: (E)) + | ^ + | | + | `E` references itself + "); } #[test] fn recursion_in_capture() { let query = Query::try_from("E = (call (E) @inner)").unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `E` → `E` has no escape path + | + 1 | E = (call (E) @inner) + | ^ + | | + | `E` references itself + "); } #[test] fn recursion_in_sequence() { let query = Query::try_from("E = (call {(a) (E)})").unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `E` → `E` has no escape path + | + 1 | E = (call {(a) (E)}) + | ^ + | | + | `E` references itself + "); } #[test] fn recursion_through_multiple_children() { let query = Query::try_from("E = [(x) (call (a) (E))]").unwrap(); + assert!(query.is_valid()); } @@ -96,16 +130,17 @@ fn mutual_recursion_no_escape() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + error: infinite recursion: cycle `B` → `A` → `B` has no escape path | 1 | A = (foo (B)) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) | ^ | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path | `B` references `A` "); } @@ -117,6 +152,7 @@ fn mutual_recursion_one_has_escape() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -128,8 +164,21 @@ fn three_way_cycle_no_escape() { C = (c (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `C` → `B` → `A` → `C` has no escape path + | + 1 | A = (a (B)) + | - `A` references `B` + 2 | B = (b (C)) + | - `B` references `C` (completing cycle) + 3 | C = (c (A)) + | ^ + | | + | `C` references `A` + "); } #[test] @@ -140,6 +189,7 @@ fn three_way_cycle_one_has_escape() { C = (c (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -152,8 +202,22 @@ fn diamond_dependency() { D = (d (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `C` → `D` → `B` → `A` → `C` has no escape path + | + 1 | A = (a [(B) (C)]) + | - `A` references `C` (completing cycle) + 2 | B = (b (D)) + 3 | C = (c (D)) + | ^ + | | + | `C` references `D` + 4 | D = (d (A)) + | - `D` references `A` + "); } #[test] @@ -163,16 +227,17 @@ fn cycle_ref_in_field() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + error: infinite recursion: cycle `B` → `A` → `B` has no escape path | 1 | A = (foo body: (B)) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) | ^ | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path | `B` references `A` "); } @@ -184,16 +249,17 @@ fn cycle_ref_in_capture() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + error: infinite recursion: cycle `B` → `A` → `B` has no escape path | 1 | A = (foo (B) @cap) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) | ^ | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path | `B` references `A` "); } @@ -205,16 +271,17 @@ fn cycle_ref_in_sequence() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + error: infinite recursion: cycle `B` → `A` → `B` has no escape path | 1 | A = (foo {(x) (B)}) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) | ^ | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path | `B` references `A` "); } @@ -226,6 +293,7 @@ fn cycle_with_quantifier_escape() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -236,8 +304,19 @@ fn cycle_with_plus_no_escape() { B = (bar (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `B` → `A` → `B` has no escape path + | + 1 | A = (foo (B)+) + | - `A` references `B` (completing cycle) + 2 | B = (bar (A)) + | ^ + | | + | `B` references `A` + "); } #[test] @@ -247,6 +326,7 @@ fn non_recursive_reference() { Tree = (call (Leaf)) "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -257,12 +337,14 @@ fn entry_point_uses_recursive_def() { (program (E)) "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } #[test] fn direct_self_ref_in_alternation() { let query = Query::try_from("E = [(E) (x)]").unwrap(); + assert!(query.is_valid()); } @@ -272,6 +354,7 @@ fn escape_via_literal_string() { A = [(A) "escape"] "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -281,6 +364,7 @@ fn escape_via_wildcard() { A = [(A) _] "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -290,6 +374,7 @@ fn escape_via_childless_tree() { A = [(A) (leaf)] "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -299,6 +384,7 @@ fn escape_via_anchor() { A = (foo . [(A) (x)]) "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -308,8 +394,17 @@ fn no_escape_tree_all_recursive() { A = (foo (A)) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); - assert!(query.dump_diagnostics().contains("recursive pattern")); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `A` → `A` has no escape path + | + 1 | A = (foo (A)) + | ^ + | | + | `A` references itself + "); } #[test] @@ -318,6 +413,7 @@ fn escape_in_capture_inner() { A = [(x)@cap (foo (A))] "#}; let query = Query::try_from(input).unwrap(); + assert!(query.is_valid()); } @@ -327,5 +423,6 @@ fn ref_in_quantifier_plus_no_escape() { A = (foo (A)+) "#}; let query = Query::try_from(input).unwrap(); + assert!(!query.is_valid()); } diff --git a/crates/plotnik-lib/src/query/shapes.rs b/crates/plotnik-lib/src/query/shapes.rs index 8916d131..5a3fe840 100644 --- a/crates/plotnik-lib/src/query/shapes.rs +++ b/crates/plotnik-lib/src/query/shapes.rs @@ -11,6 +11,7 @@ use super::Query; use super::invariants::{ ensure_capture_has_inner, ensure_quantifier_has_inner, ensure_ref_has_name, }; +use crate::diagnostics::DiagnosticKind; use crate::parser::{Expr, FieldExpr, Ref, SeqExpr, SyntaxNode}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -116,13 +117,8 @@ impl Query<'_> { .unwrap_or_else(|| "field".to_string()); self.shapes_diagnostics - .error( - format!( - "field `{}` value must match a single node, not a sequence", - field_name - ), - value.text_range(), - ) + .report(DiagnosticKind::FieldSequenceValue, value.text_range()) + .message(field_name) .emit(); } diff --git a/crates/plotnik-lib/src/query/shapes_tests.rs b/crates/plotnik-lib/src/query/shapes_tests.rs index 46f5964f..9c132b68 100644 --- a/crates/plotnik-lib/src/query/shapes_tests.rs +++ b/crates/plotnik-lib/src/query/shapes_tests.rs @@ -168,10 +168,10 @@ fn field_with_seq_error() { NamedNode¹ b "); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field `name` value must match a single node, not a sequence + error: field `name` must match exactly one node, not a sequence | 1 | (call name: {(a) (b)}) - | ^^^^^^^^^ field `name` value must match a single node, not a sequence + | ^^^^^^^^^ "); } @@ -195,10 +195,10 @@ fn field_with_ref_to_seq_error() { Ref⁺ X "); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: field `name` value must match a single node, not a sequence + error: field `name` must match exactly one node, not a sequence | 2 | (call name: (X)) - | ^^^ field `name` value must match a single node, not a sequence + | ^^^ "); } diff --git a/crates/plotnik-lib/src/query/symbol_table.rs b/crates/plotnik-lib/src/query/symbol_table.rs index e1620e59..73ebb029 100644 --- a/crates/plotnik-lib/src/query/symbol_table.rs +++ b/crates/plotnik-lib/src/query/symbol_table.rs @@ -6,6 +6,7 @@ use indexmap::IndexMap; +use crate::diagnostics::DiagnosticKind; use crate::parser::{Expr, Ref, ast}; use super::Query; @@ -25,7 +26,8 @@ impl<'a> Query<'a> { if self.symbol_table.contains_key(name) { self.resolve_diagnostics - .error(format!("duplicate definition: `{}`", name), range) + .report(DiagnosticKind::DuplicateDefinition, range) + .message(name) .emit(); continue; } @@ -101,10 +103,8 @@ impl<'a> Query<'a> { } self.resolve_diagnostics - .error( - format!("undefined reference: `{}`", name), - name_token.text_range(), - ) + .report(DiagnosticKind::UndefinedReference, name_token.text_range()) + .message(name) .emit(); } } diff --git a/crates/plotnik-lib/src/query/symbol_table_tests.rs b/crates/plotnik-lib/src/query/symbol_table_tests.rs index e512f798..d127c3a0 100644 --- a/crates/plotnik-lib/src/query/symbol_table_tests.rs +++ b/crates/plotnik-lib/src/query/symbol_table_tests.rs @@ -49,10 +49,10 @@ fn undefined_reference() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: undefined reference: `Undefined` + error: `Undefined` is not defined | 1 | Call = (call_expression function: (Undefined)) - | ^^^^^^^^^ undefined reference: `Undefined` + | ^^^^^^^^^ "); } @@ -78,14 +78,13 @@ fn mutual_recursion() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + error: infinite recursion: cycle `B` → `A` → `B` has no escape path | 1 | A = (foo (B)) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) | ^ | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path | `B` references `A` "); } @@ -100,10 +99,10 @@ fn duplicate_definition() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: duplicate definition: `Expr` + error: `Expr` is already defined | 2 | Expr = (other) - | ^^^^ duplicate definition: `Expr` + | ^^^^ "); } @@ -190,10 +189,10 @@ fn entry_point_undefined_reference() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: undefined reference: `Unknown` + error: `Unknown` is not defined | 1 | (call function: (Unknown)) - | ^^^^^^^ undefined reference: `Unknown` + | ^^^^^^^ "); } @@ -238,18 +237,20 @@ fn multiple_undefined() { let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: undefined reference: `X` + error: `X` is not defined | 1 | (foo (X) (Y) (Z)) - | ^ undefined reference: `X` - error: undefined reference: `Y` + | ^ + + error: `Y` is not defined | 1 | (foo (X) (Y) (Z)) - | ^ undefined reference: `Y` - error: undefined reference: `Z` + | ^ + + error: `Z` is not defined | 1 | (foo (X) (Y) (Z)) - | ^ undefined reference: `Z` + | ^ "); }