From 278b28467707f5d33a00ab665f2d29776d46dfef Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 20 Dec 2025 19:48:48 -0300 Subject: [PATCH 1/5] Fixes --- crates/plotnik-lib/src/diagnostics/message.rs | 22 +- crates/plotnik-lib/src/diagnostics/mod.rs | 82 +++-- crates/plotnik-lib/src/diagnostics/printer.rs | 65 ++-- crates/plotnik-lib/src/diagnostics/tests.rs | 125 ++++++- crates/plotnik-lib/src/lib.rs | 3 +- crates/plotnik-lib/src/parser/core.rs | 37 ++- crates/plotnik-lib/src/query/alt_kinds.rs | 12 +- crates/plotnik-lib/src/query/dependencies.rs | 79 ++--- crates/plotnik-lib/src/query/expr_arity.rs | 54 ++- crates/plotnik-lib/src/query/link.rs | 71 ++-- crates/plotnik-lib/src/query/mod.rs | 2 + crates/plotnik-lib/src/query/query.rs | 140 ++++---- crates/plotnik-lib/src/query/source_map.rs | 312 ++++++++++++++++++ crates/plotnik-lib/src/query/symbol_table.rs | 96 +++--- 14 files changed, 826 insertions(+), 274 deletions(-) create mode 100644 crates/plotnik-lib/src/query/source_map.rs diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index e700d1ea..59cfcaf7 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -1,5 +1,7 @@ use rowan::TextRange; +use super::{SourceId, Span}; + /// Diagnostic kinds ordered by priority (highest priority first). /// /// When two diagnostics have overlapping spans, the higher-priority one @@ -282,14 +284,23 @@ impl Fix { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelatedInfo { - pub(crate) range: TextRange, + pub(crate) span: Span, pub(crate) message: String, } impl RelatedInfo { + /// Same-file related info (backward compat). pub fn new(range: TextRange, message: impl Into) -> Self { Self { - range, + span: Span::anonymous(range), + message: message.into(), + } + } + + /// Cross-file related info. + pub fn in_source(source: SourceId, range: TextRange, message: impl Into) -> Self { + Self { + span: Span::new(source, range), message: message.into(), } } @@ -298,6 +309,8 @@ impl RelatedInfo { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DiagnosticMessage { pub(crate) kind: DiagnosticKind, + /// Which source file this diagnostic belongs to. + pub(crate) source: SourceId, /// The range shown to the user (underlined in output). pub(crate) range: TextRange, /// The range used for suppression logic. Errors within another error's @@ -315,6 +328,7 @@ impl DiagnosticMessage { pub(crate) fn new(kind: DiagnosticKind, range: TextRange, message: impl Into) -> Self { Self { kind, + source: SourceId::DEFAULT, range, suppression_range: range, message: message.into(), @@ -359,8 +373,8 @@ impl std::fmt::Display for DiagnosticMessage { f, " (related: {} at {}..{})", related.message, - u32::from(related.range.start()), - u32::from(related.range.end()) + u32::from(related.span.range.start()), + u32::from(related.span.range.end()) )?; } for hint in &self.hints { diff --git a/crates/plotnik-lib/src/diagnostics/mod.rs b/crates/plotnik-lib/src/diagnostics/mod.rs index 7edb207a..c131b3d0 100644 --- a/crates/plotnik-lib/src/diagnostics/mod.rs +++ b/crates/plotnik-lib/src/diagnostics/mod.rs @@ -11,6 +11,16 @@ pub use printer::DiagnosticsPrinter; use message::{DiagnosticMessage, Fix, RelatedInfo}; +// Re-export from query module +pub use crate::query::{SourceId, SourceMap}; + +/// A location that knows which source it belongs to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Span { + pub source: SourceId, + pub range: TextRange, +} + #[derive(Debug, Clone, Default)] pub struct Diagnostics { messages: Vec, @@ -32,26 +42,17 @@ 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<'_> { + pub fn report( + &mut self, + source: SourceId, + kind: DiagnosticKind, + range: TextRange, + ) -> DiagnosticBuilder<'_> { + let mut msg = DiagnosticMessage::with_default_message(kind, range); + msg.source = source; DiagnosticBuilder { diagnostics: self, - 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::new(DiagnosticKind::UnexpectedToken, range, msg), + message: msg, } } @@ -163,29 +164,34 @@ impl Diagnostics { &self.messages } - pub fn printer<'a>(&self, source: &'a str) -> DiagnosticsPrinter<'a> { - DiagnosticsPrinter::new(self.messages.clone(), source) + /// Create a printer with a source map (multi-file support). + pub fn printer<'a>(&self, sources: &'a SourceMap) -> DiagnosticsPrinter<'a> { + DiagnosticsPrinter::new(self.messages.clone(), sources) } - /// Printer that uses filtered diagnostics (cascading errors suppressed). - pub fn filtered_printer<'a>(&self, source: &'a str) -> DiagnosticsPrinter<'a> { - DiagnosticsPrinter::new(self.filtered(), source) + /// Filtered printer with source map (cascading errors suppressed). + pub fn filtered_printer<'a>(&self, sources: &'a SourceMap) -> DiagnosticsPrinter<'a> { + DiagnosticsPrinter::new(self.filtered(), sources) } - pub fn render(&self, source: &str) -> String { - self.printer(source).render() + /// Render with source map. + pub fn render(&self, sources: &SourceMap) -> String { + self.printer(sources).render() } - pub fn render_colored(&self, source: &str, colored: bool) -> String { - self.printer(source).colored(colored).render() + /// Render with source map, colored output. + pub fn render_colored(&self, sources: &SourceMap, colored: bool) -> String { + self.printer(sources).colored(colored).render() } - pub fn render_filtered(&self, source: &str) -> String { - self.filtered_printer(source).render() + /// Render filtered with source map. + pub fn render_filtered(&self, sources: &SourceMap) -> String { + self.filtered_printer(sources).render() } - pub fn render_filtered_colored(&self, source: &str, colored: bool) -> String { - self.filtered_printer(source).colored(colored).render() + /// Render filtered with source map, colored output. + pub fn render_filtered_colored(&self, sources: &SourceMap, colored: bool) -> String { + self.filtered_printer(sources).colored(colored).render() } pub fn extend(&mut self, other: Diagnostics) { @@ -201,11 +207,25 @@ impl<'a> DiagnosticBuilder<'a> { self } + /// Related info in same file (backward compat). pub fn related_to(mut self, msg: impl Into, range: TextRange) -> Self { self.message.related.push(RelatedInfo::new(range, msg)); self } + /// Related info in different file. + pub fn related_in( + mut self, + source: SourceId, + range: TextRange, + msg: impl Into, + ) -> Self { + self.message + .related + .push(RelatedInfo::in_source(source, range, msg)); + self + } + /// Set the suppression range for this diagnostic. /// /// The suppression range is used to suppress cascading errors. Errors whose diff --git a/crates/plotnik-lib/src/diagnostics/printer.rs b/crates/plotnik-lib/src/diagnostics/printer.rs index 88d34136..b6ab2052 100644 --- a/crates/plotnik-lib/src/diagnostics/printer.rs +++ b/crates/plotnik-lib/src/diagnostics/printer.rs @@ -5,30 +5,24 @@ use std::fmt::Write; use annotate_snippets::{AnnotationKind, Group, Level, Patch, Renderer, Snippet}; use rowan::TextRange; +use super::SourceMap; use super::message::{DiagnosticMessage, Severity}; pub struct DiagnosticsPrinter<'a> { diagnostics: Vec, - source: &'a str, - path: Option<&'a str>, + sources: &'a SourceMap, colored: bool, } impl<'a> DiagnosticsPrinter<'a> { - pub(crate) fn new(diagnostics: Vec, source: &'a str) -> Self { + pub(crate) fn new(diagnostics: Vec, sources: &'a SourceMap) -> Self { Self { diagnostics, - source, - path: None, + sources, colored: false, } } - pub fn path(mut self, path: &'a str) -> Self { - self.path = Some(path); - self - } - pub fn colored(mut self, value: bool) -> Self { self.colored = value; self @@ -48,33 +42,58 @@ impl<'a> DiagnosticsPrinter<'a> { }; for (i, diag) in self.diagnostics.iter().enumerate() { - let range = adjust_range(diag.range, self.source.len()); + let Some(primary_content) = self.sources.content(diag.source) else { + continue; + }; - let mut snippet = Snippet::source(self.source) - .line_start(1) - .annotation(AnnotationKind::Primary.span(range.clone())); + let range = adjust_range(diag.range, primary_content.len()); - if let Some(p) = self.path { - snippet = snippet.path(p); + let mut primary_snippet = Snippet::source(primary_content).line_start(1); + if let Some(name) = self.sources.name(diag.source) { + primary_snippet = primary_snippet.path(name); } + primary_snippet = + primary_snippet.annotation(AnnotationKind::Primary.span(range.clone())); + + // Collect same-file and cross-file related info separately + let mut cross_file_snippets = Vec::new(); for related in &diag.related { - snippet = snippet.annotation( - AnnotationKind::Context - .span(adjust_range(related.range, self.source.len())) - .label(&related.message), - ); + if related.span.source == diag.source { + // Same file: add annotation to primary snippet + primary_snippet = primary_snippet.annotation( + AnnotationKind::Context + .span(adjust_range(related.span.range, primary_content.len())) + .label(&related.message), + ); + } else if let Some(related_content) = self.sources.content(related.span.source) { + // Different file: create separate snippet + let mut snippet = Snippet::source(related_content).line_start(1); + if let Some(name) = self.sources.name(related.span.source) { + snippet = snippet.path(name); + } + snippet = snippet.annotation( + AnnotationKind::Context + .span(adjust_range(related.span.range, related_content.len())) + .label(&related.message), + ); + cross_file_snippets.push(snippet); + } } let level = severity_to_level(diag.severity()); - let title_group = level.primary_title(&diag.message).element(snippet); + let mut title_group = level.primary_title(&diag.message).element(primary_snippet); + + for snippet in cross_file_snippets { + title_group = title_group.element(snippet); + } let mut report: Vec = vec![title_group]; if let Some(fix) = &diag.fix { report.push( Level::HELP.secondary_title(&fix.description).element( - Snippet::source(self.source) + Snippet::source(primary_content) .line_start(1) .patch(Patch::new(range, &fix.replacement)), ), diff --git a/crates/plotnik-lib/src/diagnostics/tests.rs b/crates/plotnik-lib/src/diagnostics/tests.rs index 479e0c0a..c7b4b433 100644 --- a/crates/plotnik-lib/src/diagnostics/tests.rs +++ b/crates/plotnik-lib/src/diagnostics/tests.rs @@ -61,7 +61,8 @@ fn builder_with_related() { .emit(); assert_eq!(diagnostics.len(), 1); - let result = diagnostics.printer("hello world!").render(); + let map = SourceMap::one_liner("hello world!"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; primary | @@ -82,7 +83,8 @@ fn builder_with_fix() { .fix("apply this fix", "fixed") .emit(); - let result = diagnostics.printer("hello world").render(); + let (map, _) = SourceMap::anonymous("hello world"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: use `:` for field constraints, not `=`; fixable | @@ -111,7 +113,8 @@ fn builder_with_all_options() { .fix("try this", "HELLO") .emit(); - let result = diagnostics.printer("hello world stuff!").render(); + let (map, _) = SourceMap::anonymous("hello world stuff!"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; main error | @@ -139,7 +142,8 @@ fn printer_colored() { .message("test") .emit(); - let result = diagnostics.printer("hello").colored(true).render(); + let (map, _) = SourceMap::anonymous("hello"); + let result = diagnostics.printer_with(&map).colored(true).render(); assert!(result.contains("test")); assert!(result.contains('\x1b')); } @@ -147,12 +151,13 @@ fn printer_colored() { #[test] fn printer_empty_diagnostics() { let diagnostics = Diagnostics::new(); - let result = diagnostics.printer("source").render(); + let (map, _) = SourceMap::anonymous("source"); + let result = diagnostics.printer_with(&map).render(); assert!(result.is_empty()); } #[test] -fn printer_with_path() { +fn printer_with_custom_path() { let mut diagnostics = Diagnostics::new(); diagnostics .report( @@ -162,7 +167,9 @@ fn printer_with_path() { .message("test error") .emit(); - let result = diagnostics.printer("hello world").path("test.pql").render(); + let mut map = SourceMap::new(); + map.add("test.pql", "hello world"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: `test error` is not defined --> test.pql:1:1 @@ -183,7 +190,8 @@ fn printer_zero_width_span() { .message("zero width error") .emit(); - let result = diagnostics.printer("hello").render(); + let (map, _) = SourceMap::anonymous("hello"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: expected an expression; zero width error | @@ -204,7 +212,8 @@ fn printer_related_zero_width() { .related_to("zero width related", TextRange::empty(6.into())) .emit(); - let result = diagnostics.printer("hello world!").render(); + let (map, _) = SourceMap::anonymous("hello world!"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; primary | @@ -231,7 +240,8 @@ fn printer_multiple_diagnostics() { .message("second error") .emit(); - let result = diagnostics.printer("hello world!").render(); + let (map, _) = SourceMap::anonymous("hello world!"); + let result = diagnostics.printer_with(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; first error | @@ -454,8 +464,101 @@ fn render_filtered() { .message("unnamed def") .emit(); - let result = diagnostics.render_filtered("(function_declaration"); + let (map, _) = SourceMap::anonymous("(function_declaration"); + let result = diagnostics.render_filtered_with(&map); // Should only show the unclosed tree error assert!(result.contains("unclosed tree")); assert!(!result.contains("unnamed def")); } + +// Multi-file diagnostics tests + +#[test] +fn multi_file_cross_file_related() { + let mut map = SourceMap::new(); + let file_a = map.add("a.ptk", "Foo = (bar)"); + let file_b = map.add("b.ptk", "(Foo) @x"); + + let mut diagnostics = Diagnostics::new(); + diagnostics + .report_in( + file_b, + DiagnosticKind::UndefinedReference, + TextRange::new(1.into(), 4.into()), + ) + .message("Foo") + .related_in(file_a, TextRange::new(0.into(), 3.into()), "defined here") + .emit(); + + let result = diagnostics.printer_with(&map).render(); + insta::assert_snapshot!(result, @r" + error: `Foo` is not defined + --> b.ptk:1:2 + | + 1 | (Foo) @x + | ^^^ + | + ::: a.ptk:1:1 + | + 1 | Foo = (bar) + | --- defined here + "); +} + +#[test] +fn multi_file_same_file_related() { + let mut map = SourceMap::new(); + let file_a = map.add("main.ptk", "Foo = (bar) Foo = (baz)"); + + let mut diagnostics = Diagnostics::new(); + diagnostics + .report_in( + file_a, + DiagnosticKind::DuplicateDefinition, + TextRange::new(12.into(), 15.into()), + ) + .message("Foo") + .related_in( + file_a, + TextRange::new(0.into(), 3.into()), + "first defined here", + ) + .emit(); + + let result = diagnostics.printer_with(&map).render(); + insta::assert_snapshot!(result, @r" + error: `Foo` is already defined + --> main.ptk:1:13 + | + 1 | Foo = (bar) Foo = (baz) + | --- ^^^ + | | + | first defined here + "); +} + +#[test] +fn source_map_iteration() { + let mut map = SourceMap::new(); + map.add("a.ptk", "content a"); + map.add("b.ptk", "content b"); + + assert_eq!(map.len(), 2); + assert!(!map.is_empty()); + + let names: Vec<_> = map.iter().map(|(_, name, _)| name).collect(); + assert_eq!(names, vec![Some("a.ptk"), Some("b.ptk")]); +} + +#[test] +fn source_id_default() { + assert_eq!(SourceId::DEFAULT, SourceId::default()); +} + +#[test] +fn span_anonymous() { + let range = TextRange::new(5.into(), 10.into()); + let span = Span::anonymous(range); + assert_eq!(span.source, SourceId::DEFAULT); + assert_eq!(span.range, range); +} diff --git a/crates/plotnik-lib/src/lib.rs b/crates/plotnik-lib/src/lib.rs index e3f57098..0506747a 100644 --- a/crates/plotnik-lib/src/lib.rs +++ b/crates/plotnik-lib/src/lib.rs @@ -26,8 +26,9 @@ pub mod query; /// Fatal errors (like fuel exhaustion) use the outer `Result`. pub type PassResult = std::result::Result<(T, Diagnostics), Error>; -pub use diagnostics::{Diagnostics, DiagnosticsPrinter, Severity}; +pub use diagnostics::{Diagnostics, DiagnosticsPrinter, Severity, Span}; pub use query::{Query, QueryBuilder}; +pub use query::{SourceId, SourceMap}; /// Errors that can occur during query parsing. #[derive(Debug, Clone, thiserror::Error)] diff --git a/crates/plotnik-lib/src/parser/core.rs b/crates/plotnik-lib/src/parser/core.rs index 33a61704..810489ed 100644 --- a/crates/plotnik-lib/src/parser/core.rs +++ b/crates/plotnik-lib/src/parser/core.rs @@ -6,12 +6,11 @@ use super::ast::Root; use super::cst::{SyntaxKind, SyntaxNode, TokenSet}; use super::lexer::{Token, token_text}; use crate::Error; -use crate::diagnostics::{DiagnosticKind, Diagnostics}; +use crate::diagnostics::{DiagnosticKind, Diagnostics, SourceId}; #[derive(Debug)] pub struct ParseResult { pub ast: Root, - pub diag: Diagnostics, pub fuel_consumed: u32, } @@ -23,13 +22,14 @@ pub(super) struct OpenDelimiter { } /// Trivia tokens are buffered and flushed when starting a new node. -pub struct Parser<'src> { +pub struct Parser<'src, 'diag> { pub(super) source: &'src str, + pub(super) source_id: SourceId, pub(super) tokens: Vec, pub(super) pos: usize, pub(super) trivia_buffer: Vec, pub(super) builder: GreenNodeBuilder<'static>, - pub(super) diagnostics: Diagnostics, + pub(super) diagnostics: &'diag mut Diagnostics, pub(super) depth: u32, pub(super) last_diagnostic_pos: Option, pub(super) delimiter_stack: Vec, @@ -40,15 +40,23 @@ pub struct Parser<'src> { fatal_error: Option, } -impl<'src> Parser<'src> { - pub fn new(source: &'src str, tokens: Vec, fuel: u32, max_depth: u32) -> Self { +impl<'src, 'diag> Parser<'src, 'diag> { + pub fn new( + source: &'src str, + source_id: SourceId, + tokens: Vec, + diagnostics: &'diag mut Diagnostics, + fuel: u32, + max_depth: u32, + ) -> Self { Self { source, + source_id, tokens, pos: 0, trivia_buffer: Vec::with_capacity(4), builder: GreenNodeBuilder::new(), - diagnostics: Diagnostics::new(), + diagnostics, depth: 0, last_diagnostic_pos: None, delimiter_stack: Vec::with_capacity(8), @@ -62,22 +70,21 @@ impl<'src> Parser<'src> { pub fn parse(mut self) -> Result { self.parse_root(); - let (cst, diagnostics, exec_fuel_consumed) = self.finish()?; + let (cst, exec_fuel_consumed) = self.finish()?; let root = Root::cast(SyntaxNode::new_root(cst)).expect("parser always produces Root"); Ok(ParseResult { ast: root, - diag: diagnostics, fuel_consumed: exec_fuel_consumed, }) } - fn finish(mut self) -> Result<(GreenNode, Diagnostics, u32), Error> { + fn finish(mut self) -> Result<(GreenNode, u32), Error> { self.drain_trivia(); if let Some(err) = self.fatal_error { return Err(err); } let fuel_consumed = self.fuel_initial.saturating_sub(self.fuel_remaining); - Ok((self.builder.finish(), self.diagnostics, fuel_consumed)) + Ok((self.builder.finish(), fuel_consumed)) } pub(super) fn has_fatal_error(&self) -> bool { @@ -275,7 +282,7 @@ impl<'src> Parser<'src> { return; }; self.diagnostics - .report(kind, range) + .report(self.source_id, kind, range) .suppression_range(suppression) .emit(); } @@ -285,7 +292,7 @@ impl<'src> Parser<'src> { return; }; self.diagnostics - .report(kind, range) + .report(self.source_id, kind, range) .message(message) .suppression_range(suppression) .emit(); @@ -363,7 +370,7 @@ impl<'src> Parser<'src> { // Use full range for easier downstream error suppression let full_range = TextRange::new(open_range.start(), current.end()); self.diagnostics - .report(kind, full_range) + .report(self.source_id, kind, full_range) .message(message) .related_to(related_msg, open_range) .emit(); @@ -389,7 +396,7 @@ impl<'src> Parser<'src> { return; } self.diagnostics - .report(kind, range) + .report(self.source_id, kind, range) .message(message) .fix(fix_description, fix_replacement) .emit(); diff --git a/crates/plotnik-lib/src/query/alt_kinds.rs b/crates/plotnik-lib/src/query/alt_kinds.rs index 423f43d1..5fd533c6 100644 --- a/crates/plotnik-lib/src/query/alt_kinds.rs +++ b/crates/plotnik-lib/src/query/alt_kinds.rs @@ -7,16 +7,18 @@ use rowan::TextRange; use super::invariants::ensure_both_branch_kinds; use super::visitor::{Visitor, walk, walk_alt_expr}; +use crate::SourceId; use crate::diagnostics::{DiagnosticKind, Diagnostics}; use crate::parser::{AltExpr, AltKind, Branch, Root}; -pub fn validate_alt_kinds(ast: &Root, diag: &mut Diagnostics) { - let mut visitor = AltKindsValidator { diag }; +pub fn validate_alt_kinds(source_id: SourceId, ast: &Root, diag: &mut Diagnostics) { + let mut visitor = AltKindsValidator { diag, source_id }; visitor.visit(ast); } struct AltKindsValidator<'a> { diag: &'a mut Diagnostics, + source_id: SourceId, } impl Visitor for AltKindsValidator<'_> { @@ -59,7 +61,11 @@ impl AltKindsValidator<'_> { let untagged_range = branch_range(untagged_branch); self.diag - .report(DiagnosticKind::MixedAltBranches, untagged_range) + .report( + self.source_id, + DiagnosticKind::MixedAltBranches, + untagged_range, + ) .related_to("tagged branch here", tagged_range) .emit(); } diff --git a/crates/plotnik-lib/src/query/dependencies.rs b/crates/plotnik-lib/src/query/dependencies.rs index a86059ff..0490f2de 100644 --- a/crates/plotnik-lib/src/query/dependencies.rs +++ b/crates/plotnik-lib/src/query/dependencies.rs @@ -9,6 +9,8 @@ //! dependents (like type inference). use indexmap::{IndexMap, IndexSet}; + +use super::source_map::SourceId; use rowan::TextRange; use crate::Diagnostics; @@ -40,12 +42,12 @@ pub fn analyze_dependencies<'q>(symbol_table: &SymbolTable<'q>) -> DependencyAna /// Validate recursion using the pre-computed dependency analysis. pub fn validate_recursion<'q>( analysis: &DependencyAnalysis<'q>, - ast: &Root, + ast_map: &IndexMap, symbol_table: &SymbolTable<'q>, diag: &mut Diagnostics, ) { let mut validator = RecursionValidator { - ast, + ast_map, symbol_table, diag, }; @@ -57,7 +59,7 @@ pub fn validate_recursion<'q>( // ----------------------------------------------------------------------------- struct RecursionValidator<'a, 'q, 'd> { - ast: &'a Root, + ast_map: &'a IndexMap, symbol_table: &'a SymbolTable<'q>, diag: &'d mut Diagnostics, } @@ -77,7 +79,7 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { let is_self_recursive = self .symbol_table .get(name) - .map(|body| collect_refs(body, self.symbol_table).contains(name)) + .map(|(_, body)| collect_refs(body, self.symbol_table).contains(name)) .unwrap_or(false); if !is_self_recursive { @@ -93,14 +95,14 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { let has_escape = scc.iter().any(|name| { self.symbol_table .get(*name) - .map(|body| expr_has_escape(body, &scc_set)) + .map(|(_, body)| expr_has_escape(body, &scc_set)) .unwrap_or(true) }); if !has_escape { // Find a cycle to report. Any cycle within the SCC is an infinite recursion loop // because there are no escape paths. - if let Some(raw_chain) = self.find_cycle(scc, &scc_set, |_, expr, target| { + if let Some(raw_chain) = self.find_cycle(scc, &scc_set, |_, _, expr, target| { find_ref_range(expr, target) }) { let chain = self.format_chain(raw_chain, false); @@ -112,7 +114,7 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { // 2. Check for infinite loops (Guarded Recursion Analysis) // Even if there is an escape, every recursive cycle must consume input (be guarded). // We look for a cycle composed entirely of unguarded references. - if let Some(raw_chain) = self.find_cycle(scc, &scc_set, |_, expr, target| { + if let Some(raw_chain) = self.find_cycle(scc, &scc_set, |_, _, expr, target| { find_unguarded_ref_range(expr, target) }) { let chain = self.format_chain(raw_chain, true); @@ -126,15 +128,16 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { &self, nodes: &[&'q str], domain: &IndexSet<&'q str>, - get_edge_location: impl Fn(&Self, &Expr, &str) -> Option, - ) -> Option> { + get_edge_location: impl Fn(&Self, SourceId, &Expr, &str) -> Option, + ) -> Option> { let mut adj = IndexMap::new(); for name in nodes { - if let Some(body) = self.symbol_table.get(*name) { + if let Some(&(source_id, ref body)) = self.symbol_table.get(*name) { let neighbors = domain .iter() .filter_map(|target| { - get_edge_location(self, body, target).map(|range| (*target, range)) + get_edge_location(self, source_id, body, target) + .map(|range| (*target, source_id, range)) }) .collect::>(); adj.insert(*name, neighbors); @@ -146,30 +149,30 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { fn format_chain( &self, - chain: Vec<(TextRange, &'q str)>, + chain: Vec<(SourceId, TextRange, &'q str)>, is_unguarded: bool, - ) -> Vec<(TextRange, String)> { + ) -> Vec<(SourceId, TextRange, String)> { if chain.len() == 1 { - let (range, target) = &chain[0]; + let (source_id, range, target) = &chain[0]; let msg = if is_unguarded { "references itself".to_string() } else { format!("{} references itself", target) }; - return vec![(*range, msg)]; + return vec![(*source_id, *range, msg)]; } let len = chain.len(); chain .into_iter() .enumerate() - .map(|(i, (range, target))| { + .map(|(i, (source_id, range, target))| { let msg = if i == len - 1 { format!("references {} (completing cycle)", target) } else { format!("references {}", target) }; - (range, msg) + (source_id, range, msg) }) .collect() } @@ -178,12 +181,12 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { &mut self, kind: DiagnosticKind, scc: &[&'q str], - chain: Vec<(TextRange, String)>, + chain: Vec<(SourceId, TextRange, String)>, ) { - let primary_loc = chain + let (primary_source, primary_loc) = chain .first() - .map(|(r, _)| *r) - .unwrap_or_else(|| TextRange::empty(0.into())); + .map(|(s, r, _)| (*s, *r)) + .unwrap_or_else(|| (SourceId::default(), TextRange::empty(0.into()))); let related_def = if scc.len() > 1 { self.find_def_info_containing(scc, primary_loc) @@ -191,9 +194,9 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { None }; - let mut builder = self.diag.report(kind, primary_loc); + let mut builder = self.diag.report(primary_source, kind, primary_loc); - for (range, msg) in chain { + for (_, range, msg) in chain { builder = builder.related_to(msg, range); } @@ -213,7 +216,7 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { .find(|name| { self.symbol_table .get(*name) - .map(|body| body.text_range().contains_range(range)) + .map(|(_, body)| body.text_range().contains_range(range)) .unwrap_or(false) }) .and_then(|name| { @@ -225,9 +228,10 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { } fn find_def_by_name(&self, name: &str) -> Option { - self.ast - .defs() - .find(|d| d.name().map(|n| n.text() == name).unwrap_or(false)) + self.ast_map.values().find_map(|ast| { + ast.defs() + .find(|d| d.name().map(|n| n.text() == name).unwrap_or(false)) + }) } } @@ -273,7 +277,7 @@ impl<'a, 'q> SccFinder<'a, 'q> { self.stack.push(name); self.on_stack.insert(name); - if let Some(body) = self.symbol_table.get(name) { + if let Some((_, body)) = self.symbol_table.get(name) { let refs = collect_refs(body, self.symbol_table); for ref_name in refs { // We've already resolved to canonical &'q str in collect_refs @@ -311,18 +315,18 @@ impl<'a, 'q> SccFinder<'a, 'q> { // ----------------------------------------------------------------------------- struct CycleFinder<'a, 'q> { - adj: &'a IndexMap<&'q str, Vec<(&'q str, TextRange)>>, + adj: &'a IndexMap<&'q str, Vec<(&'q str, SourceId, TextRange)>>, visited: IndexSet<&'q str>, on_path: IndexMap<&'q str, usize>, path: Vec<&'q str>, - edges: Vec, + edges: Vec<(SourceId, TextRange)>, } impl<'a, 'q> CycleFinder<'a, 'q> { fn find( nodes: &[&'q str], - adj: &'a IndexMap<&'q str, Vec<(&'q str, TextRange)>>, - ) -> Option> { + adj: &'a IndexMap<&'q str, Vec<(&'q str, SourceId, TextRange)>>, + ) -> Option> { let mut finder = Self { adj, visited: IndexSet::new(), @@ -339,7 +343,7 @@ impl<'a, 'q> CycleFinder<'a, 'q> { None } - fn dfs(&mut self, current: &'q str) -> Option> { + fn dfs(&mut self, current: &'q str) -> Option> { if self.on_path.contains_key(current) { return None; } @@ -353,18 +357,19 @@ impl<'a, 'q> CycleFinder<'a, 'q> { self.path.push(current); if let Some(neighbors) = self.adj.get(current) { - for (target, range) in neighbors { + for (target, source_id, range) in neighbors { if let Some(&start_index) = self.on_path.get(target) { // Cycle detected! let mut chain = Vec::new(); for i in start_index..self.path.len() - 1 { - chain.push((self.edges[i], self.path[i + 1])); + let (src, rng) = self.edges[i]; + chain.push((src, rng, self.path[i + 1])); } - chain.push((*range, *target)); + chain.push((*source_id, *range, *target)); return Some(chain); } - self.edges.push(*range); + self.edges.push((*source_id, *range)); if let Some(chain) = self.dfs(target) { return Some(chain); } diff --git a/crates/plotnik-lib/src/query/expr_arity.rs b/crates/plotnik-lib/src/query/expr_arity.rs index 3d56ac71..1d985b43 100644 --- a/crates/plotnik-lib/src/query/expr_arity.rs +++ b/crates/plotnik-lib/src/query/expr_arity.rs @@ -9,10 +9,12 @@ use std::collections::HashMap; +use super::query::AstMap; +use super::source_map::SourceId; use super::symbol_table::SymbolTable; use super::visitor::{Visitor, walk_expr, walk_field_expr}; use crate::diagnostics::{DiagnosticKind, Diagnostics}; -use crate::parser::{Expr, FieldExpr, Ref, Root, SeqExpr, SyntaxKind, SyntaxNode, ast}; +use crate::parser::{Expr, FieldExpr, Ref, SeqExpr, SyntaxKind, SyntaxNode, ast}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExprArity { @@ -24,25 +26,37 @@ pub enum ExprArity { pub type ExprArityTable = HashMap; pub fn infer_arities( - root: &Root, + ast_map: &AstMap, symbol_table: &SymbolTable, diag: &mut Diagnostics, ) -> ExprArityTable { - let ctx = ArityContext { - symbol_table, - arity_table: HashMap::new(), - diag, - }; - - let mut computer = ArityComputer { ctx }; - computer.visit(root); - let ctx = computer.ctx; + let mut arity_table = ExprArityTable::default(); + + for (&source_id, root) in ast_map { + let ctx = ArityContext { + symbol_table, + arity_table, + diag, + source_id, + }; + let mut computer = ArityComputer { ctx }; + computer.visit(root); + arity_table = computer.ctx.arity_table; + } - let mut validator = ArityValidator { ctx }; - validator.visit(root); - let ctx = validator.ctx; + for (&source_id, root) in ast_map { + let ctx = ArityContext { + symbol_table, + arity_table, + diag, + source_id, + }; + let mut validator = ArityValidator { ctx }; + validator.visit(root); + arity_table = validator.ctx.arity_table; + } - ctx.arity_table + arity_table } pub fn resolve_arity(node: &SyntaxNode, table: &ExprArityTable) -> Option { @@ -81,6 +95,7 @@ struct ArityContext<'a, 'd> { symbol_table: &'a SymbolTable<'a>, arity_table: ExprArityTable, diag: &'d mut Diagnostics, + source_id: SourceId, } impl ArityContext<'_, '_> { @@ -143,8 +158,7 @@ impl ArityContext<'_, '_> { self.symbol_table .get(name) - .cloned() - .map(|body| self.compute_arity(&body)) + .map(|(_, body)| self.compute_arity(body)) .unwrap_or(ExprArity::Invalid) } @@ -166,7 +180,11 @@ impl ArityContext<'_, '_> { .unwrap_or_else(|| "field".to_string()); self.diag - .report(DiagnosticKind::FieldSequenceValue, value.text_range()) + .report( + self.source_id, + DiagnosticKind::FieldSequenceValue, + value.text_range(), + ) .message(field_name) .emit(); } diff --git a/crates/plotnik-lib/src/query/link.rs b/crates/plotnik-lib/src/query/link.rs index 469d5e94..4f3cf51a 100644 --- a/crates/plotnik-lib/src/query/link.rs +++ b/crates/plotnik-lib/src/query/link.rs @@ -13,11 +13,12 @@ use plotnik_langs::Lang; use rowan::TextRange; use crate::diagnostics::{DiagnosticKind, Diagnostics}; -use crate::parser::Root; use crate::parser::ast::{self, Expr, NamedNode}; use crate::parser::cst::{SyntaxKind, SyntaxToken}; use crate::parser::token_src; +use super::query::AstMap; +use super::source_map::{SourceId, SourceMap}; use super::symbol_table::SymbolTable; use super::utils::find_similar; use super::visitor::{Visitor, walk}; @@ -27,27 +28,31 @@ use super::visitor::{Visitor, walk}; /// This function is decoupled from `Query` to allow easier testing and /// modularity. It orchestrates the resolution and validation phases. pub fn link<'q>( - root: &Root, - source: &'q str, + ast_map: &AstMap, + source_map: &'q SourceMap, lang: &Lang, symbol_table: &SymbolTable<'q>, node_type_ids: &mut HashMap<&'q str, Option>, node_field_ids: &mut HashMap<&'q str, Option>, diagnostics: &mut Diagnostics, ) { - let mut linker = Linker { - source, - lang, - symbol_table, - node_type_ids, - node_field_ids, - diagnostics, - }; - linker.link(root); + for (&source_id, root) in ast_map { + let mut linker = Linker { + source_map, + source_id, + lang, + symbol_table, + node_type_ids, + node_field_ids, + diagnostics, + }; + linker.link(root); + } } struct Linker<'a, 'q> { - source: &'q str, + source_map: &'q SourceMap, + source_id: SourceId, lang: &'a Lang, symbol_table: &'a SymbolTable<'q>, node_type_ids: &'a mut HashMap<&'q str, Option>, @@ -56,13 +61,17 @@ struct Linker<'a, 'q> { } impl<'a, 'q> Linker<'a, 'q> { - fn link(&mut self, root: &Root) { + fn source(&self) -> &'q str { + self.source_map.content(self.source_id) + } + + fn link(&mut self, root: &ast::Root) { self.resolve_node_types(root); self.resolve_fields(root); self.validate_structure(root); } - fn resolve_node_types(&mut self, root: &Root) { + fn resolve_node_types(&mut self, root: &ast::Root) { let mut collector = NodeTypeCollector { linker: self }; collector.visit(root); } @@ -86,7 +95,7 @@ impl<'a, 'q> Linker<'a, 'q> { } let resolved = self.lang.resolve_named_node(type_name); self.node_type_ids - .insert(token_src(&type_token, self.source), resolved); + .insert(token_src(&type_token, self.source()), resolved); if resolved.is_none() { let all_types = self.lang.all_named_node_kinds(); let max_dist = (type_name.len() / 3).clamp(2, 4); @@ -94,7 +103,11 @@ impl<'a, 'q> Linker<'a, 'q> { let mut builder = self .diagnostics - .report(DiagnosticKind::UnknownNodeType, type_token.text_range()) + .report( + self.source_id, + DiagnosticKind::UnknownNodeType, + type_token.text_range(), + ) .message(type_name); if let Some(similar) = suggestion { @@ -104,7 +117,7 @@ impl<'a, 'q> Linker<'a, 'q> { } } - fn resolve_fields(&mut self, root: &Root) { + fn resolve_fields(&mut self, root: &ast::Root) { let mut collector = FieldCollector { linker: self }; collector.visit(root); } @@ -119,7 +132,7 @@ impl<'a, 'q> Linker<'a, 'q> { } let resolved = self.lang.resolve_field(field_name); self.node_field_ids - .insert(token_src(&name_token, self.source), resolved); + .insert(token_src(&name_token, self.source()), resolved); if resolved.is_some() { return; } @@ -129,7 +142,11 @@ impl<'a, 'q> Linker<'a, 'q> { let mut builder = self .diagnostics - .report(DiagnosticKind::UnknownField, name_token.text_range()) + .report( + self.source_id, + DiagnosticKind::UnknownField, + name_token.text_range(), + ) .message(field_name); if let Some(similar) = suggestion { @@ -138,7 +155,7 @@ impl<'a, 'q> Linker<'a, 'q> { builder.emit(); } - fn validate_structure(&mut self, root: &Root) { + fn validate_structure(&mut self, root: &ast::Root) { let defs: Vec<_> = root.defs().collect(); for def in defs { let Some(body) = def.body() else { continue }; @@ -223,7 +240,7 @@ impl<'a, 'q> Linker<'a, 'q> { if !visited.insert(name.to_string()) { return; } - let Some(body) = self.symbol_table.get(name).cloned() else { + let Some((_, body)) = self.symbol_table.get(name).cloned() else { visited.swap_remove(name); return; }; @@ -351,7 +368,7 @@ impl<'a, 'q> Linker<'a, 'q> { let mut builder = self .diagnostics - .report(DiagnosticKind::FieldNotOnNodeType, range) + .report(self.source_id, DiagnosticKind::FieldNotOnNodeType, range) .message(field_name) .related_to(format!("on `{}`", parent_name), parent_range); @@ -436,12 +453,16 @@ impl Visitor for NodeTypeCollector<'_, '_, '_> { let resolved = self.linker.lang.resolve_anonymous_node(value); self.linker .node_type_ids - .insert(token_src(&value_token, self.linker.source), resolved); + .insert(token_src(&value_token, self.linker.source()), resolved); if resolved.is_none() { self.linker .diagnostics - .report(DiagnosticKind::UnknownNodeType, value_token.text_range()) + .report( + self.linker.source_id, + DiagnosticKind::UnknownNodeType, + value_token.text_range(), + ) .message(value) .emit(); } diff --git a/crates/plotnik-lib/src/query/mod.rs b/crates/plotnik-lib/src/query/mod.rs index e67ce71b..5de9913c 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -1,9 +1,11 @@ mod dump; mod invariants; mod printer; +mod source_map; mod utils; pub use printer::QueryPrinter; pub use query::{Query, QueryBuilder}; +pub use source_map::{SourceId, SourceMap}; pub mod alt_kinds; mod dependencies; diff --git a/crates/plotnik-lib/src/query/query.rs b/crates/plotnik-lib/src/query/query.rs index 66b1917c..125a041b 100644 --- a/crates/plotnik-lib/src/query/query.rs +++ b/crates/plotnik-lib/src/query/query.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::ops::{Deref, DerefMut}; +use indexmap::IndexMap; + use plotnik_core::{NodeFieldId, NodeTypeId}; use plotnik_langs::Lang; @@ -11,29 +13,32 @@ use crate::query::alt_kinds::validate_alt_kinds; use crate::query::dependencies::{self, DependencyAnalysis}; use crate::query::expr_arity::{ExprArity, ExprArityTable, infer_arities, resolve_arity}; use crate::query::link; +use crate::query::source_map::{SourceId, SourceMap}; use crate::query::symbol_table::{SymbolTable, resolve_names}; const DEFAULT_QUERY_PARSE_FUEL: u32 = 1_000_000; const DEFAULT_QUERY_PARSE_MAX_DEPTH: u32 = 4096; +pub type AstMap = IndexMap; + pub struct QueryConfig { pub query_parse_fuel: u32, pub query_parse_max_depth: u32, } -pub struct QueryBuilder<'q> { - pub src: &'q str, +pub struct QueryBuilder { + source_map: SourceMap, config: QueryConfig, } -impl<'q> QueryBuilder<'q> { - pub fn new(src: &'q str) -> Self { +impl QueryBuilder { + pub fn new(source_map: SourceMap) -> Self { let config = QueryConfig { query_parse_fuel: DEFAULT_QUERY_PARSE_FUEL, query_parse_max_depth: DEFAULT_QUERY_PARSE_MAX_DEPTH, }; - Self { src, config } + Self { source_map, config } } pub fn with_query_parse_fuel(mut self, fuel: u32) -> Self { @@ -46,60 +51,65 @@ impl<'q> QueryBuilder<'q> { self } - pub fn parse(self) -> crate::Result> { - let src = self.src; - let tokens = lex(src); - let parser = Parser::new( - self.src, - tokens, - self.config.query_parse_fuel, - self.config.query_parse_max_depth, - ); + pub fn parse(self) -> crate::Result { + let mut ast = IndexMap::new(); + let mut diag = Diagnostics::new(); + let mut total_fuel_consumed = 0u32; - let ParseResult { - ast, - mut diag, - fuel_consumed, - } = parser.parse()?; + for source in self.source_map.iter() { + let tokens = lex(source.content); + let parser = Parser::new( + source.content, + source.id, + tokens, + &mut diag, + self.config.query_parse_fuel, + self.config.query_parse_max_depth, + ); - validate_alt_kinds(&ast, &mut diag); + let res = parser.parse()?; + + validate_alt_kinds(source.id, &res.ast, &mut diag); + total_fuel_consumed = total_fuel_consumed.saturating_add(res.fuel_consumed); + ast.insert(source.id, res.ast); + } Ok(QueryParsed { - src, + source_map: self.source_map, diag, - ast, - fuel_consumed, + ast_map: ast, + fuel_consumed: total_fuel_consumed, }) } } #[derive(Debug)] -pub struct QueryParsed<'q> { - src: &'q str, +pub struct QueryParsed { + source_map: SourceMap, + ast_map: AstMap, diag: Diagnostics, - ast: Root, fuel_consumed: u32, } -impl<'q> QueryParsed<'q> { +impl QueryParsed { pub fn query_parser_fuel_consumed(&self) -> u32 { self.fuel_consumed } } -impl<'q> QueryParsed<'q> { - pub fn analyze(mut self) -> QueryAnalyzed<'q> { - let symbol_table = resolve_names(&self.ast, self.src, &mut self.diag); +impl QueryParsed { + pub fn analyze(mut self) -> QueryAnalyzed { + let symbol_table = resolve_names(&self.source_map, &self.ast_map, &mut self.diag); let dependency_analysis = dependencies::analyze_dependencies(&symbol_table); dependencies::validate_recursion( &dependency_analysis, - &self.ast, + &self.ast_map, &symbol_table, &mut self.diag, ); - let arity_table = infer_arities(&self.ast, &symbol_table, &mut self.diag); + let arity_table = infer_arities(&self.ast_map, &symbol_table, &mut self.diag); QueryAnalyzed { query_parsed: self, @@ -109,33 +119,29 @@ impl<'q> QueryParsed<'q> { } } - pub fn source(&self) -> &'q str { - self.src + pub fn source_map(&self) -> &SourceMap { + &self.source_map } pub fn diagnostics(&self) -> Diagnostics { self.diag.clone() } - pub fn root(&self) -> &Root { - &self.ast - } - - pub fn as_cst(&self) -> &SyntaxNode { - self.ast.as_cst() + pub fn asts(&self) -> &AstMap { + &self.ast_map } } -pub type Query<'q> = QueryAnalyzed<'q>; +pub type Query = QueryAnalyzed; -pub struct QueryAnalyzed<'q> { - query_parsed: QueryParsed<'q>, - pub symbol_table: SymbolTable<'q>, - dependency_analysis: DependencyAnalysis<'q>, +pub struct QueryAnalyzed { + query_parsed: QueryParsed, + pub symbol_table: SymbolTable, + dependency_analysis: DependencyAnalysis, arity_table: ExprArityTable, } -impl<'q> QueryAnalyzed<'q> { +impl QueryAnalyzed { pub fn is_valid(&self) -> bool { !self.diag.has_errors() } @@ -144,13 +150,13 @@ impl<'q> QueryAnalyzed<'q> { resolve_arity(node, &self.arity_table) } - pub fn link(mut self, lang: &Lang) -> LinkedQuery<'q> { - let mut type_ids: HashMap<&'q str, Option> = HashMap::new(); - let mut field_ids: HashMap<&'q str, Option> = HashMap::new(); + pub fn link(mut self, lang: &Lang) -> LinkedQuery { + let mut type_ids: NodeTypeIdTable = HashMap::new(); + let mut field_ids: NodeFieldIdTable = HashMap::new(); link::link( - &self.query_parsed.ast, - self.query_parsed.src, + &self.query_parsed.ast_map, + &self.query_parsed.source_map, lang, &self.symbol_table, &mut type_ids, @@ -166,46 +172,48 @@ impl<'q> QueryAnalyzed<'q> { } } -impl<'q> Deref for QueryAnalyzed<'q> { - type Target = QueryParsed<'q>; +impl Deref for QueryAnalyzed { + type Target = QueryParsed; fn deref(&self) -> &Self::Target { &self.query_parsed } } -impl<'q> DerefMut for QueryAnalyzed<'q> { +impl DerefMut for QueryAnalyzed { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.query_parsed } } -impl<'q> TryFrom<&'q str> for QueryAnalyzed<'q> { +impl TryFrom<&str> for QueryAnalyzed { type Error = crate::Error; - fn try_from(src: &'q str) -> crate::Result { - Ok(QueryBuilder::new(src).parse()?.analyze()) + fn try_from(src: &str) -> crate::Result { + Ok(QueryBuilder::new(SourceMap::one_liner(src)) + .parse()? + .analyze()) } } -type NodeTypeIdTable<'q> = HashMap<&'q str, Option>; -type NodeFieldIdTable<'q> = HashMap<&'q str, Option>; +type NodeTypeIdTable<'a> = HashMap<&'a str, Option>; +type NodeFieldIdTable<'a> = HashMap<&'a str, Option>; -pub struct LinkedQuery<'q> { - inner: QueryAnalyzed<'q>, - type_ids: NodeTypeIdTable<'q>, - field_ids: NodeFieldIdTable<'q>, +pub struct LinkedQuery<'a> { + inner: QueryAnalyzed, + type_ids: NodeTypeIdTable<'a>, + field_ids: NodeFieldIdTable<'a>, } -impl<'q> Deref for LinkedQuery<'q> { - type Target = QueryAnalyzed<'q>; +impl Deref for LinkedQuery<'_> { + type Target = QueryAnalyzed; fn deref(&self) -> &Self::Target { &self.inner } } -impl<'q> DerefMut for LinkedQuery<'q> { +impl DerefMut for LinkedQuery<'_> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } diff --git a/crates/plotnik-lib/src/query/source_map.rs b/crates/plotnik-lib/src/query/source_map.rs new file mode 100644 index 00000000..49ea6df2 --- /dev/null +++ b/crates/plotnik-lib/src/query/source_map.rs @@ -0,0 +1,312 @@ +//! Arena-based source storage for unified lifetimes. +//! +//! All sources are stored in a single contiguous buffer, allowing all string slices +//! to share the same lifetime as `&SourceMap`. This eliminates lifetime complexity +//! when multiple sources need to be analyzed together. + +use std::ops::Range; + +/// Lightweight handle to a source in a compilation session. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)] +pub struct SourceId(u32); + +/// Describes the origin of a source. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SourceKind<'a> { + /// A one-liner query passed directly (e.g., CLI `-q` argument). + OneLiner, + /// Input read from stdin. + Stdin, + /// A file with its path. + File(&'a str), +} + +impl SourceKind<'_> { + /// Returns the display name for diagnostics. + pub fn display_name(&self) -> &str { + match self { + SourceKind::OneLiner => "", + SourceKind::Stdin => "", + SourceKind::File(path) => path, + } + } +} + +/// A borrowed view of a source: id, kind, and content. +/// All string slices share the lifetime of the `SourceMap`. +#[derive(Copy, Clone, Debug)] +pub struct Source<'a> { + pub id: SourceId, + pub kind: SourceKind<'a>, + pub content: &'a str, +} + +impl<'a> Source<'a> { + /// Returns the content string. + pub fn as_str(&self) -> &'a str { + self.content + } +} + +/// Internal representation of source kind, storing ranges instead of slices. +#[derive(Clone, Debug)] +enum SourceKindEntry { + OneLiner, + Stdin, + /// Stores the byte range of the filename in the shared buffer. + File { + name_range: Range, + }, +} + +/// Metadata for a source in the arena. +#[derive(Clone, Debug)] +struct SourceEntry { + kind: SourceKindEntry, + /// Byte range of content in the shared buffer. + content_range: Range, +} + +/// Arena-based registry of all sources. Owns a single buffer. +/// +/// All content slices returned have the same lifetime as `&SourceMap`, +/// eliminating the need for separate lifetimes per source file. +#[derive(Clone, Debug, Default)] +pub struct SourceMap { + buffer: String, + entries: Vec, +} + +impl SourceMap { + pub fn new() -> Self { + Self::default() + } + + /// Add a one-liner source (CLI `-q` argument, REPL, tests). + pub fn add_one_liner(&mut self, content: &str) -> SourceId { + let content_range = self.push_content(content); + self.push_entry(SourceKindEntry::OneLiner, content_range) + } + + /// Add a source read from stdin. + pub fn add_stdin(&mut self, content: &str) -> SourceId { + let content_range = self.push_content(content); + self.push_entry(SourceKindEntry::Stdin, content_range) + } + + /// Add a file source with its path. + pub fn add_file(&mut self, path: &str, content: &str) -> SourceId { + let name_start = self.buffer.len() as u32; + self.buffer.push_str(path); + let name_end = self.buffer.len() as u32; + + let content_range = self.push_content(content); + + self.push_entry( + SourceKindEntry::File { + name_range: name_start..name_end, + }, + content_range, + ) + } + + /// Create a SourceMap with a single one-liner source. + /// Convenience for single-source use cases (CLI, REPL, tests). + pub fn one_liner(content: &str) -> Self { + let mut map = Self::new(); + map.add_one_liner(content); + map + } + + /// Get the content of a source by ID. + pub fn content(&self, id: SourceId) -> &str { + self.entries + .get(id.0 as usize) + .map(|e| self.slice(&e.content_range)) + .expect("invalid SourceId") + } + + /// Get the kind of a source by ID. + pub fn kind(&self, id: SourceId) -> SourceKind<'_> { + self.entries + .get(id.0 as usize) + .map(|e| self.resolve_kind(&e.kind)) + .expect("invalid SourceId") + } + + /// Number of sources in the map. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if the map is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Get a source by ID, returning a `Source` view. + pub fn get(&self, id: SourceId) -> Source<'_> { + let entry = self.entries.get(id.0 as usize).expect("invalid SourceId"); + Source { + id, + kind: self.resolve_kind(&entry.kind), + content: self.slice(&entry.content_range), + } + } + + /// Iterate over all sources as `Source` views. + pub fn iter(&self) -> impl Iterator> { + self.entries.iter().enumerate().map(|(idx, entry)| Source { + id: SourceId(idx as u32), + kind: self.resolve_kind(&entry.kind), + content: self.slice(&entry.content_range), + }) + } + + /// Reserve additional capacity in the buffer. + /// Useful when loading multiple files to avoid reallocations. + pub fn reserve(&mut self, additional: usize) { + self.buffer.reserve(additional); + } + + fn push_content(&mut self, content: &str) -> Range { + let start = self.buffer.len() as u32; + self.buffer.push_str(content); + let end = self.buffer.len() as u32; + start..end + } + + fn push_entry(&mut self, kind: SourceKindEntry, content_range: Range) -> SourceId { + let id = SourceId(self.entries.len() as u32); + self.entries.push(SourceEntry { + kind, + content_range, + }); + id + } + + fn slice(&self, range: &Range) -> &str { + &self.buffer[range.start as usize..range.end as usize] + } + + fn resolve_kind(&self, kind: &SourceKindEntry) -> SourceKind<'_> { + match kind { + SourceKindEntry::OneLiner => SourceKind::OneLiner, + SourceKindEntry::Stdin => SourceKind::Stdin, + SourceKindEntry::File { name_range } => SourceKind::File(self.slice(name_range)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_one_liner() { + let (map, id) = SourceMap::one_liner("hello world"); + + assert_eq!(map.content(id), "hello world"); + assert_eq!(map.kind(id), SourceKind::OneLiner); + assert_eq!(map.len(), 1); + } + + #[test] + fn stdin_source() { + let mut map = SourceMap::new(); + let id = map.add_stdin("from stdin"); + + assert_eq!(map.content(id), "from stdin"); + assert_eq!(map.kind(id), SourceKind::Stdin); + } + + #[test] + fn file_source() { + let mut map = SourceMap::new(); + let id = map.add_file("main.ptk", "Foo = (bar)"); + + assert_eq!(map.content(id), "Foo = (bar)"); + assert_eq!(map.kind(id), SourceKind::File("main.ptk")); + } + + #[test] + fn multiple_sources() { + let mut map = SourceMap::new(); + let a = map.add_file("a.ptk", "content a"); + let b = map.add_file("b.ptk", "content b"); + let c = map.add_one_liner("inline"); + let d = map.add_stdin("piped"); + + assert_eq!(map.len(), 4); + assert_eq!(map.content(a), "content a"); + assert_eq!(map.content(b), "content b"); + assert_eq!(map.content(c), "inline"); + assert_eq!(map.content(d), "piped"); + + assert_eq!(map.kind(a), SourceKind::File("a.ptk")); + assert_eq!(map.kind(b), SourceKind::File("b.ptk")); + assert_eq!(map.kind(c), SourceKind::OneLiner); + assert_eq!(map.kind(d), SourceKind::Stdin); + } + + #[test] + fn iteration() { + let mut map = SourceMap::new(); + map.add_file("a.ptk", "aaa"); + map.add_one_liner("bbb"); + + let items: Vec<_> = map.iter().collect(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].id, SourceId(0)); + assert_eq!(items[0].kind, SourceKind::File("a.ptk")); + assert_eq!(items[0].content, "aaa"); + assert_eq!(items[1].id, SourceId(1)); + assert_eq!(items[1].kind, SourceKind::OneLiner); + assert_eq!(items[1].content, "bbb"); + } + + #[test] + fn get_source() { + let mut map = SourceMap::new(); + let id = map.add_file("test.ptk", "hello"); + + let source = map.get(id); + assert_eq!(source.id, id); + assert_eq!(source.kind, SourceKind::File("test.ptk")); + assert_eq!(source.content, "hello"); + assert_eq!(source.as_str(), "hello"); + } + + #[test] + fn display_name() { + assert_eq!(SourceKind::OneLiner.display_name(), ""); + assert_eq!(SourceKind::Stdin.display_name(), ""); + assert_eq!(SourceKind::File("foo.ptk").display_name(), "foo.ptk"); + } + + #[test] + fn shared_buffer_lifetime() { + let mut map = SourceMap::new(); + map.add_file("a", "first"); + map.add_file("b", "second"); + + // Both slices have the same lifetime as &map + let a_content = map.content(SourceId(0)); + let b_content = map.content(SourceId(1)); + + // Can use both simultaneously + assert_eq!(format!("{} {}", a_content, b_content), "first second"); + } + + #[test] + fn multiple_stdin_sources() { + let mut map = SourceMap::new(); + let a = map.add_stdin("first stdin"); + let b = map.add_stdin("second stdin"); + + assert_eq!(map.content(a), "first stdin"); + assert_eq!(map.content(b), "second stdin"); + assert_eq!(map.kind(a), SourceKind::Stdin); + assert_eq!(map.kind(b), SourceKind::Stdin); + } +} diff --git a/crates/plotnik-lib/src/query/symbol_table.rs b/crates/plotnik-lib/src/query/symbol_table.rs index 529c0a0d..bbc64dc6 100644 --- a/crates/plotnik-lib/src/query/symbol_table.rs +++ b/crates/plotnik-lib/src/query/symbol_table.rs @@ -1,92 +1,108 @@ //! Symbol table: name resolution and reference checking. //! //! Two-pass approach: -//! 1. Collect all `Name = expr` definitions +//! 1. Collect all `Name = expr` definitions from all sources //! 2. Check that all `(UpperIdent)` references are defined use indexmap::IndexMap; -/// Sentinel name for unnamed definitions (bare expressions at root level). -/// Code generators can emit whatever name they want for this. -pub const UNNAMED_DEF: &str = "_"; - use crate::Diagnostics; use crate::diagnostics::DiagnosticKind; use crate::parser::{Root, ast, token_src}; +use super::source_map::{SourceId, SourceMap}; use super::visitor::Visitor; -pub type SymbolTable<'src> = IndexMap<&'src str, ast::Expr>; +/// Sentinel name for unnamed definitions (bare expressions at root level). +/// Code generators can emit whatever name they want for this. +pub const UNNAMED_DEF: &str = "_"; -pub fn resolve_names<'q>(ast: &Root, src: &'q str, diag: &mut Diagnostics) -> SymbolTable<'q> { - let symbol_table = SymbolTable::default(); - let ctx = Context { - src, - diag, - symbol_table, - }; +pub type SymbolTable<'src> = IndexMap<&'src str, (SourceId, ast::Expr)>; + +pub fn resolve_names<'q>( + source_map: &'q SourceMap, + ast_map: &IndexMap, + diag: &mut Diagnostics, +) -> SymbolTable<'q> { + let mut symbol_table = SymbolTable::default(); + + // Pass 1: collect definitions from all sources + for (&source_id, ast) in ast_map { + let src = source_map.content(source_id); + let mut resolver = ReferenceResolver { + src, + source_id, + diag, + symbol_table: &mut symbol_table, + }; + resolver.visit(ast); + } - let mut resolver = ReferenceResolver { ctx }; - resolver.visit(ast); - let ctx = resolver.ctx; + // Pass 2: validate references from all sources + for (&source_id, ast) in ast_map { + let src = source_map.content(source_id); + let mut validator = ReferenceValidator { + src, + diag, + symbol_table: &symbol_table, + }; + validator.visit(ast); + } - let mut validator = ReferenceValidator { ctx }; - validator.visit(ast); - validator.ctx.symbol_table + symbol_table } -struct Context<'q, 'd> { +struct ReferenceResolver<'q, 'd, 't> { src: &'q str, + source_id: SourceId, diag: &'d mut Diagnostics, - symbol_table: SymbolTable<'q>, -} - -struct ReferenceResolver<'q, 'd> { - pub ctx: Context<'q, 'd>, + symbol_table: &'t mut SymbolTable<'q>, } -impl Visitor for ReferenceResolver<'_, '_> { +impl Visitor for ReferenceResolver<'_, '_, '_> { fn visit_def(&mut self, def: &ast::Def) { let Some(body) = def.body() else { return }; if let Some(token) = def.name() { // Named definition: `Name = ...` - let name = token_src(&token, self.ctx.src); - if self.ctx.symbol_table.contains_key(name) { - self.ctx - .diag + let name = token_src(&token, self.src); + if self.symbol_table.contains_key(name) { + self.diag .report(DiagnosticKind::DuplicateDefinition, token.text_range()) .message(name) .emit(); } else { - self.ctx.symbol_table.insert(name, body); + self.symbol_table.insert(name, (self.source_id, body)); } } else { // Unnamed definition: `...` (root expression) // Parser already validates multiple unnamed defs; we keep the last one. - if self.ctx.symbol_table.contains_key(UNNAMED_DEF) { - self.ctx.symbol_table.shift_remove(UNNAMED_DEF); + if self.symbol_table.contains_key(UNNAMED_DEF) { + self.symbol_table.shift_remove(UNNAMED_DEF); } - self.ctx.symbol_table.insert(UNNAMED_DEF, body); + self.symbol_table + .insert(UNNAMED_DEF, (self.source_id, body)); } } } -struct ReferenceValidator<'q, 'd> { - pub ctx: Context<'q, 'd>, +struct ReferenceValidator<'q, 'd, 't> { + #[allow(dead_code)] + src: &'q str, + diag: &'d mut Diagnostics, + symbol_table: &'t SymbolTable<'q>, } -impl Visitor for ReferenceValidator<'_, '_> { +impl Visitor for ReferenceValidator<'_, '_, '_> { fn visit_ref(&mut self, r: &ast::Ref) { let Some(name_token) = r.name() else { return }; let name = name_token.text(); - if self.ctx.symbol_table.contains_key(name) { + if self.symbol_table.contains_key(name) { return; } - self.ctx - .diag + self.diag .report(DiagnosticKind::UndefinedReference, name_token.text_range()) .message(name) .emit(); From 34f0625614ce52939fa8fc7595fc59e8b6977808 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 20 Dec 2025 20:59:07 -0300 Subject: [PATCH 2/5] Fixes --- crates/plotnik-cli/src/commands/debug/mod.rs | 6 +- crates/plotnik-cli/src/commands/exec.rs | 6 +- crates/plotnik-cli/src/commands/types.rs | 4 +- crates/plotnik-lib/src/diagnostics/message.rs | 28 +-- crates/plotnik-lib/src/diagnostics/mod.rs | 21 +-- crates/plotnik-lib/src/diagnostics/printer.rs | 16 +- crates/plotnik-lib/src/diagnostics/tests.rs | 161 +++++++++++------- crates/plotnik-lib/src/parser/core.rs | 2 +- .../plotnik-lib/src/parser/grammar/atoms.rs | 2 +- .../src/parser/grammar/expressions.rs | 2 +- .../plotnik-lib/src/parser/grammar/fields.rs | 2 +- .../plotnik-lib/src/parser/grammar/items.rs | 4 +- .../src/parser/grammar/structures.rs | 8 +- .../src/parser/grammar/validation.rs | 2 +- crates/plotnik-lib/src/parser/invariants.rs | 2 +- .../parser/tests/recovery/coverage_tests.rs | 18 +- crates/plotnik-lib/src/query/alt_kinds.rs | 2 +- crates/plotnik-lib/src/query/dependencies.rs | 45 +++-- crates/plotnik-lib/src/query/dump.rs | 6 +- crates/plotnik-lib/src/query/link.rs | 12 +- crates/plotnik-lib/src/query/printer.rs | 64 +++++-- crates/plotnik-lib/src/query/query.rs | 55 ++++-- crates/plotnik-lib/src/query/query_tests.rs | 38 +++-- crates/plotnik-lib/src/query/source_map.rs | 19 ++- crates/plotnik-lib/src/query/symbol_table.rs | 19 ++- 25 files changed, 363 insertions(+), 181 deletions(-) diff --git a/crates/plotnik-cli/src/commands/debug/mod.rs b/crates/plotnik-cli/src/commands/debug/mod.rs index 2bd351c9..5251720d 100644 --- a/crates/plotnik-cli/src/commands/debug/mod.rs +++ b/crates/plotnik-cli/src/commands/debug/mod.rs @@ -101,8 +101,10 @@ pub fn run(args: DebugArgs) { if let Some(ref q) = query && !q.is_valid() { - let src = query_source.as_ref().unwrap(); - eprint!("{}", q.diagnostics().render_colored(src, args.color)); + eprint!( + "{}", + q.diagnostics().render_colored(q.source_map(), args.color) + ); std::process::exit(1); } } diff --git a/crates/plotnik-cli/src/commands/exec.rs b/crates/plotnik-cli/src/commands/exec.rs index 5a2b82b8..d791e908 100644 --- a/crates/plotnik-cli/src/commands/exec.rs +++ b/crates/plotnik-cli/src/commands/exec.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::{self, Read}; use std::path::PathBuf; -use plotnik_lib::QueryBuilder; +use plotnik_lib::{QueryBuilder, SourceMap}; use super::debug::source::resolve_lang; @@ -33,7 +33,7 @@ pub fn run(args: ExecArgs) { let lang = resolve_lang(&args.lang, &args.source_text, &args.source_file); // Parse query - let query_parsed = QueryBuilder::new(&query_source) + let query_parsed = QueryBuilder::new(SourceMap::one_liner(&query_source)) .parse() .unwrap_or_else(|e| { eprintln!("error: {}", e); @@ -46,7 +46,7 @@ pub fn run(args: ExecArgs) { // Link query against language let linked = query_analyzed.link(&lang); if !linked.is_valid() { - eprint!("{}", linked.diagnostics().render(&query_source)); + eprint!("{}", linked.diagnostics().render(linked.source_map())); std::process::exit(1); } diff --git a/crates/plotnik-cli/src/commands/types.rs b/crates/plotnik-cli/src/commands/types.rs index ca26907d..8259d255 100644 --- a/crates/plotnik-cli/src/commands/types.rs +++ b/crates/plotnik-cli/src/commands/types.rs @@ -41,13 +41,13 @@ pub fn run(args: TypesArgs) { .link(&lang); if !query.is_valid() { - eprint!("{}", query.diagnostics().render(&query_source)); + eprint!("{}", query.diagnostics().render(query.source_map())); std::process::exit(1); } // Link query against language if !query.is_valid() { - eprint!("{}", query.diagnostics().render(&query_source)); + eprint!("{}", query.diagnostics().render(query.source_map())); std::process::exit(1); } diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index 59cfcaf7..d53e34e2 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -289,16 +289,7 @@ pub struct RelatedInfo { } impl RelatedInfo { - /// Same-file related info (backward compat). - pub fn new(range: TextRange, message: impl Into) -> Self { - Self { - span: Span::anonymous(range), - message: message.into(), - } - } - - /// Cross-file related info. - pub fn in_source(source: SourceId, range: TextRange, message: impl Into) -> Self { + pub fn new(source: SourceId, range: TextRange, message: impl Into) -> Self { Self { span: Span::new(source, range), message: message.into(), @@ -325,10 +316,15 @@ pub(crate) struct DiagnosticMessage { } impl DiagnosticMessage { - pub(crate) fn new(kind: DiagnosticKind, range: TextRange, message: impl Into) -> Self { + pub(crate) fn new( + source: SourceId, + kind: DiagnosticKind, + range: TextRange, + message: impl Into, + ) -> Self { Self { kind, - source: SourceId::DEFAULT, + source, range, suppression_range: range, message: message.into(), @@ -338,8 +334,12 @@ impl DiagnosticMessage { } } - pub(crate) fn with_default_message(kind: DiagnosticKind, range: TextRange) -> Self { - Self::new(kind, range, kind.fallback_message()) + pub(crate) fn with_default_message( + source: SourceId, + kind: DiagnosticKind, + range: TextRange, + ) -> Self { + Self::new(source, kind, range, kind.fallback_message()) } pub(crate) fn severity(&self) -> Severity { diff --git a/crates/plotnik-lib/src/diagnostics/mod.rs b/crates/plotnik-lib/src/diagnostics/mod.rs index c131b3d0..77c9291d 100644 --- a/crates/plotnik-lib/src/diagnostics/mod.rs +++ b/crates/plotnik-lib/src/diagnostics/mod.rs @@ -21,6 +21,12 @@ pub struct Span { pub range: TextRange, } +impl Span { + pub fn new(source: SourceId, range: TextRange) -> Self { + Self { source, range } + } +} + #[derive(Debug, Clone, Default)] pub struct Diagnostics { messages: Vec, @@ -48,11 +54,9 @@ impl Diagnostics { kind: DiagnosticKind, range: TextRange, ) -> DiagnosticBuilder<'_> { - let mut msg = DiagnosticMessage::with_default_message(kind, range); - msg.source = source; DiagnosticBuilder { diagnostics: self, - message: msg, + message: DiagnosticMessage::with_default_message(source, kind, range), } } @@ -207,14 +211,7 @@ impl<'a> DiagnosticBuilder<'a> { self } - /// Related info in same file (backward compat). - pub fn related_to(mut self, msg: impl Into, range: TextRange) -> Self { - self.message.related.push(RelatedInfo::new(range, msg)); - self - } - - /// Related info in different file. - pub fn related_in( + pub fn related_to( mut self, source: SourceId, range: TextRange, @@ -222,7 +219,7 @@ impl<'a> DiagnosticBuilder<'a> { ) -> Self { self.message .related - .push(RelatedInfo::in_source(source, range, msg)); + .push(RelatedInfo::new(source, range, msg)); self } diff --git a/crates/plotnik-lib/src/diagnostics/printer.rs b/crates/plotnik-lib/src/diagnostics/printer.rs index b6ab2052..5b60176a 100644 --- a/crates/plotnik-lib/src/diagnostics/printer.rs +++ b/crates/plotnik-lib/src/diagnostics/printer.rs @@ -42,14 +42,11 @@ impl<'a> DiagnosticsPrinter<'a> { }; for (i, diag) in self.diagnostics.iter().enumerate() { - let Some(primary_content) = self.sources.content(diag.source) else { - continue; - }; - + let primary_content = self.sources.content(diag.source); let range = adjust_range(diag.range, primary_content.len()); let mut primary_snippet = Snippet::source(primary_content).line_start(1); - if let Some(name) = self.sources.name(diag.source) { + if let Some(name) = self.source_path(diag.source) { primary_snippet = primary_snippet.path(name); } primary_snippet = @@ -66,10 +63,11 @@ impl<'a> DiagnosticsPrinter<'a> { .span(adjust_range(related.span.range, primary_content.len())) .label(&related.message), ); - } else if let Some(related_content) = self.sources.content(related.span.source) { + } else { // Different file: create separate snippet + let related_content = self.sources.content(related.span.source); let mut snippet = Snippet::source(related_content).line_start(1); - if let Some(name) = self.sources.name(related.span.source) { + if let Some(name) = self.source_path(related.span.source) { snippet = snippet.path(name); } snippet = snippet.annotation( @@ -112,6 +110,10 @@ impl<'a> DiagnosticsPrinter<'a> { Ok(()) } + + fn source_path(&self, source: crate::query::SourceId) -> Option<&'a str> { + self.sources.path(source) + } } fn severity_to_level(severity: Severity) -> Level<'static> { diff --git a/crates/plotnik-lib/src/diagnostics/tests.rs b/crates/plotnik-lib/src/diagnostics/tests.rs index c7b4b433..98b9bb62 100644 --- a/crates/plotnik-lib/src/diagnostics/tests.rs +++ b/crates/plotnik-lib/src/diagnostics/tests.rs @@ -10,9 +10,13 @@ fn severity_display() { #[test] fn report_with_default_message() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::ExpectedTypeName, TextRange::new(0.into(), 5.into()), ) @@ -24,9 +28,13 @@ fn report_with_default_message() { #[test] fn report_with_custom_message() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::ExpectedTypeName, TextRange::new(0.into(), 5.into()), ) @@ -37,32 +45,24 @@ fn report_with_custom_message() { 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()); -} - #[test] fn builder_with_related() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello world!"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 5.into()), ) .message("primary") - .related_to("related info", TextRange::new(6.into(), 10.into())) + .related_to(id, TextRange::new(6.into(), 10.into()), "related info") .emit(); assert_eq!(diagnostics.len(), 1); - let map = SourceMap::one_liner("hello world!"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; primary | @@ -73,9 +73,13 @@ fn builder_with_related() { #[test] fn builder_with_fix() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello world"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::InvalidFieldEquals, TextRange::new(0.into(), 5.into()), ) @@ -83,8 +87,7 @@ fn builder_with_fix() { .fix("apply this fix", "fixed") .emit(); - let (map, _) = SourceMap::anonymous("hello world"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: use `:` for field constraints, not `=`; fixable | @@ -101,20 +104,23 @@ fn builder_with_fix() { #[test] fn builder_with_all_options() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello world stuff!"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, 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())) + .related_to(id, TextRange::new(6.into(), 11.into()), "see also") + .related_to(id, TextRange::new(12.into(), 17.into()), "and here") .fix("try this", "HELLO") .emit(); - let (map, _) = SourceMap::anonymous("hello world stuff!"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; main error | @@ -133,43 +139,48 @@ fn builder_with_all_options() { #[test] fn printer_colored() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::EmptyTree, TextRange::new(0.into(), 5.into()), ) .message("test") .emit(); - let (map, _) = SourceMap::anonymous("hello"); - let result = diagnostics.printer_with(&map).colored(true).render(); + let result = diagnostics.printer(&map).colored(true).render(); assert!(result.contains("test")); assert!(result.contains('\x1b')); } #[test] fn printer_empty_diagnostics() { + let map = SourceMap::one_liner("source"); let diagnostics = Diagnostics::new(); - let (map, _) = SourceMap::anonymous("source"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); assert!(result.is_empty()); } #[test] fn printer_with_custom_path() { + let mut map = SourceMap::new(); + let id = map.add_file("test.pql", "hello world"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::UndefinedReference, TextRange::new(0.into(), 5.into()), ) .message("test error") .emit(); - let mut map = SourceMap::new(); - map.add("test.pql", "hello world"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: `test error` is not defined --> test.pql:1:1 @@ -181,17 +192,20 @@ fn printer_with_custom_path() { #[test] fn printer_zero_width_span() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::ExpectedExpression, TextRange::empty(0.into()), ) .message("zero width error") .emit(); - let (map, _) = SourceMap::anonymous("hello"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: expected an expression; zero width error | @@ -202,18 +216,21 @@ fn printer_zero_width_span() { #[test] fn printer_related_zero_width() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello world!"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 5.into()), ) .message("primary") - .related_to("zero width related", TextRange::empty(6.into())) + .related_to(id, TextRange::empty(6.into()), "zero width related") .emit(); - let (map, _) = SourceMap::anonymous("hello world!"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; primary | @@ -224,9 +241,13 @@ fn printer_related_zero_width() { #[test] fn printer_multiple_diagnostics() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello world!"); + let mut diagnostics = Diagnostics::new(); diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 5.into()), ) @@ -234,14 +255,14 @@ fn printer_multiple_diagnostics() { .emit(); diagnostics .report( + id, DiagnosticKind::UndefinedReference, TextRange::new(6.into(), 10.into()), ) .message("second error") .emit(); - let (map, _) = SourceMap::anonymous("hello world!"); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: missing closing `)`; first error | @@ -257,12 +278,16 @@ fn printer_multiple_diagnostics() { #[test] fn diagnostics_collection_methods() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("ab"); + let mut diagnostics = Diagnostics::new(); diagnostics - .report(DiagnosticKind::UnclosedTree, TextRange::empty(0.into())) + .report(id, DiagnosticKind::UnclosedTree, TextRange::empty(0.into())) .emit(); diagnostics .report( + id, DiagnosticKind::UndefinedReference, TextRange::empty(1.into()), ) @@ -352,16 +377,21 @@ fn diagnostic_kind_message_rendering() { #[test] fn filtered_no_suppression_disjoint_spans() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("0123456789012345"); + let mut diagnostics = Diagnostics::new(); // Two errors at different positions - both should show diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 5.into()), ) .emit(); diagnostics .report( + id, DiagnosticKind::UndefinedReference, TextRange::new(10.into(), 15.into()), ) @@ -373,16 +403,21 @@ fn filtered_no_suppression_disjoint_spans() { #[test] fn filtered_suppresses_lower_priority_contained() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("01234567890123456789"); + let mut diagnostics = Diagnostics::new(); // Higher priority error (UnclosedTree) contains lower priority (UnnamedDef) diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 20.into()), ) .emit(); diagnostics .report( + id, DiagnosticKind::UnnamedDef, TextRange::new(5.into(), 15.into()), ) @@ -395,16 +430,21 @@ fn filtered_suppresses_lower_priority_contained() { #[test] fn filtered_consequence_suppressed_by_structural() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("01234567890123456789"); + let mut diagnostics = Diagnostics::new(); // Consequence error (UnnamedDef) suppressed when structural error (UnclosedTree) exists diagnostics .report( + id, DiagnosticKind::UnnamedDef, TextRange::new(0.into(), 20.into()), ) .emit(); diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(5.into(), 15.into()), ) @@ -418,16 +458,21 @@ fn filtered_consequence_suppressed_by_structural() { #[test] fn filtered_same_span_higher_priority_wins() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("0123456789"); + let mut diagnostics = Diagnostics::new(); // Two errors at exact same span diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 10.into()), ) .emit(); diagnostics .report( + id, DiagnosticKind::UnnamedDef, TextRange::new(0.into(), 10.into()), ) @@ -447,10 +492,14 @@ fn filtered_empty_diagnostics() { #[test] fn render_filtered() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("(function_declaration"); + let mut diagnostics = Diagnostics::new(); // Add overlapping errors where one should be suppressed diagnostics .report( + id, DiagnosticKind::UnclosedTree, TextRange::new(0.into(), 20.into()), ) @@ -458,14 +507,14 @@ fn render_filtered() { .emit(); diagnostics .report( + id, DiagnosticKind::UnnamedDef, TextRange::new(5.into(), 15.into()), ) .message("unnamed def") .emit(); - let (map, _) = SourceMap::anonymous("(function_declaration"); - let result = diagnostics.render_filtered_with(&map); + let result = diagnostics.render_filtered(&map); // Should only show the unclosed tree error assert!(result.contains("unclosed tree")); assert!(!result.contains("unnamed def")); @@ -476,21 +525,21 @@ fn render_filtered() { #[test] fn multi_file_cross_file_related() { let mut map = SourceMap::new(); - let file_a = map.add("a.ptk", "Foo = (bar)"); - let file_b = map.add("b.ptk", "(Foo) @x"); + let file_a = map.add_file("a.ptk", "Foo = (bar)"); + let file_b = map.add_file("b.ptk", "(Foo) @x"); let mut diagnostics = Diagnostics::new(); diagnostics - .report_in( + .report( file_b, DiagnosticKind::UndefinedReference, TextRange::new(1.into(), 4.into()), ) .message("Foo") - .related_in(file_a, TextRange::new(0.into(), 3.into()), "defined here") + .related_to(file_a, TextRange::new(0.into(), 3.into()), "defined here") .emit(); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: `Foo` is not defined --> b.ptk:1:2 @@ -508,24 +557,24 @@ fn multi_file_cross_file_related() { #[test] fn multi_file_same_file_related() { let mut map = SourceMap::new(); - let file_a = map.add("main.ptk", "Foo = (bar) Foo = (baz)"); + let file_a = map.add_file("main.ptk", "Foo = (bar) Foo = (baz)"); let mut diagnostics = Diagnostics::new(); diagnostics - .report_in( + .report( file_a, DiagnosticKind::DuplicateDefinition, TextRange::new(12.into(), 15.into()), ) .message("Foo") - .related_in( + .related_to( file_a, TextRange::new(0.into(), 3.into()), "first defined here", ) .emit(); - let result = diagnostics.printer_with(&map).render(); + let result = diagnostics.printer(&map).render(); insta::assert_snapshot!(result, @r" error: `Foo` is already defined --> main.ptk:1:13 @@ -540,25 +589,23 @@ fn multi_file_same_file_related() { #[test] fn source_map_iteration() { let mut map = SourceMap::new(); - map.add("a.ptk", "content a"); - map.add("b.ptk", "content b"); + map.add_file("a.ptk", "content a"); + map.add_file("b.ptk", "content b"); assert_eq!(map.len(), 2); assert!(!map.is_empty()); - let names: Vec<_> = map.iter().map(|(_, name, _)| name).collect(); - assert_eq!(names, vec![Some("a.ptk"), Some("b.ptk")]); + let contents: Vec<_> = map.iter().map(|s| s.content).collect(); + assert_eq!(contents, vec!["content a", "content b"]); } #[test] -fn source_id_default() { - assert_eq!(SourceId::DEFAULT, SourceId::default()); -} +fn span_new() { + let mut map = SourceMap::new(); + let id = map.add_one_liner("hello"); -#[test] -fn span_anonymous() { let range = TextRange::new(5.into(), 10.into()); - let span = Span::anonymous(range); - assert_eq!(span.source, SourceId::DEFAULT); + let span = Span::new(id, range); + assert_eq!(span.source, id); assert_eq!(span.range, range); } diff --git a/crates/plotnik-lib/src/parser/core.rs b/crates/plotnik-lib/src/parser/core.rs index 810489ed..363bf458 100644 --- a/crates/plotnik-lib/src/parser/core.rs +++ b/crates/plotnik-lib/src/parser/core.rs @@ -372,7 +372,7 @@ impl<'src, 'diag> Parser<'src, 'diag> { self.diagnostics .report(self.source_id, kind, full_range) .message(message) - .related_to(related_msg, open_range) + .related_to(self.source_id, open_range, related_msg) .emit(); } diff --git a/crates/plotnik-lib/src/parser/grammar/atoms.rs b/crates/plotnik-lib/src/parser/grammar/atoms.rs index 451d8706..e0fa046c 100644 --- a/crates/plotnik-lib/src/parser/grammar/atoms.rs +++ b/crates/plotnik-lib/src/parser/grammar/atoms.rs @@ -1,7 +1,7 @@ use crate::parser::Parser; use crate::parser::cst::SyntaxKind; -impl Parser<'_> { +impl Parser<'_, '_> { pub(crate) fn parse_wildcard(&mut self) { self.start_node(SyntaxKind::Wildcard); self.expect(SyntaxKind::Underscore, "'_' wildcard"); diff --git a/crates/plotnik-lib/src/parser/grammar/expressions.rs b/crates/plotnik-lib/src/parser/grammar/expressions.rs index 6ff5dc4e..0448958f 100644 --- a/crates/plotnik-lib/src/parser/grammar/expressions.rs +++ b/crates/plotnik-lib/src/parser/grammar/expressions.rs @@ -3,7 +3,7 @@ use crate::parser::Parser; use crate::parser::cst::SyntaxKind; use crate::parser::cst::token_sets::EXPR_FIRST_TOKENS; -impl Parser<'_> { +impl Parser<'_, '_> { /// Parse an expression, or emit an error if current token can't start one. /// Returns `true` if a valid expression was parsed, `false` on error. pub(crate) fn parse_expr_or_error(&mut self) -> bool { diff --git a/crates/plotnik-lib/src/parser/grammar/fields.rs b/crates/plotnik-lib/src/parser/grammar/fields.rs index f2dae388..25fc56c4 100644 --- a/crates/plotnik-lib/src/parser/grammar/fields.rs +++ b/crates/plotnik-lib/src/parser/grammar/fields.rs @@ -6,7 +6,7 @@ use crate::parser::cst::SyntaxKind; use crate::parser::cst::token_sets::{EXPR_FIRST_TOKENS, QUANTIFIERS}; use crate::parser::lexer::token_text; -impl Parser<'_> { +impl Parser<'_, '_> { /// `@name` | `@name :: Type` pub(crate) fn parse_capture_suffix(&mut self) { self.bump(); // consume At diff --git a/crates/plotnik-lib/src/parser/grammar/items.rs b/crates/plotnik-lib/src/parser/grammar/items.rs index 412522d2..9a096233 100644 --- a/crates/plotnik-lib/src/parser/grammar/items.rs +++ b/crates/plotnik-lib/src/parser/grammar/items.rs @@ -6,7 +6,7 @@ use crate::parser::cst::SyntaxKind; use crate::parser::cst::token_sets::{EXPR_FIRST_TOKENS, ROOT_EXPR_FIRST_TOKENS}; use crate::parser::lexer::token_text; -impl Parser<'_> { +impl Parser<'_, '_> { pub fn parse_root(&mut self) { self.start_node(SyntaxKind::Root); @@ -29,7 +29,7 @@ impl Parser<'_> { let span = TextRange::new(start, end); let def_text = &self.source[usize::from(start)..usize::from(end)]; self.diagnostics - .report(DiagnosticKind::UnnamedDef, span) + .report(self.source_id, DiagnosticKind::UnnamedDef, span) .message(format!("give it a name like `Name = {}`", def_text.trim())) .emit(); } diff --git a/crates/plotnik-lib/src/parser/grammar/structures.rs b/crates/plotnik-lib/src/parser/grammar/structures.rs index ba21645c..de097bfe 100644 --- a/crates/plotnik-lib/src/parser/grammar/structures.rs +++ b/crates/plotnik-lib/src/parser/grammar/structures.rs @@ -9,7 +9,7 @@ use crate::parser::cst::token_sets::{ use crate::parser::cst::{SyntaxKind, TokenSet}; use crate::parser::lexer::token_text; -impl Parser<'_> { +impl Parser<'_, '_> { /// `(type ...)` | `(_ ...)` | `(ERROR)` | `(MISSING ...)` | `(RefName)` | `(expr/subtype)` /// PascalCase without children → Ref; with children → error but parses as Tree. pub(crate) fn parse_tree(&mut self) { @@ -140,7 +140,11 @@ impl Parser<'_> { if let Some(name) = &ref_name { self.diagnostics - .report(DiagnosticKind::RefCannotHaveChildren, children_span) + .report( + self.source_id, + DiagnosticKind::RefCannotHaveChildren, + children_span, + ) .message(name) .emit(); } diff --git a/crates/plotnik-lib/src/parser/grammar/validation.rs b/crates/plotnik-lib/src/parser/grammar/validation.rs index b2388abf..600d3563 100644 --- a/crates/plotnik-lib/src/parser/grammar/validation.rs +++ b/crates/plotnik-lib/src/parser/grammar/validation.rs @@ -4,7 +4,7 @@ use super::utils::{to_pascal_case, to_snake_case}; use crate::diagnostics::DiagnosticKind; use crate::parser::Parser; -impl Parser<'_> { +impl Parser<'_, '_> { /// Validate capture name follows plotnik convention (snake_case). pub(crate) fn validate_capture_name(&mut self, name: &str, span: TextRange) { if name.contains('.') { diff --git a/crates/plotnik-lib/src/parser/invariants.rs b/crates/plotnik-lib/src/parser/invariants.rs index 425a021c..1ff2b86b 100644 --- a/crates/plotnik-lib/src/parser/invariants.rs +++ b/crates/plotnik-lib/src/parser/invariants.rs @@ -6,7 +6,7 @@ use crate::parser::SyntaxKind; use super::core::Parser; -impl Parser<'_> { +impl Parser<'_, '_> { #[inline] pub(super) fn ensure_progress(&self) { assert!( 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 ad32e3d9..1c637fcd 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs @@ -13,7 +13,7 @@ fn deeply_nested_trees_hit_recursion_limit() { input.push(')'); } - let result = QueryBuilder::new(&input) + let result = QueryBuilder::from_str(&input) .with_query_parse_recursion_limit(depth) .parse(); @@ -35,7 +35,7 @@ fn deeply_nested_sequences_hit_recursion_limit() { input.push('}'); } - let result = QueryBuilder::new(&input) + let result = QueryBuilder::from_str(&input) .with_query_parse_recursion_limit(depth) .parse(); @@ -57,7 +57,7 @@ fn deeply_nested_alternations_hit_recursion_limit() { input.push(']'); } - let result = QueryBuilder::new(&input) + let result = QueryBuilder::from_str(&input) .with_query_parse_recursion_limit(depth) .parse(); @@ -76,7 +76,9 @@ fn many_trees_exhaust_exec_fuel() { input.push_str("(a) "); } - let result = QueryBuilder::new(&input).with_query_parse_fuel(100).parse(); + let result = QueryBuilder::from_str(&input) + .with_query_parse_fuel(100) + .parse(); assert!( matches!(result, Err(crate::Error::ExecFuelExhausted)), @@ -98,7 +100,9 @@ fn many_branches_exhaust_exec_fuel() { } input.push(']'); - let result = QueryBuilder::new(&input).with_query_parse_fuel(100).parse(); + let result = QueryBuilder::from_str(&input) + .with_query_parse_fuel(100) + .parse(); assert!( matches!(result, Err(crate::Error::ExecFuelExhausted)), @@ -120,7 +124,9 @@ fn many_fields_exhaust_exec_fuel() { } input.push(')'); - let result = QueryBuilder::new(&input).with_query_parse_fuel(100).parse(); + let result = QueryBuilder::from_str(&input) + .with_query_parse_fuel(100) + .parse(); assert!( matches!(result, Err(crate::Error::ExecFuelExhausted)), diff --git a/crates/plotnik-lib/src/query/alt_kinds.rs b/crates/plotnik-lib/src/query/alt_kinds.rs index 5fd533c6..0f2d31a1 100644 --- a/crates/plotnik-lib/src/query/alt_kinds.rs +++ b/crates/plotnik-lib/src/query/alt_kinds.rs @@ -66,7 +66,7 @@ impl AltKindsValidator<'_> { DiagnosticKind::MixedAltBranches, untagged_range, ) - .related_to("tagged branch here", tagged_range) + .related_to(self.source_id, tagged_range, "tagged branch here") .emit(); } } diff --git a/crates/plotnik-lib/src/query/dependencies.rs b/crates/plotnik-lib/src/query/dependencies.rs index 0490f2de..d3d5b9ed 100644 --- a/crates/plotnik-lib/src/query/dependencies.rs +++ b/crates/plotnik-lib/src/query/dependencies.rs @@ -31,6 +31,25 @@ pub struct DependencyAnalysis<'q> { pub sccs: Vec>, } +/// Owned variant of `DependencyAnalysis` for storage in pipeline structs. +#[derive(Debug, Clone, Default)] +pub struct DependencyAnalysisOwned { + #[allow(dead_code)] + pub sccs: Vec>, +} + +impl DependencyAnalysis<'_> { + pub fn to_owned(&self) -> DependencyAnalysisOwned { + DependencyAnalysisOwned { + sccs: self + .sccs + .iter() + .map(|scc| scc.iter().map(|s| (*s).to_owned()).collect()) + .collect(), + } + } +} + /// Analyze dependencies between definitions. /// /// Returns the SCCs in reverse topological order. @@ -196,12 +215,12 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { let mut builder = self.diag.report(primary_source, kind, primary_loc); - for (_, range, msg) in chain { - builder = builder.related_to(msg, range); + for (source_id, range, msg) in chain { + builder = builder.related_to(source_id, range, msg); } - if let Some((msg, range)) = related_def { - builder = builder.related_to(msg, range); + if let Some((source_id, msg, range)) = related_def { + builder = builder.related_to(source_id, range, msg); } builder.emit(); @@ -211,7 +230,7 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { &self, scc: &[&'q str], range: TextRange, - ) -> Option<(String, TextRange)> { + ) -> Option<(SourceId, String, TextRange)> { scc.iter() .find(|name| { self.symbol_table @@ -220,17 +239,23 @@ impl<'a, 'q, 'd> RecursionValidator<'a, 'q, 'd> { .unwrap_or(false) }) .and_then(|name| { - self.find_def_by_name(name).and_then(|def| { - def.name() - .map(|n| (format!("{} is defined here", name), n.text_range())) + self.find_def_by_name(name).and_then(|(source_id, def)| { + def.name().map(|n| { + ( + source_id, + format!("{} is defined here", name), + n.text_range(), + ) + }) }) }) } - fn find_def_by_name(&self, name: &str) -> Option { - self.ast_map.values().find_map(|ast| { + fn find_def_by_name(&self, name: &str) -> Option<(SourceId, Def)> { + self.ast_map.iter().find_map(|(source_id, ast)| { ast.defs() .find(|d| d.name().map(|n| n.text() == name).unwrap_or(false)) + .map(|def| (*source_id, def)) }) } } diff --git a/crates/plotnik-lib/src/query/dump.rs b/crates/plotnik-lib/src/query/dump.rs index 26580f57..b8f28dd8 100644 --- a/crates/plotnik-lib/src/query/dump.rs +++ b/crates/plotnik-lib/src/query/dump.rs @@ -4,7 +4,7 @@ mod test_helpers { use crate::Query; - impl Query<'_> { + impl Query { pub fn dump_cst(&self) -> String { self.printer().raw(true).dump() } @@ -30,11 +30,11 @@ mod test_helpers { } pub fn dump_diagnostics(&self) -> String { - self.diagnostics().render_filtered(self.source()) + self.diagnostics().render_filtered(self.source_map()) } pub fn dump_diagnostics_raw(&self) -> String { - self.diagnostics().render(self.source()) + self.diagnostics().render(self.source_map()) } } } diff --git a/crates/plotnik-lib/src/query/link.rs b/crates/plotnik-lib/src/query/link.rs index 4f3cf51a..d82e5e4a 100644 --- a/crates/plotnik-lib/src/query/link.rs +++ b/crates/plotnik-lib/src/query/link.rs @@ -19,7 +19,7 @@ use crate::parser::token_src; use super::query::AstMap; use super::source_map::{SourceId, SourceMap}; -use super::symbol_table::SymbolTable; +use super::symbol_table::SymbolTableOwned; use super::utils::find_similar; use super::visitor::{Visitor, walk}; @@ -31,7 +31,7 @@ pub fn link<'q>( ast_map: &AstMap, source_map: &'q SourceMap, lang: &Lang, - symbol_table: &SymbolTable<'q>, + symbol_table: &SymbolTableOwned, node_type_ids: &mut HashMap<&'q str, Option>, node_field_ids: &mut HashMap<&'q str, Option>, diagnostics: &mut Diagnostics, @@ -54,7 +54,7 @@ struct Linker<'a, 'q> { source_map: &'q SourceMap, source_id: SourceId, lang: &'a Lang, - symbol_table: &'a SymbolTable<'q>, + symbol_table: &'a SymbolTableOwned, node_type_ids: &'a mut HashMap<&'q str, Option>, node_field_ids: &'a mut HashMap<&'q str, Option>, diagnostics: &'a mut Diagnostics, @@ -370,7 +370,11 @@ impl<'a, 'q> Linker<'a, 'q> { .diagnostics .report(self.source_id, DiagnosticKind::FieldNotOnNodeType, range) .message(field_name) - .related_to(format!("on `{}`", parent_name), parent_range); + .related_to( + self.source_id, + parent_range, + format!("on `{}`", parent_name), + ); if valid_fields.is_empty() { builder = builder.hint(format!("`{}` has no fields", parent_name)); diff --git a/crates/plotnik-lib/src/query/printer.rs b/crates/plotnik-lib/src/query/printer.rs index dbbf5bfb..d6ef4e0e 100644 --- a/crates/plotnik-lib/src/query/printer.rs +++ b/crates/plotnik-lib/src/query/printer.rs @@ -9,9 +9,10 @@ use crate::parser::{self as ast, Expr, SyntaxNode}; use super::Query; use super::expr_arity::ExprArity; +use super::source_map::SourceKind; -pub struct QueryPrinter<'q, 'src> { - query: &'q Query<'src>, +pub struct QueryPrinter<'q> { + query: &'q Query, raw: bool, trivia: bool, arities: bool, @@ -19,8 +20,8 @@ pub struct QueryPrinter<'q, 'src> { symbols: bool, } -impl<'q, 'src> QueryPrinter<'q, 'src> { - pub fn new(query: &'q Query<'src>) -> Self { +impl<'q> QueryPrinter<'q> { + pub fn new(query: &'q Query) -> Self { Self { query, raw: false, @@ -66,10 +67,42 @@ impl<'q, 'src> QueryPrinter<'q, 'src> { if self.symbols { return self.format_symbols(w); } - if self.raw { - return self.format_cst(self.query.as_cst(), 0, w); + + let source_map = self.query.source_map(); + let ast_map = self.query.asts(); + let show_headers = self.should_show_headers(source_map); + let mut first = true; + + for source in source_map.iter() { + let Some(root) = ast_map.get(&source.id) else { + continue; + }; + + if show_headers { + if !first { + writeln!(w)?; + } + writeln!(w, "# {}", source.kind.display_name())?; + } + + if self.raw { + self.format_cst(root.as_cst(), 0, w)?; + } else { + self.format_root(root, w)?; + } + + first = false; } - self.format_root(self.query.root(), w) + + Ok(()) + } + + fn should_show_headers(&self, source_map: &super::source_map::SourceMap) -> bool { + source_map.len() > 1 + || source_map + .iter() + .next() + .is_some_and(|s| !matches!(s.kind, SourceKind::OneLiner)) } fn format_symbols(&self, w: &mut impl Write) -> std::fmt::Result { @@ -80,12 +113,15 @@ impl<'q, 'src> QueryPrinter<'q, 'src> { return Ok(()); } - let defined: IndexSet<&str> = symbols.keys().copied().collect(); + let defined: IndexSet<&str> = symbols.keys().map(String::as_str).collect(); + // Collect body nodes from all files let mut body_nodes: HashMap = HashMap::new(); - for def in self.query.root().defs() { - if let (Some(name_tok), Some(body)) = (def.name(), def.body()) { - body_nodes.insert(name_tok.text().to_string(), body.as_cst().clone()); + for root in self.query.asts().values() { + for def in root.defs() { + if let (Some(name_tok), Some(body)) = (def.name(), def.body()) { + body_nodes.insert(name_tok.text().to_string(), body.as_cst().clone()); + } } } @@ -125,7 +161,7 @@ impl<'q, 'src> QueryPrinter<'q, 'src> { writeln!(w, "{}{}{}", prefix, name, card)?; visited.insert(name.to_string()); - if let Some(body) = self.query.symbol_table.get(name) { + if let Some((_, body)) = self.query.symbol_table.get(name) { let refs_set = collect_refs(body); let mut refs: Vec<_> = refs_set.iter().map(|s| s.as_str()).collect(); refs.sort(); @@ -368,8 +404,8 @@ impl<'q, 'src> QueryPrinter<'q, 'src> { } } -impl Query<'_> { - pub fn printer(&self) -> QueryPrinter<'_, '_> { +impl Query { + pub fn printer(&self) -> QueryPrinter<'_> { QueryPrinter::new(self) } } diff --git a/crates/plotnik-lib/src/query/query.rs b/crates/plotnik-lib/src/query/query.rs index 125a041b..a0942b80 100644 --- a/crates/plotnik-lib/src/query/query.rs +++ b/crates/plotnik-lib/src/query/query.rs @@ -10,11 +10,11 @@ use plotnik_langs::Lang; use crate::Diagnostics; use crate::parser::{ParseResult, Parser, Root, SyntaxNode, lexer::lex}; use crate::query::alt_kinds::validate_alt_kinds; -use crate::query::dependencies::{self, DependencyAnalysis}; +use crate::query::dependencies::{self, DependencyAnalysisOwned}; use crate::query::expr_arity::{ExprArity, ExprArityTable, infer_arities, resolve_arity}; use crate::query::link; use crate::query::source_map::{SourceId, SourceMap}; -use crate::query::symbol_table::{SymbolTable, resolve_names}; +use crate::query::symbol_table::{SymbolTableOwned, resolve_names}; const DEFAULT_QUERY_PARSE_FUEL: u32 = 1_000_000; const DEFAULT_QUERY_PARSE_MAX_DEPTH: u32 = 4096; @@ -41,6 +41,11 @@ impl QueryBuilder { Self { source_map, config } } + pub fn from_str(src: &str) -> Self { + let source_map = SourceMap::one_liner(src); + Self::new(source_map) + } + pub fn with_query_parse_fuel(mut self, fuel: u32) -> Self { self.config.query_parse_fuel = fuel; self @@ -99,6 +104,7 @@ impl QueryParsed { impl QueryParsed { pub fn analyze(mut self) -> QueryAnalyzed { + // Use reference-based structures for processing let symbol_table = resolve_names(&self.source_map, &self.ast_map, &mut self.diag); let dependency_analysis = dependencies::analyze_dependencies(&symbol_table); @@ -111,10 +117,14 @@ impl QueryParsed { let arity_table = infer_arities(&self.ast_map, &symbol_table, &mut self.diag); + // Convert to owned for storage + let symbol_table_owned = crate::query::symbol_table::to_owned(symbol_table); + let dependency_analysis_owned = dependency_analysis.to_owned(); + QueryAnalyzed { query_parsed: self, - symbol_table, - dependency_analysis, + symbol_table: symbol_table_owned, + dependency_analysis: dependency_analysis_owned, arity_table, } } @@ -136,8 +146,8 @@ pub type Query = QueryAnalyzed; pub struct QueryAnalyzed { query_parsed: QueryParsed, - pub symbol_table: SymbolTable, - dependency_analysis: DependencyAnalysis, + pub symbol_table: SymbolTableOwned, + dependency_analysis: DependencyAnalysisOwned, arity_table: ExprArityTable, } @@ -151,8 +161,9 @@ impl QueryAnalyzed { } pub fn link(mut self, lang: &Lang) -> LinkedQuery { - let mut type_ids: NodeTypeIdTable = HashMap::new(); - let mut field_ids: NodeFieldIdTable = HashMap::new(); + // Use reference-based hash maps during processing + let mut type_ids: HashMap<&str, Option> = HashMap::new(); + let mut field_ids: HashMap<&str, Option> = HashMap::new(); link::link( &self.query_parsed.ast_map, @@ -164,10 +175,20 @@ impl QueryAnalyzed { &mut self.query_parsed.diag, ); + // Convert to owned for storage + let type_ids_owned = type_ids + .into_iter() + .map(|(k, v)| (k.to_owned(), v)) + .collect(); + let field_ids_owned = field_ids + .into_iter() + .map(|(k, v)| (k.to_owned(), v)) + .collect(); + LinkedQuery { inner: self, - type_ids, - field_ids, + type_ids: type_ids_owned, + field_ids: field_ids_owned, } } } @@ -196,16 +217,16 @@ impl TryFrom<&str> for QueryAnalyzed { } } -type NodeTypeIdTable<'a> = HashMap<&'a str, Option>; -type NodeFieldIdTable<'a> = HashMap<&'a str, Option>; +type NodeTypeIdTableOwned = HashMap>; +type NodeFieldIdTableOwned = HashMap>; -pub struct LinkedQuery<'a> { +pub struct LinkedQuery { inner: QueryAnalyzed, - type_ids: NodeTypeIdTable<'a>, - field_ids: NodeFieldIdTable<'a>, + type_ids: NodeTypeIdTableOwned, + field_ids: NodeFieldIdTableOwned, } -impl Deref for LinkedQuery<'_> { +impl Deref for LinkedQuery { type Target = QueryAnalyzed; fn deref(&self) -> &Self::Target { @@ -213,7 +234,7 @@ impl Deref for LinkedQuery<'_> { } } -impl DerefMut for LinkedQuery<'_> { +impl DerefMut for LinkedQuery { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } diff --git a/crates/plotnik-lib/src/query/query_tests.rs b/crates/plotnik-lib/src/query/query_tests.rs index 755ae157..1ec51106 100644 --- a/crates/plotnik-lib/src/query/query_tests.rs +++ b/crates/plotnik-lib/src/query/query_tests.rs @@ -1,11 +1,15 @@ use plotnik_langs::Lang; -use crate::query::query::{LinkedQuery, QueryAnalyzed, QueryBuilder}; +use crate::{ + SourceMap, + query::query::{LinkedQuery, QueryAnalyzed, QueryBuilder}, +}; -impl<'q> QueryAnalyzed<'q> { +impl QueryAnalyzed { #[track_caller] - fn parse_and_validate(src: &'q str) -> Self { - let query = QueryBuilder::new(src).parse().unwrap().analyze(); + fn parse_and_validate(src: &str) -> Self { + let source_map = SourceMap::one_liner(src); + let query = QueryBuilder::new(source_map).parse().unwrap().analyze(); if !query.is_valid() { panic!( "Expected valid query, got error:\n{}", @@ -16,42 +20,43 @@ impl<'q> QueryAnalyzed<'q> { } #[track_caller] - pub fn expect(src: &'q str) -> Self { - QueryBuilder::new(src).parse().unwrap().analyze() + pub fn expect(src: &str) -> Self { + let source_map = SourceMap::one_liner(src); + QueryBuilder::new(source_map).parse().unwrap().analyze() } #[track_caller] - pub fn expect_valid(src: &'q str) -> Self { + pub fn expect_valid(src: &str) -> Self { Self::parse_and_validate(src) } #[track_caller] - pub fn expect_valid_cst(src: &'q str) -> String { + pub fn expect_valid_cst(src: &str) -> String { Self::parse_and_validate(src).dump_cst() } #[track_caller] - pub fn expect_valid_cst_full(src: &'q str) -> String { + pub fn expect_valid_cst_full(src: &str) -> String { Self::parse_and_validate(src).dump_cst_full() } #[track_caller] - pub fn expect_valid_ast(src: &'q str) -> String { + pub fn expect_valid_ast(src: &str) -> String { Self::parse_and_validate(src).dump_ast() } #[track_caller] - pub fn expect_valid_arities(src: &'q str) -> String { + pub fn expect_valid_arities(src: &str) -> String { Self::parse_and_validate(src).dump_with_arities() } #[track_caller] - pub fn expect_valid_symbols(src: &'q str) -> String { + pub fn expect_valid_symbols(src: &str) -> String { Self::parse_and_validate(src).dump_symbols() } #[track_caller] - pub fn expect_valid_linking(src: &'q str, lang: &Lang) -> LinkedQuery<'q> { + pub fn expect_valid_linking(src: &str, lang: &Lang) -> LinkedQuery { let query = Self::parse_and_validate(src).link(lang); if !query.is_valid() { panic!( @@ -63,7 +68,7 @@ impl<'q> QueryAnalyzed<'q> { } #[track_caller] - pub fn expect_invalid_linking(src: &'q str, lang: &Lang) -> String { + pub fn expect_invalid_linking(src: &str, lang: &Lang) -> String { let query = Self::parse_and_validate(src).link(lang); if query.is_valid() { panic!("Expected failed linking, got valid"); @@ -72,8 +77,9 @@ impl<'q> QueryAnalyzed<'q> { } #[track_caller] - pub fn expect_invalid(src: &'q str) -> String { - let query = QueryBuilder::new(src).parse().unwrap().analyze(); + pub fn expect_invalid(src: &str) -> String { + let source_map = SourceMap::one_liner(src); + let query = QueryBuilder::new(source_map).parse().unwrap().analyze(); if query.is_valid() { panic!("Expected invalid query, got valid:\n{}", query.dump_cst()); } diff --git a/crates/plotnik-lib/src/query/source_map.rs b/crates/plotnik-lib/src/query/source_map.rs index 49ea6df2..16b9b9c8 100644 --- a/crates/plotnik-lib/src/query/source_map.rs +++ b/crates/plotnik-lib/src/query/source_map.rs @@ -134,6 +134,15 @@ impl SourceMap { .expect("invalid SourceId") } + /// Get the file path if this source is a file, None otherwise. + pub fn path(&self, id: SourceId) -> Option<&str> { + let entry = self.entries.get(id.0 as usize).expect("invalid SourceId"); + match &entry.kind { + SourceKindEntry::File { name_range } => Some(self.slice(name_range)), + _ => None, + } + } + /// Number of sources in the map. pub fn len(&self) -> usize { self.entries.len() @@ -204,7 +213,8 @@ mod tests { #[test] fn single_one_liner() { - let (map, id) = SourceMap::one_liner("hello world"); + let map = SourceMap::one_liner("hello world"); + let id = SourceId(0); assert_eq!(map.content(id), "hello world"); assert_eq!(map.kind(id), SourceKind::OneLiner); @@ -284,6 +294,13 @@ mod tests { assert_eq!(SourceKind::File("foo.ptk").display_name(), "foo.ptk"); } + #[test] + #[should_panic(expected = "invalid SourceId")] + fn invalid_id_panics() { + let map = SourceMap::new(); + let _ = map.content(SourceId(999)); + } + #[test] fn shared_buffer_lifetime() { let mut map = SourceMap::new(); diff --git a/crates/plotnik-lib/src/query/symbol_table.rs b/crates/plotnik-lib/src/query/symbol_table.rs index bbc64dc6..b945f71a 100644 --- a/crates/plotnik-lib/src/query/symbol_table.rs +++ b/crates/plotnik-lib/src/query/symbol_table.rs @@ -18,6 +18,11 @@ use super::visitor::Visitor; pub const UNNAMED_DEF: &str = "_"; pub type SymbolTable<'src> = IndexMap<&'src str, (SourceId, ast::Expr)>; +pub type SymbolTableOwned = IndexMap; + +pub fn to_owned(table: SymbolTable<'_>) -> SymbolTableOwned { + table.into_iter().map(|(k, v)| (k.to_owned(), v)).collect() +} pub fn resolve_names<'q>( source_map: &'q SourceMap, @@ -43,6 +48,7 @@ pub fn resolve_names<'q>( let src = source_map.content(source_id); let mut validator = ReferenceValidator { src, + source_id, diag, symbol_table: &symbol_table, }; @@ -68,7 +74,11 @@ impl Visitor for ReferenceResolver<'_, '_, '_> { let name = token_src(&token, self.src); if self.symbol_table.contains_key(name) { self.diag - .report(DiagnosticKind::DuplicateDefinition, token.text_range()) + .report( + self.source_id, + DiagnosticKind::DuplicateDefinition, + token.text_range(), + ) .message(name) .emit(); } else { @@ -89,6 +99,7 @@ impl Visitor for ReferenceResolver<'_, '_, '_> { struct ReferenceValidator<'q, 'd, 't> { #[allow(dead_code)] src: &'q str, + source_id: SourceId, diag: &'d mut Diagnostics, symbol_table: &'t SymbolTable<'q>, } @@ -103,7 +114,11 @@ impl Visitor for ReferenceValidator<'_, '_, '_> { } self.diag - .report(DiagnosticKind::UndefinedReference, name_token.text_range()) + .report( + self.source_id, + DiagnosticKind::UndefinedReference, + name_token.text_range(), + ) .message(name) .emit(); } From d6e90d06b6078474788bf5f34b70426a8e82b377 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 20 Dec 2025 21:05:28 -0300 Subject: [PATCH 3/5] Update query_tests.rs --- crates/plotnik-lib/src/query/query_tests.rs | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/plotnik-lib/src/query/query_tests.rs b/crates/plotnik-lib/src/query/query_tests.rs index 1ec51106..bf071898 100644 --- a/crates/plotnik-lib/src/query/query_tests.rs +++ b/crates/plotnik-lib/src/query/query_tests.rs @@ -86,3 +86,35 @@ impl QueryAnalyzed { query.dump_diagnostics() } } + +#[test] +fn invalid_three_way_mutual_recursion_across_files() { + let mut source_map = SourceMap::new(); + source_map.add_file("a.ptk", "A = (a (B))"); + source_map.add_file("b.ptk", "B = (b (C))"); + source_map.add_file("c.ptk", "C = (c (A))"); + + let query = QueryBuilder::new(source_map).parse().unwrap().analyze(); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle has no escape path + --> c.ptk:1:9 + | + 1 | C = (c (A)) + | - ^ + | | | + | | references A + | C is defined here + | + ::: a.ptk:1:9 + | + 1 | A = (a (B)) + | - references B + | + ::: b.ptk:1:9 + | + 1 | B = (b (C)) + | - references C (completing cycle) + "); +} From 09b0cef31b8fc017387fd0bb1c145e07f42c16a0 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 20 Dec 2025 21:18:42 -0300 Subject: [PATCH 4/5] Tidy --- AGENTS.md | 5 ++-- crates/plotnik-lib/src/query/expr_arity.rs | 16 ++++++++++--- .../plotnik-lib/src/query/expr_arity_tests.rs | 2 ++ crates/plotnik-lib/src/query/query_tests.rs | 23 +++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec1ebe0a..9ef1a9bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,10 +209,9 @@ fn valid_query() { (function_declaration name: (identifier) @name) "#}; - let query = Query::try_from(input).unwrap(); + let res = Query::expect_valid_ast(input).unwrap(); - assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_ast(), @""); + insta::assert_snapshot!(res, @""); } ``` diff --git a/crates/plotnik-lib/src/query/expr_arity.rs b/crates/plotnik-lib/src/query/expr_arity.rs index 1d985b43..d6452f38 100644 --- a/crates/plotnik-lib/src/query/expr_arity.rs +++ b/crates/plotnik-lib/src/query/expr_arity.rs @@ -179,14 +179,24 @@ impl ArityContext<'_, '_> { .map(|t| t.text().to_string()) .unwrap_or_else(|| "field".to_string()); - self.diag + let mut builder = self + .diag .report( self.source_id, DiagnosticKind::FieldSequenceValue, value.text_range(), ) - .message(field_name) - .emit(); + .message(field_name); + + // If value is a reference, add related info pointing to definition + if let Expr::Ref(r) = &value + && let Some(name_tok) = r.name() + && let Some((def_source, def_body)) = self.symbol_table.get(name_tok.text()) + { + builder = builder.related_to(*def_source, def_body.text_range(), "defined here"); + } + + builder.emit(); } } } diff --git a/crates/plotnik-lib/src/query/expr_arity_tests.rs b/crates/plotnik-lib/src/query/expr_arity_tests.rs index 8f2ca208..687e330a 100644 --- a/crates/plotnik-lib/src/query/expr_arity_tests.rs +++ b/crates/plotnik-lib/src/query/expr_arity_tests.rs @@ -197,6 +197,8 @@ fn field_with_ref_to_seq_error() { insta::assert_snapshot!(res, @r" error: field `name` must match exactly one node, not a sequence | + 1 | X = {(a) (b)} + | --------- defined here 2 | Q = (call name: (X)) | ^^^ "); diff --git a/crates/plotnik-lib/src/query/query_tests.rs b/crates/plotnik-lib/src/query/query_tests.rs index bf071898..9aa44c11 100644 --- a/crates/plotnik-lib/src/query/query_tests.rs +++ b/crates/plotnik-lib/src/query/query_tests.rs @@ -118,3 +118,26 @@ fn invalid_three_way_mutual_recursion_across_files() { | - references C (completing cycle) "); } + +#[test] +fn multifile_field_with_ref_to_seq_error() { + let mut source_map = SourceMap::new(); + source_map.add_file("defs.ptk", "X = {(a) (b)}"); + source_map.add_file("main.ptk", "Q = (call name: (X))"); + + let query = QueryBuilder::new(source_map).parse().unwrap().analyze(); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: field `name` must match exactly one node, not a sequence + --> main.ptk:1:17 + | + 1 | Q = (call name: (X)) + | ^^^ + | + ::: defs.ptk:1:5 + | + 1 | X = {(a) (b)} + | --------- defined here + "); +} From 5f8b7c942d91605fb132937e27bd2b148b6a3491 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 20 Dec 2025 21:21:38 -0300 Subject: [PATCH 5/5] Rename `from_str` method to `one_liner` in query builder --- .../src/parser/tests/recovery/coverage_tests.rs | 12 ++++++------ crates/plotnik-lib/src/query/query.rs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) 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 1c637fcd..40b81548 100644 --- a/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs +++ b/crates/plotnik-lib/src/parser/tests/recovery/coverage_tests.rs @@ -13,7 +13,7 @@ fn deeply_nested_trees_hit_recursion_limit() { input.push(')'); } - let result = QueryBuilder::from_str(&input) + let result = QueryBuilder::one_liner(&input) .with_query_parse_recursion_limit(depth) .parse(); @@ -35,7 +35,7 @@ fn deeply_nested_sequences_hit_recursion_limit() { input.push('}'); } - let result = QueryBuilder::from_str(&input) + let result = QueryBuilder::one_liner(&input) .with_query_parse_recursion_limit(depth) .parse(); @@ -57,7 +57,7 @@ fn deeply_nested_alternations_hit_recursion_limit() { input.push(']'); } - let result = QueryBuilder::from_str(&input) + let result = QueryBuilder::one_liner(&input) .with_query_parse_recursion_limit(depth) .parse(); @@ -76,7 +76,7 @@ fn many_trees_exhaust_exec_fuel() { input.push_str("(a) "); } - let result = QueryBuilder::from_str(&input) + let result = QueryBuilder::one_liner(&input) .with_query_parse_fuel(100) .parse(); @@ -100,7 +100,7 @@ fn many_branches_exhaust_exec_fuel() { } input.push(']'); - let result = QueryBuilder::from_str(&input) + let result = QueryBuilder::one_liner(&input) .with_query_parse_fuel(100) .parse(); @@ -124,7 +124,7 @@ fn many_fields_exhaust_exec_fuel() { } input.push(')'); - let result = QueryBuilder::from_str(&input) + let result = QueryBuilder::one_liner(&input) .with_query_parse_fuel(100) .parse(); diff --git a/crates/plotnik-lib/src/query/query.rs b/crates/plotnik-lib/src/query/query.rs index a0942b80..63e2c4ee 100644 --- a/crates/plotnik-lib/src/query/query.rs +++ b/crates/plotnik-lib/src/query/query.rs @@ -41,7 +41,7 @@ impl QueryBuilder { Self { source_map, config } } - pub fn from_str(src: &str) -> Self { + pub fn one_liner(src: &str) -> Self { let source_map = SourceMap::one_liner(src); Self::new(source_map) }