From ad26d21998ad8bbdffa8e1e67b5196aca87d2ab0 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 14:11:35 -0300 Subject: [PATCH 1/4] feat: Node type and field resolving --- Cargo.lock | 1 + crates/plotnik-cli/src/commands/debug/mod.rs | 18 +- crates/plotnik-lib/Cargo.toml | 4 + crates/plotnik-lib/src/diagnostics/message.rs | 18 + crates/plotnik-lib/src/query/link.rs | 403 ++++++++++++++++++ crates/plotnik-lib/src/query/mod.rs | 31 ++ 6 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 crates/plotnik-lib/src/query/link.rs diff --git a/Cargo.lock b/Cargo.lock index a4610751..6ebcebc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,7 @@ dependencies = [ "indoc", "insta", "logos", + "plotnik-langs", "rowan", "serde", "serde_json", diff --git a/crates/plotnik-cli/src/commands/debug/mod.rs b/crates/plotnik-cli/src/commands/debug/mod.rs index a4e41771..22a5d0e3 100644 --- a/crates/plotnik-cli/src/commands/debug/mod.rs +++ b/crates/plotnik-cli/src/commands/debug/mod.rs @@ -36,13 +36,21 @@ pub fn run(args: DebugArgs) { None }; - let query = query_source.as_ref().map(|src| { + let mut query = query_source.as_ref().map(|src| { Query::try_from(src).unwrap_or_else(|e| { eprintln!("error: {}", e); std::process::exit(1); }) }); + // Auto-link when --lang is provided with a query + if args.lang.is_some() + && let Some(ref mut q) = query + { + let lang = resolve_lang_for_link(&args.lang); + q.link(&lang); + } + let show_query = has_query_input && !args.symbols; let show_source = has_source_input; let show_headers = (show_query || args.symbols) && show_source; @@ -130,3 +138,11 @@ fn validate(args: &DebugArgs, has_query: bool, has_source: bool) -> Result<(), & Ok(()) } + +fn resolve_lang_for_link(lang: &Option) -> plotnik_langs::Lang { + let name = lang.as_ref().expect("--lang required for --link"); + plotnik_langs::from_name(name).unwrap_or_else(|| { + eprintln!("error: unknown language: {}", name); + std::process::exit(1); + }) +} diff --git a/crates/plotnik-lib/Cargo.toml b/crates/plotnik-lib/Cargo.toml index 1eae0b13..97428997 100644 --- a/crates/plotnik-lib/Cargo.toml +++ b/crates/plotnik-lib/Cargo.toml @@ -20,6 +20,10 @@ indexmap = "2" rowan = "0.16.1" serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.17" +plotnik-langs = { version = "0.1", path = "../plotnik-langs", optional = true } + +[features] +default = ["plotnik-langs"] [dev-dependencies] insta = { version = "=1.44.3", features = ["yaml"] } diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index 940632b6..bcc23c51 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -60,6 +60,12 @@ pub enum DiagnosticKind { RecursionNoEscape, FieldSequenceValue, + // Link pass - grammar validation + UnknownNodeType, + UnknownField, + FieldNotOnNodeType, + InvalidFieldChildType, + // Often consequences of earlier errors UnnamedDefNotLast, } @@ -157,6 +163,12 @@ impl DiagnosticKind { Self::RecursionNoEscape => "infinite recursion detected", Self::FieldSequenceValue => "field must match exactly one node", + // Link pass - grammar validation + Self::UnknownNodeType => "unknown node type", + Self::UnknownField => "unknown field", + Self::FieldNotOnNodeType => "field not valid on this node type", + Self::InvalidFieldChildType => "node type not valid for this field", + // Structural Self::UnnamedDefNotLast => "only the last definition can be unnamed", } @@ -177,6 +189,12 @@ impl DiagnosticKind { Self::DuplicateDefinition => "`{}` is already defined".to_string(), Self::UndefinedReference => "`{}` is not defined".to_string(), + // Link pass errors with context + Self::UnknownNodeType => "`{}` is not a valid node type".to_string(), + Self::UnknownField => "`{}` is not a valid field".to_string(), + Self::FieldNotOnNodeType => "field `{}` is not valid on this node type".to_string(), + Self::InvalidFieldChildType => "node type `{}` is not valid for this field".to_string(), + // Recursion with cycle path Self::RecursionNoEscape => "infinite recursion: {}".to_string(), diff --git a/crates/plotnik-lib/src/query/link.rs b/crates/plotnik-lib/src/query/link.rs new file mode 100644 index 00000000..27d99df9 --- /dev/null +++ b/crates/plotnik-lib/src/query/link.rs @@ -0,0 +1,403 @@ +//! Link pass: resolve node types and fields against tree-sitter grammar. +//! +//! Three-phase approach: +//! 1. Collect and resolve all node type names (NamedNode, AnonymousNode) +//! 2. Collect and resolve all field names (FieldExpr, NegatedField) +//! 3. Validate structural constraints (field on node type, child type for field) + +use plotnik_langs::{Lang, NodeTypeId}; + +use crate::diagnostics::DiagnosticKind; +use crate::parser::ast::{self, Expr, NamedNode}; +use crate::parser::cst::SyntaxKind; + +use super::Query; + +impl<'a> Query<'a> { + /// Link query against a language grammar. + /// + /// Resolves node types and fields, validates structural constraints. + pub fn link(&mut self, lang: &Lang) { + self.resolve_node_types(lang); + self.resolve_fields(lang); + self.validate_structure(lang); + } + + fn resolve_node_types(&mut self, lang: &Lang) { + let defs: Vec<_> = self.ast.defs().collect(); + for def in defs { + let Some(body) = def.body() else { continue }; + self.collect_node_types(&body, lang); + } + } + + fn collect_node_types(&mut self, expr: &Expr, lang: &Lang) { + match expr { + Expr::NamedNode(node) => { + self.resolve_named_node(node, lang); + for child in node.children() { + self.collect_node_types(&child, lang); + } + } + Expr::AnonymousNode(anon) => { + if anon.is_any() { + return; + } + let Some(value_token) = anon.value() else { + return; + }; + let value = value_token.text(); + if self.node_type_ids.contains_key(value) { + return; + } + let resolved = lang.resolve_anonymous_node(value); + self.node_type_ids.insert( + &self.source[text_range_to_usize(value_token.text_range())], + resolved, + ); + if resolved.is_none() { + self.link_diagnostics + .report(DiagnosticKind::UnknownNodeType, value_token.text_range()) + .message(value) + .emit(); + } + } + Expr::AltExpr(alt) => { + for branch in alt.branches() { + let Some(body) = branch.body() else { continue }; + self.collect_node_types(&body, lang); + } + } + Expr::SeqExpr(seq) => { + for child in seq.children() { + self.collect_node_types(&child, lang); + } + } + Expr::CapturedExpr(cap) => { + let Some(inner) = cap.inner() else { return }; + self.collect_node_types(&inner, lang); + } + Expr::QuantifiedExpr(q) => { + let Some(inner) = q.inner() else { return }; + self.collect_node_types(&inner, lang); + } + Expr::FieldExpr(f) => { + let Some(value) = f.value() else { return }; + self.collect_node_types(&value, lang); + } + Expr::Ref(_) => {} + } + } + + fn resolve_named_node(&mut self, node: &NamedNode, lang: &Lang) { + if node.is_any() { + return; + } + let Some(type_token) = node.node_type() else { + return; + }; + // Skip ERROR and MISSING - they're built-in tree-sitter concepts + if matches!( + type_token.kind(), + SyntaxKind::KwError | SyntaxKind::KwMissing + ) { + return; + } + let type_name = type_token.text(); + if self.node_type_ids.contains_key(type_name) { + return; + } + let resolved = lang.resolve_named_node(type_name); + self.node_type_ids.insert( + &self.source[text_range_to_usize(type_token.text_range())], + resolved, + ); + if resolved.is_none() { + self.link_diagnostics + .report(DiagnosticKind::UnknownNodeType, type_token.text_range()) + .message(type_name) + .emit(); + } + } + + fn resolve_fields(&mut self, lang: &Lang) { + let defs: Vec<_> = self.ast.defs().collect(); + for def in defs { + let Some(body) = def.body() else { continue }; + self.collect_fields(&body, lang); + } + } + + fn collect_fields(&mut self, expr: &Expr, lang: &Lang) { + match expr { + Expr::NamedNode(node) => { + for child in node.children() { + self.collect_fields(&child, lang); + } + } + Expr::AltExpr(alt) => { + for branch in alt.branches() { + let Some(body) = branch.body() else { continue }; + self.collect_fields(&body, lang); + } + } + Expr::SeqExpr(seq) => { + for child in seq.children() { + self.collect_fields(&child, lang); + } + } + Expr::CapturedExpr(cap) => { + let Some(inner) = cap.inner() else { return }; + self.collect_fields(&inner, lang); + } + Expr::QuantifiedExpr(q) => { + let Some(inner) = q.inner() else { return }; + self.collect_fields(&inner, lang); + } + Expr::FieldExpr(f) => { + self.resolve_field_expr(f, lang); + let Some(value) = f.value() else { return }; + self.collect_fields(&value, lang); + } + Expr::AnonymousNode(_) | Expr::Ref(_) => {} + } + + // Also check NegatedField (predicate, not in Expr enum) + if let Some(node) = ast::NamedNode::cast(expr.as_cst().clone()) { + for child in node.as_cst().children() { + if let Some(neg) = ast::NegatedField::cast(child) { + self.resolve_negated_field(&neg, lang); + } + } + } + } + + fn resolve_field_expr(&mut self, field: &ast::FieldExpr, lang: &Lang) { + let Some(name_token) = field.name() else { + return; + }; + let field_name = name_token.text(); + if self.node_field_ids.contains_key(field_name) { + return; + } + let resolved = lang.resolve_field(field_name); + self.node_field_ids.insert( + &self.source[text_range_to_usize(name_token.text_range())], + resolved, + ); + if resolved.is_none() { + self.link_diagnostics + .report(DiagnosticKind::UnknownField, name_token.text_range()) + .message(field_name) + .emit(); + } + } + + fn resolve_negated_field(&mut self, neg: &ast::NegatedField, lang: &Lang) { + let Some(name_token) = neg.name() else { + return; + }; + let field_name = name_token.text(); + if self.node_field_ids.contains_key(field_name) { + return; + } + let resolved = lang.resolve_field(field_name); + self.node_field_ids.insert( + &self.source[text_range_to_usize(name_token.text_range())], + resolved, + ); + if resolved.is_none() { + self.link_diagnostics + .report(DiagnosticKind::UnknownField, name_token.text_range()) + .message(field_name) + .emit(); + } + } + + fn validate_structure(&mut self, lang: &Lang) { + let defs: Vec<_> = self.ast.defs().collect(); + for def in defs { + let Some(body) = def.body() else { continue }; + self.validate_expr_structure(&body, None, lang); + } + } + + fn validate_expr_structure( + &mut self, + expr: &Expr, + parent_type_id: Option, + lang: &Lang, + ) { + match expr { + Expr::NamedNode(node) => { + let current_type_id = self.get_node_type_id(node); + for child in node.children() { + self.validate_expr_structure(&child, current_type_id, lang); + } + // Also validate NegatedField predicates + for child in node.as_cst().children() { + if let Some(neg) = ast::NegatedField::cast(child) { + self.validate_negated_field(&neg, current_type_id, lang); + } + } + } + Expr::FieldExpr(f) => { + self.validate_field(f, parent_type_id, lang); + let Some(value) = f.value() else { return }; + // Field children don't inherit parent_type_id - they're the field value + self.validate_expr_structure(&value, parent_type_id, lang); + } + Expr::AltExpr(alt) => { + for branch in alt.branches() { + let Some(body) = branch.body() else { continue }; + self.validate_expr_structure(&body, parent_type_id, lang); + } + } + Expr::SeqExpr(seq) => { + for child in seq.children() { + self.validate_expr_structure(&child, parent_type_id, lang); + } + } + Expr::CapturedExpr(cap) => { + let Some(inner) = cap.inner() else { return }; + self.validate_expr_structure(&inner, parent_type_id, lang); + } + Expr::QuantifiedExpr(q) => { + let Some(inner) = q.inner() else { return }; + self.validate_expr_structure(&inner, parent_type_id, lang); + } + Expr::AnonymousNode(_) | Expr::Ref(_) => {} + } + } + + fn validate_field( + &mut self, + field: &ast::FieldExpr, + parent_type_id: Option, + lang: &Lang, + ) { + let Some(name_token) = field.name() else { + return; + }; + let field_name = name_token.text(); + + // Get field ID - skip if unresolved (already reported) + let Some(field_id) = self.node_field_ids.get(field_name).copied().flatten() else { + return; + }; + + // Check if field exists on parent node type + let Some(parent_id) = parent_type_id else { + return; + }; + if !lang.has_field(parent_id, field_id) { + self.link_diagnostics + .report(DiagnosticKind::FieldNotOnNodeType, name_token.text_range()) + .message(field_name) + .emit(); + return; + } + + // Check if child type is valid for this field + let Some(value) = field.value() else { + return; + }; + let child_type_id = self.get_expr_type_id(&value); + let Some(child_id) = child_type_id else { + return; + }; + if !lang.is_valid_field_type(parent_id, field_id, child_id) { + let child_name = self.get_expr_type_name(&value).unwrap_or("(unknown)"); + self.link_diagnostics + .report(DiagnosticKind::InvalidFieldChildType, value.text_range()) + .message(child_name) + .emit(); + } + } + + fn validate_negated_field( + &mut self, + neg: &ast::NegatedField, + parent_type_id: Option, + lang: &Lang, + ) { + let Some(name_token) = neg.name() else { + return; + }; + let field_name = name_token.text(); + + let Some(field_id) = self.node_field_ids.get(field_name).copied().flatten() else { + return; + }; + + let Some(parent_id) = parent_type_id else { + return; + }; + if !lang.has_field(parent_id, field_id) { + self.link_diagnostics + .report(DiagnosticKind::FieldNotOnNodeType, name_token.text_range()) + .message(field_name) + .emit(); + } + } + + fn get_node_type_id(&self, node: &NamedNode) -> Option { + if node.is_any() { + return None; + } + let type_token = node.node_type()?; + if matches!( + type_token.kind(), + SyntaxKind::KwError | SyntaxKind::KwMissing + ) { + return None; + } + let type_name = type_token.text(); + self.node_type_ids.get(type_name).copied().flatten() + } + + fn get_expr_type_id(&self, expr: &Expr) -> Option { + match expr { + Expr::NamedNode(node) => self.get_node_type_id(node), + Expr::AnonymousNode(anon) => { + if anon.is_any() { + return None; + } + let value_token = anon.value()?; + let value = &self.source[text_range_to_usize(value_token.text_range())]; + self.node_type_ids.get(value).copied().flatten() + } + Expr::CapturedExpr(cap) => self.get_expr_type_id(&cap.inner()?), + Expr::QuantifiedExpr(q) => self.get_expr_type_id(&q.inner()?), + _ => None, + } + } + + fn get_expr_type_name(&self, expr: &Expr) -> Option<&'a str> { + match expr { + Expr::NamedNode(node) => { + if node.is_any() { + return None; + } + let type_token = node.node_type()?; + Some(&self.source[text_range_to_usize(type_token.text_range())]) + } + Expr::AnonymousNode(anon) => { + if anon.is_any() { + return None; + } + let value_token = anon.value()?; + Some(&self.source[text_range_to_usize(value_token.text_range())]) + } + Expr::CapturedExpr(cap) => self.get_expr_type_name(&cap.inner()?), + Expr::QuantifiedExpr(q) => self.get_expr_type_name(&q.inner()?), + _ => None, + } + } +} + +fn text_range_to_usize(range: rowan::TextRange) -> std::ops::Range { + let start: usize = range.start().into(); + let end: usize = range.end().into(); + start..end +} diff --git a/crates/plotnik-lib/src/query/mod.rs b/crates/plotnik-lib/src/query/mod.rs index c768deac..25226f5c 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -10,6 +10,8 @@ mod printer; pub use printer::QueryPrinter; pub mod alt_kinds; +#[cfg(feature = "plotnik-langs")] +pub mod link; pub mod recursion; pub mod shapes; pub mod symbol_table; @@ -29,6 +31,9 @@ mod symbol_table_tests; use std::collections::HashMap; +#[cfg(feature = "plotnik-langs")] +use plotnik_langs::{NodeFieldId, NodeTypeId}; + use rowan::GreenNodeBuilder; use crate::Result; @@ -56,6 +61,10 @@ pub struct Query<'a> { ast: Root, symbol_table: SymbolTable<'a>, shape_cardinality_table: HashMap, + #[cfg(feature = "plotnik-langs")] + node_type_ids: HashMap<&'a str, Option>, + #[cfg(feature = "plotnik-langs")] + node_field_ids: HashMap<&'a str, Option>, exec_fuel: Option, recursion_fuel: Option, exec_fuel_consumed: u32, @@ -64,6 +73,8 @@ pub struct Query<'a> { resolve_diagnostics: Diagnostics, recursion_diagnostics: Diagnostics, shapes_diagnostics: Diagnostics, + #[cfg(feature = "plotnik-langs")] + link_diagnostics: Diagnostics, } fn empty_root() -> Root { @@ -84,6 +95,10 @@ impl<'a> Query<'a> { ast: empty_root(), symbol_table: SymbolTable::default(), shape_cardinality_table: HashMap::new(), + #[cfg(feature = "plotnik-langs")] + node_type_ids: HashMap::new(), + #[cfg(feature = "plotnik-langs")] + node_field_ids: HashMap::new(), exec_fuel: Some(DEFAULT_EXEC_FUEL), recursion_fuel: Some(DEFAULT_RECURSION_FUEL), exec_fuel_consumed: 0, @@ -92,6 +107,8 @@ impl<'a> Query<'a> { resolve_diagnostics: Diagnostics::new(), recursion_diagnostics: Diagnostics::new(), shapes_diagnostics: Diagnostics::new(), + #[cfg(feature = "plotnik-langs")] + link_diagnostics: Diagnostics::new(), } } @@ -199,6 +216,8 @@ impl<'a> Query<'a> { all.extend(self.resolve_diagnostics.clone()); all.extend(self.recursion_diagnostics.clone()); all.extend(self.shapes_diagnostics.clone()); + #[cfg(feature = "plotnik-langs")] + all.extend(self.link_diagnostics.clone()); all } @@ -211,6 +230,18 @@ impl<'a> Query<'a> { } /// Query is valid if there are no error-severity diagnostics (warnings are allowed). + #[cfg(feature = "plotnik-langs")] + pub fn is_valid(&self) -> bool { + !self.parse_diagnostics.has_errors() + && !self.alt_kind_diagnostics.has_errors() + && !self.resolve_diagnostics.has_errors() + && !self.recursion_diagnostics.has_errors() + && !self.shapes_diagnostics.has_errors() + && !self.link_diagnostics.has_errors() + } + + /// Query is valid if there are no error-severity diagnostics (warnings are allowed). + #[cfg(not(feature = "plotnik-langs"))] pub fn is_valid(&self) -> bool { !self.parse_diagnostics.has_errors() && !self.alt_kind_diagnostics.has_errors() From aa210a557fd6013a5843f74986b87fef55c528dd Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 14:31:06 -0300 Subject: [PATCH 2/4] Improve implementation --- crates/plotnik-langs/src/lib.rs | 44 +++ crates/plotnik-lib/src/diagnostics/message.rs | 5 + crates/plotnik-lib/src/diagnostics/mod.rs | 5 + crates/plotnik-lib/src/diagnostics/printer.rs | 4 + crates/plotnik-lib/src/query/link.rs | 212 ++++++++---- crates/plotnik-lib/src/query/link_tests.rs | 327 ++++++++++++++++++ crates/plotnik-lib/src/query/mod.rs | 2 + 7 files changed, 536 insertions(+), 63 deletions(-) create mode 100644 crates/plotnik-lib/src/query/link_tests.rs diff --git a/crates/plotnik-langs/src/lib.rs b/crates/plotnik-langs/src/lib.rs index 7d68d99f..581ae00d 100644 --- a/crates/plotnik-langs/src/lib.rs +++ b/crates/plotnik-langs/src/lib.rs @@ -24,6 +24,13 @@ pub trait LangImpl: Send + Sync { fn resolve_anonymous_node(&self, kind: &str) -> Option; fn resolve_field(&self, name: &str) -> Option; + // Enumeration methods for suggestions + fn all_named_node_kinds(&self) -> Vec<&'static str>; + fn all_field_names(&self) -> Vec<&'static str>; + fn node_type_name(&self, node_type_id: NodeTypeId) -> Option<&'static str>; + fn field_name(&self, field_id: NodeFieldId) -> Option<&'static str>; + fn fields_for_node_type(&self, node_type_id: NodeTypeId) -> Vec<&'static str>; + fn is_supertype(&self, node_type_id: NodeTypeId) -> bool; fn subtypes(&self, supertype: NodeTypeId) -> &[u16]; @@ -111,6 +118,43 @@ impl LangImpl for LangInner { self.ts_lang.field_id_for_name(name) } + fn all_named_node_kinds(&self) -> Vec<&'static str> { + let count = self.ts_lang.node_kind_count(); + (0..count as u16) + .filter(|&id| self.ts_lang.node_kind_is_named(id)) + .filter_map(|id| self.ts_lang.node_kind_for_id(id)) + .collect() + } + + fn all_field_names(&self) -> Vec<&'static str> { + let count = self.ts_lang.field_count(); + (1..=count as u16) + .filter_map(|id| self.ts_lang.field_name_for_id(id)) + .collect() + } + + fn node_type_name(&self, node_type_id: NodeTypeId) -> Option<&'static str> { + self.ts_lang.node_kind_for_id(node_type_id) + } + + fn field_name(&self, field_id: NodeFieldId) -> Option<&'static str> { + self.ts_lang.field_name_for_id(field_id.get()) + } + + fn fields_for_node_type(&self, node_type_id: NodeTypeId) -> Vec<&'static str> { + let count = self.ts_lang.field_count(); + (1..=count as u16) + .filter_map(|id| { + let field_id = std::num::NonZeroU16::new(id)?; + if self.node_types.has_field(node_type_id, field_id) { + self.ts_lang.field_name_for_id(id) + } else { + None + } + }) + .collect() + } + fn is_supertype(&self, node_type_id: NodeTypeId) -> bool { self.ts_lang.node_kind_is_supertype(node_type_id) } diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index bcc23c51..e48a4b55 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -290,6 +290,7 @@ pub(crate) struct DiagnosticMessage { pub(crate) message: String, pub(crate) fix: Option, pub(crate) related: Vec, + pub(crate) hints: Vec, } impl DiagnosticMessage { @@ -301,6 +302,7 @@ impl DiagnosticMessage { message: message.into(), fix: None, related: Vec::new(), + hints: Vec::new(), } } @@ -343,6 +345,9 @@ impl std::fmt::Display for DiagnosticMessage { u32::from(related.range.end()) )?; } + for hint in &self.hints { + write!(f, " (hint: {})", hint)?; + } Ok(()) } } diff --git a/crates/plotnik-lib/src/diagnostics/mod.rs b/crates/plotnik-lib/src/diagnostics/mod.rs index 390783f4..d170ea73 100644 --- a/crates/plotnik-lib/src/diagnostics/mod.rs +++ b/crates/plotnik-lib/src/diagnostics/mod.rs @@ -223,6 +223,11 @@ impl<'a> DiagnosticBuilder<'a> { self } + pub fn hint(mut self, hint: impl Into) -> Self { + self.message.hints.push(hint.into()); + self + } + pub fn emit(self) { self.diagnostics.messages.push(self.message); } diff --git a/crates/plotnik-lib/src/diagnostics/printer.rs b/crates/plotnik-lib/src/diagnostics/printer.rs index 4c86a424..88d34136 100644 --- a/crates/plotnik-lib/src/diagnostics/printer.rs +++ b/crates/plotnik-lib/src/diagnostics/printer.rs @@ -81,6 +81,10 @@ impl<'a> DiagnosticsPrinter<'a> { ); } + for hint in &diag.hints { + report.push(Group::with_title(Level::HELP.secondary_title(hint))); + } + if i > 0 { w.write_str("\n\n")?; } diff --git a/crates/plotnik-lib/src/query/link.rs b/crates/plotnik-lib/src/query/link.rs index 27d99df9..1ed26b2b 100644 --- a/crates/plotnik-lib/src/query/link.rs +++ b/crates/plotnik-lib/src/query/link.rs @@ -9,10 +9,71 @@ use plotnik_langs::{Lang, NodeTypeId}; use crate::diagnostics::DiagnosticKind; use crate::parser::ast::{self, Expr, NamedNode}; -use crate::parser::cst::SyntaxKind; +use crate::parser::cst::{SyntaxKind, SyntaxToken}; use super::Query; +/// Simple edit distance for fuzzy matching (Levenshtein). +fn edit_distance(a: &str, b: &str) -> usize { + let a_len = a.len(); + let b_len = b.len(); + + if a_len == 0 { + return b_len; + } + if b_len == 0 { + return a_len; + } + + let mut prev: Vec = (0..=b_len).collect(); + let mut curr = vec![0; b_len + 1]; + + for (i, ca) in a.chars().enumerate() { + curr[0] = i + 1; + for (j, cb) in b.chars().enumerate() { + let cost = if ca == cb { 0 } else { 1 }; + curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[b_len] +} + +/// Find the best match from candidates within a reasonable edit distance. +fn find_similar<'a>(name: &str, candidates: &[&'a str], max_distance: usize) -> Option<&'a str> { + candidates + .iter() + .map(|&c| (c, edit_distance(name, c))) + .filter(|(_, d)| *d <= max_distance) + .min_by_key(|(_, d)| *d) + .map(|(c, _)| c) +} + +/// Format a list of items for display, truncating if too long. +fn format_list(items: &[&str], max_items: usize) -> String { + if items.is_empty() { + return String::new(); + } + if items.len() <= max_items { + items + .iter() + .map(|s| format!("`{}`", s)) + .collect::>() + .join(", ") + } else { + let shown: Vec<_> = items[..max_items] + .iter() + .map(|s| format!("`{}`", s)) + .collect(); + format!( + "{}, ... ({} more)", + shown.join(", "), + items.len() - max_items + ) + } +} + impl<'a> Query<'a> { /// Link query against a language grammar. /// @@ -96,7 +157,6 @@ impl<'a> Query<'a> { let Some(type_token) = node.node_type() else { return; }; - // Skip ERROR and MISSING - they're built-in tree-sitter concepts if matches!( type_token.kind(), SyntaxKind::KwError | SyntaxKind::KwMissing @@ -113,10 +173,19 @@ impl<'a> Query<'a> { resolved, ); if resolved.is_none() { - self.link_diagnostics + let all_types = lang.all_named_node_kinds(); + let max_dist = (type_name.len() / 3).clamp(2, 4); + let suggestion = find_similar(type_name, &all_types, max_dist); + + let mut builder = self + .link_diagnostics .report(DiagnosticKind::UnknownNodeType, type_token.text_range()) - .message(type_name) - .emit(); + .message(type_name); + + if let Some(similar) = suggestion { + builder = builder.hint(format!("did you mean `{}`?", similar)); + } + builder.emit(); } } @@ -134,6 +203,11 @@ impl<'a> Query<'a> { for child in node.children() { self.collect_fields(&child, lang); } + for child in node.as_cst().children() { + if let Some(neg) = ast::NegatedField::cast(child) { + self.resolve_field_by_token(neg.name(), lang); + } + } } Expr::AltExpr(alt) => { for branch in alt.branches() { @@ -155,25 +229,16 @@ impl<'a> Query<'a> { self.collect_fields(&inner, lang); } Expr::FieldExpr(f) => { - self.resolve_field_expr(f, lang); + self.resolve_field_by_token(f.name(), lang); let Some(value) = f.value() else { return }; self.collect_fields(&value, lang); } Expr::AnonymousNode(_) | Expr::Ref(_) => {} } - - // Also check NegatedField (predicate, not in Expr enum) - if let Some(node) = ast::NamedNode::cast(expr.as_cst().clone()) { - for child in node.as_cst().children() { - if let Some(neg) = ast::NegatedField::cast(child) { - self.resolve_negated_field(&neg, lang); - } - } - } } - fn resolve_field_expr(&mut self, field: &ast::FieldExpr, lang: &Lang) { - let Some(name_token) = field.name() else { + fn resolve_field_by_token(&mut self, name_token: Option, lang: &Lang) { + let Some(name_token) = name_token else { return; }; let field_name = name_token.text(); @@ -185,33 +250,22 @@ impl<'a> Query<'a> { &self.source[text_range_to_usize(name_token.text_range())], resolved, ); - if resolved.is_none() { - self.link_diagnostics - .report(DiagnosticKind::UnknownField, name_token.text_range()) - .message(field_name) - .emit(); - } - } - - fn resolve_negated_field(&mut self, neg: &ast::NegatedField, lang: &Lang) { - let Some(name_token) = neg.name() else { - return; - }; - let field_name = name_token.text(); - if self.node_field_ids.contains_key(field_name) { + if resolved.is_some() { return; } - let resolved = lang.resolve_field(field_name); - self.node_field_ids.insert( - &self.source[text_range_to_usize(name_token.text_range())], - resolved, - ); - if resolved.is_none() { - self.link_diagnostics - .report(DiagnosticKind::UnknownField, name_token.text_range()) - .message(field_name) - .emit(); + let all_fields = lang.all_field_names(); + let max_dist = (field_name.len() / 3).clamp(2, 4); + let suggestion = find_similar(field_name, &all_fields, max_dist); + + let mut builder = self + .link_diagnostics + .report(DiagnosticKind::UnknownField, name_token.text_range()) + .message(field_name); + + if let Some(similar) = suggestion { + builder = builder.hint(format!("did you mean `{}`?", similar)); } + builder.emit(); } fn validate_structure(&mut self, lang: &Lang) { @@ -234,7 +288,6 @@ impl<'a> Query<'a> { for child in node.children() { self.validate_expr_structure(&child, current_type_id, lang); } - // Also validate NegatedField predicates for child in node.as_cst().children() { if let Some(neg) = ast::NegatedField::cast(child) { self.validate_negated_field(&neg, current_type_id, lang); @@ -244,7 +297,6 @@ impl<'a> Query<'a> { Expr::FieldExpr(f) => { self.validate_field(f, parent_type_id, lang); let Some(value) = f.value() else { return }; - // Field children don't inherit parent_type_id - they're the field value self.validate_expr_structure(&value, parent_type_id, lang); } Expr::AltExpr(alt) => { @@ -281,38 +333,47 @@ impl<'a> Query<'a> { }; let field_name = name_token.text(); - // Get field ID - skip if unresolved (already reported) let Some(field_id) = self.node_field_ids.get(field_name).copied().flatten() else { return; }; - // Check if field exists on parent node type let Some(parent_id) = parent_type_id else { return; }; if !lang.has_field(parent_id, field_id) { - self.link_diagnostics - .report(DiagnosticKind::FieldNotOnNodeType, name_token.text_range()) - .message(field_name) - .emit(); + self.emit_field_not_on_node(name_token.text_range(), field_name, parent_id, lang); return; } - // Check if child type is valid for this field let Some(value) = field.value() else { return; }; - let child_type_id = self.get_expr_type_id(&value); - let Some(child_id) = child_type_id else { + let Some(child_id) = self.get_expr_type_id(&value) else { return; }; - if !lang.is_valid_field_type(parent_id, field_id, child_id) { - let child_name = self.get_expr_type_name(&value).unwrap_or("(unknown)"); - self.link_diagnostics - .report(DiagnosticKind::InvalidFieldChildType, value.text_range()) - .message(child_name) - .emit(); + if lang.is_valid_field_type(parent_id, field_id, child_id) { + return; + } + let child_name = self.get_expr_type_name(&value).unwrap_or("(unknown)"); + let valid_types = lang.valid_field_types(parent_id, field_id); + let valid_names: Vec<&str> = valid_types + .iter() + .filter_map(|&id| lang.node_type_name(id)) + .collect(); + + let mut builder = self + .link_diagnostics + .report(DiagnosticKind::InvalidFieldChildType, value.text_range()) + .message(child_name); + + if !valid_names.is_empty() { + builder = builder.hint(format!( + "valid types for `{}`: {}", + field_name, + format_list(&valid_names, 5) + )); } + builder.emit(); } fn validate_negated_field( @@ -333,12 +394,37 @@ impl<'a> Query<'a> { let Some(parent_id) = parent_type_id else { return; }; - if !lang.has_field(parent_id, field_id) { - self.link_diagnostics - .report(DiagnosticKind::FieldNotOnNodeType, name_token.text_range()) - .message(field_name) - .emit(); + if lang.has_field(parent_id, field_id) { + return; + } + self.emit_field_not_on_node(name_token.text_range(), field_name, parent_id, lang); + } + + fn emit_field_not_on_node( + &mut self, + range: rowan::TextRange, + field_name: &str, + parent_id: NodeTypeId, + lang: &Lang, + ) { + let valid_fields = lang.fields_for_node_type(parent_id); + let parent_name = lang.node_type_name(parent_id).unwrap_or("(unknown)"); + + let mut builder = self + .link_diagnostics + .report(DiagnosticKind::FieldNotOnNodeType, range) + .message(field_name); + + if valid_fields.is_empty() { + builder = builder.hint(format!("`{}` has no fields", parent_name)); + } else { + builder = builder.hint(format!( + "valid fields for `{}`: {}", + parent_name, + format_list(&valid_fields, 5) + )); } + builder.emit(); } fn get_node_type_id(&self, node: &NamedNode) -> Option { diff --git a/crates/plotnik-lib/src/query/link_tests.rs b/crates/plotnik-lib/src/query/link_tests.rs new file mode 100644 index 00000000..734d8483 --- /dev/null +++ b/crates/plotnik-lib/src/query/link_tests.rs @@ -0,0 +1,327 @@ +use crate::Query; + +#[test] +fn valid_query_with_field() { + let mut query = Query::try_from("(function_declaration name: (identifier) @name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + CapturedExpr @fn + NamedNode function_declaration + CapturedExpr @name + FieldExpr name: + NamedNode identifier + "); +} + +#[test] +fn unknown_node_type_with_suggestion() { + let mut query = Query::try_from("(function_declaraton) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `function_declaraton` is not a valid node type + | + 1 | (function_declaraton) @fn + | ^^^^^^^^^^^^^^^^^^^ + | + help: did you mean `function_declaration`? + "); +} + +#[test] +fn unknown_node_type_no_suggestion() { + let mut query = Query::try_from("(xyzzy_foobar_baz) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `xyzzy_foobar_baz` is not a valid node type + | + 1 | (xyzzy_foobar_baz) @fn + | ^^^^^^^^^^^^^^^^ + "); +} + +#[test] +fn unknown_field_with_suggestion() { + let mut query = Query::try_from("(function_declaration nme: (identifier) @name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `nme` is not a valid field + | + 1 | (function_declaration nme: (identifier) @name) @fn + | ^^^ + | + help: did you mean `name`? + "); +} + +#[test] +fn unknown_field_no_suggestion() { + let mut query = + Query::try_from("(function_declaration xyzzy: (identifier) @name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `xyzzy` is not a valid field + | + 1 | (function_declaration xyzzy: (identifier) @name) @fn + | ^^^^^ + "); +} + +#[test] +fn field_not_on_node_type() { + let mut query = + Query::try_from("(function_declaration condition: (identifier) @name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: field `condition` is not valid on this node type + | + 1 | (function_declaration condition: (identifier) @name) @fn + | ^^^^^^^^^ + | + help: valid fields for `function_declaration`: `body`, `name`, `parameters` + "); +} + +#[test] +fn negated_field_unknown() { + let mut query = Query::try_from("(function_declaration !nme) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `nme` is not a valid field + | + 1 | (function_declaration !nme) @fn + | ^^^ + | + help: did you mean `name`? + "); +} + +#[test] +fn negated_field_not_on_node_type() { + let mut query = Query::try_from("(function_declaration !condition) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: field `condition` is not valid on this node type + | + 1 | (function_declaration !condition) @fn + | ^^^^^^^^^ + | + help: valid fields for `function_declaration`: `body`, `name`, `parameters` + "); +} + +#[test] +fn negated_field_valid() { + let mut query = Query::try_from("(function_declaration !name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + CapturedExpr @fn + NamedNode function_declaration + NegatedField !name + "); +} + +#[test] +fn anonymous_node_unknown() { + let mut query = Query::try_from("(function_declaration \"xyzzy_fake_token\") @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r#" + error: `xyzzy_fake_token` is not a valid node type + | + 1 | (function_declaration "xyzzy_fake_token") @fn + | ^^^^^^^^^^^^^^^^ + "#); +} + +#[test] +fn error_and_missing_nodes_skip_validation() { + let mut query = Query::try_from("(ERROR) @err").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); + + let mut query2 = Query::try_from("(MISSING) @miss").unwrap(); + query2.link(&plotnik_langs::javascript()); + assert!(query2.is_valid()); +} + +#[test] +fn multiple_errors_in_query() { + let mut query = Query::try_from("(function_declaraton nme: (identifer) @name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `function_declaraton` is not a valid node type + | + 1 | (function_declaraton nme: (identifer) @name) @fn + | ^^^^^^^^^^^^^^^^^^^ + | + help: did you mean `function_declaration`? + + error: `nme` is not a valid field + | + 1 | (function_declaraton nme: (identifer) @name) @fn + | ^^^ + | + help: did you mean `name`? + + error: `identifer` is not a valid node type + | + 1 | (function_declaraton nme: (identifer) @name) @fn + | ^^^^^^^^^ + | + help: did you mean `identifier`? + "); +} + +#[test] +fn nested_field_validation() { + let mut query = Query::try_from( + "(function_declaration body: (statement_block (return_statement) @ret) @body) @fn", + ) + .unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + CapturedExpr @fn + NamedNode function_declaration + CapturedExpr @body + FieldExpr body: + NamedNode statement_block + CapturedExpr @ret + NamedNode return_statement + "); +} + +#[test] +fn invalid_child_type_for_field() { + let mut query = + Query::try_from("(function_declaration name: (statement_block) @name) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: node type `statement_block` is not valid for this field + | + 1 | (function_declaration name: (statement_block) @name) @fn + | ^^^^^^^^^^^^^^^^^ + | + help: valid types for `name`: `identifier` + "); +} + +#[test] +fn alternation_with_link_errors() { + let mut query = Query::try_from("[(function_declaraton) (class_declaraton)] @decl").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `function_declaraton` is not a valid node type + | + 1 | [(function_declaraton) (class_declaraton)] @decl + | ^^^^^^^^^^^^^^^^^^^ + | + help: did you mean `function_declaration`? + + error: `class_declaraton` is not a valid node type + | + 1 | [(function_declaraton) (class_declaraton)] @decl + | ^^^^^^^^^^^^^^^^ + | + help: did you mean `class_declaration`? + "); +} + +#[test] +fn sequence_with_link_errors() { + let mut query = + Query::try_from("(function_declaration {(identifer) (statement_block)} @body) @fn") + .unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: `identifer` is not a valid node type + | + 1 | (function_declaration {(identifer) (statement_block)} @body) @fn + | ^^^^^^^^^ + | + help: did you mean `identifier`? + "); +} + +#[test] +fn quantified_expr_validation() { + let mut query = Query::try_from("(function_declaration (identifier)+ @names) @fn").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + CapturedExpr @fn + NamedNode function_declaration + CapturedExpr @names + QuantifiedExpr + + NamedNode identifier + "); +} + +#[test] +fn wildcard_node_skips_validation() { + let mut query = Query::try_from("(_) @any").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); +} + +#[test] +fn def_reference_with_link() { + let mut query = Query::try_from( + r#" + Func = (function_declaration name: (identifier) @name) @fn + (program (Func)+) + "#, + ) + .unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def Func + CapturedExpr @fn + NamedNode function_declaration + CapturedExpr @name + FieldExpr name: + NamedNode identifier + Def + NamedNode program + QuantifiedExpr + + Ref Func + "); +} + +#[test] +fn field_on_node_without_fields() { + let mut query = Query::try_from("(identifier name: (identifier) @inner) @id").unwrap(); + query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: field `name` is not valid on this node type + | + 1 | (identifier name: (identifier) @inner) @id + | ^^^^ + | + help: `identifier` has no fields + "); +} diff --git a/crates/plotnik-lib/src/query/mod.rs b/crates/plotnik-lib/src/query/mod.rs index 25226f5c..203b4197 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -18,6 +18,8 @@ pub mod symbol_table; #[cfg(test)] mod alt_kinds_tests; +#[cfg(all(test, feature = "plotnik-langs"))] +mod link_tests; #[cfg(test)] mod mod_tests; #[cfg(test)] From 05611cb7af0b3644a5884d8ab6646e57745fc4d4 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 14:37:37 -0300 Subject: [PATCH 3/4] Use indoc to improve query readability in link_tests --- crates/plotnik-lib/src/query/link_tests.rs | 215 +++++++++++++++------ 1 file changed, 161 insertions(+), 54 deletions(-) diff --git a/crates/plotnik-lib/src/query/link_tests.rs b/crates/plotnik-lib/src/query/link_tests.rs index 734d8483..de0d15ae 100644 --- a/crates/plotnik-lib/src/query/link_tests.rs +++ b/crates/plotnik-lib/src/query/link_tests.rs @@ -1,9 +1,16 @@ use crate::Query; +use indoc::indoc; #[test] fn valid_query_with_field() { - let mut query = Query::try_from("(function_declaration name: (identifier) @name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration + name: (identifier) @name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_ast(), @r" Root @@ -18,8 +25,13 @@ fn valid_query_with_field() { #[test] fn unknown_node_type_with_suggestion() { - let mut query = Query::try_from("(function_declaraton) @fn").unwrap(); + let input = indoc! {r#" + (function_declaraton) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `function_declaraton` is not a valid node type @@ -33,8 +45,13 @@ fn unknown_node_type_with_suggestion() { #[test] fn unknown_node_type_no_suggestion() { - let mut query = Query::try_from("(xyzzy_foobar_baz) @fn").unwrap(); + let input = indoc! {r#" + (xyzzy_foobar_baz) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `xyzzy_foobar_baz` is not a valid node type @@ -46,14 +63,20 @@ fn unknown_node_type_no_suggestion() { #[test] fn unknown_field_with_suggestion() { - let mut query = Query::try_from("(function_declaration nme: (identifier) @name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration + nme: (identifier) @name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `nme` is not a valid field | - 1 | (function_declaration nme: (identifier) @name) @fn - | ^^^ + 2 | nme: (identifier) @name) @fn + | ^^^ | help: did you mean `name`? "); @@ -61,29 +84,39 @@ fn unknown_field_with_suggestion() { #[test] fn unknown_field_no_suggestion() { - let mut query = - Query::try_from("(function_declaration xyzzy: (identifier) @name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration + xyzzy: (identifier) @name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `xyzzy` is not a valid field | - 1 | (function_declaration xyzzy: (identifier) @name) @fn - | ^^^^^ + 2 | xyzzy: (identifier) @name) @fn + | ^^^^^ "); } #[test] fn field_not_on_node_type() { - let mut query = - Query::try_from("(function_declaration condition: (identifier) @name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration + condition: (identifier) @name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: field `condition` is not valid on this node type | - 1 | (function_declaration condition: (identifier) @name) @fn - | ^^^^^^^^^ + 2 | condition: (identifier) @name) @fn + | ^^^^^^^^^ | help: valid fields for `function_declaration`: `body`, `name`, `parameters` "); @@ -91,8 +124,13 @@ fn field_not_on_node_type() { #[test] fn negated_field_unknown() { - let mut query = Query::try_from("(function_declaration !nme) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration !nme) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `nme` is not a valid field @@ -106,8 +144,13 @@ fn negated_field_unknown() { #[test] fn negated_field_not_on_node_type() { - let mut query = Query::try_from("(function_declaration !condition) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration !condition) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: field `condition` is not valid on this node type @@ -121,8 +164,13 @@ fn negated_field_not_on_node_type() { #[test] fn negated_field_valid() { - let mut query = Query::try_from("(function_declaration !name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration !name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_ast(), @r" Root @@ -135,8 +183,13 @@ fn negated_field_valid() { #[test] fn anonymous_node_unknown() { - let mut query = Query::try_from("(function_declaration \"xyzzy_fake_token\") @fn").unwrap(); + let input = indoc! {r#" + (function_declaration "xyzzy_fake_token") @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r#" error: `xyzzy_fake_token` is not a valid node type @@ -148,39 +201,55 @@ fn anonymous_node_unknown() { #[test] fn error_and_missing_nodes_skip_validation() { - let mut query = Query::try_from("(ERROR) @err").unwrap(); + let input = indoc! {r#" + (ERROR) @err + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); - let mut query2 = Query::try_from("(MISSING) @miss").unwrap(); + let input2 = indoc! {r#" + (MISSING) @miss + "#}; + + let mut query2 = Query::try_from(input2).unwrap(); query2.link(&plotnik_langs::javascript()); + assert!(query2.is_valid()); } #[test] fn multiple_errors_in_query() { - let mut query = Query::try_from("(function_declaraton nme: (identifer) @name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaraton + nme: (identifer) @name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `function_declaraton` is not a valid node type | - 1 | (function_declaraton nme: (identifer) @name) @fn + 1 | (function_declaraton | ^^^^^^^^^^^^^^^^^^^ | help: did you mean `function_declaration`? error: `nme` is not a valid field | - 1 | (function_declaraton nme: (identifer) @name) @fn - | ^^^ + 2 | nme: (identifer) @name) @fn + | ^^^ | help: did you mean `name`? error: `identifer` is not a valid node type | - 1 | (function_declaraton nme: (identifer) @name) @fn - | ^^^^^^^^^ + 2 | nme: (identifer) @name) @fn + | ^^^^^^^^^ | help: did you mean `identifier`? "); @@ -188,11 +257,15 @@ fn multiple_errors_in_query() { #[test] fn nested_field_validation() { - let mut query = Query::try_from( - "(function_declaration body: (statement_block (return_statement) @ret) @body) @fn", - ) - .unwrap(); + let input = indoc! {r#" + (function_declaration + body: (statement_block + (return_statement) @ret) @body) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_ast(), @r" Root @@ -209,15 +282,20 @@ fn nested_field_validation() { #[test] fn invalid_child_type_for_field() { - let mut query = - Query::try_from("(function_declaration name: (statement_block) @name) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration + name: (statement_block) @name) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: node type `statement_block` is not valid for this field | - 1 | (function_declaration name: (statement_block) @name) @fn - | ^^^^^^^^^^^^^^^^^ + 2 | name: (statement_block) @name) @fn + | ^^^^^^^^^^^^^^^^^ | help: valid types for `name`: `identifier` "); @@ -225,21 +303,27 @@ fn invalid_child_type_for_field() { #[test] fn alternation_with_link_errors() { - let mut query = Query::try_from("[(function_declaraton) (class_declaraton)] @decl").unwrap(); + let input = indoc! {r#" + [(function_declaraton) + (class_declaraton)] @decl + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `function_declaraton` is not a valid node type | - 1 | [(function_declaraton) (class_declaraton)] @decl + 1 | [(function_declaraton) | ^^^^^^^^^^^^^^^^^^^ | help: did you mean `function_declaration`? error: `class_declaraton` is not a valid node type | - 1 | [(function_declaraton) (class_declaraton)] @decl - | ^^^^^^^^^^^^^^^^ + 2 | (class_declaraton)] @decl + | ^^^^^^^^^^^^^^^^ | help: did you mean `class_declaration`? "); @@ -247,16 +331,21 @@ fn alternation_with_link_errors() { #[test] fn sequence_with_link_errors() { - let mut query = - Query::try_from("(function_declaration {(identifer) (statement_block)} @body) @fn") - .unwrap(); + let input = indoc! {r#" + (function_declaration + {(identifer) + (statement_block)} @body) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: `identifer` is not a valid node type | - 1 | (function_declaration {(identifer) (statement_block)} @body) @fn - | ^^^^^^^^^ + 2 | {(identifer) + | ^^^^^^^^^ | help: did you mean `identifier`? "); @@ -264,8 +353,14 @@ fn sequence_with_link_errors() { #[test] fn quantified_expr_validation() { - let mut query = Query::try_from("(function_declaration (identifier)+ @names) @fn").unwrap(); + let input = indoc! {r#" + (function_declaration + (identifier)+ @names) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_ast(), @r" Root @@ -280,21 +375,27 @@ fn quantified_expr_validation() { #[test] fn wildcard_node_skips_validation() { - let mut query = Query::try_from("(_) @any").unwrap(); + let input = indoc! {r#" + (_) @any + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); } #[test] fn def_reference_with_link() { - let mut query = Query::try_from( - r#" - Func = (function_declaration name: (identifier) @name) @fn + let input = indoc! {r#" + Func = (function_declaration + name: (identifier) @name) @fn (program (Func)+) - "#, - ) - .unwrap(); + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(query.is_valid()); insta::assert_snapshot!(query.dump_ast(), @r" Root @@ -313,14 +414,20 @@ fn def_reference_with_link() { #[test] fn field_on_node_without_fields() { - let mut query = Query::try_from("(identifier name: (identifier) @inner) @id").unwrap(); + let input = indoc! {r#" + (identifier + name: (identifier) @inner) @id + "#}; + + let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); + assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" error: field `name` is not valid on this node type | - 1 | (identifier name: (identifier) @inner) @id - | ^^^^ + 2 | name: (identifier) @inner) @id + | ^^^^ | help: `identifier` has no fields "); From 0ccfd4aae96c4177ccafca3e4216db70b3da96a3 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 14:48:35 -0300 Subject: [PATCH 4/4] Add field name suggestion for unknown fields --- crates/plotnik-lib/src/query/link.rs | 4 ++ crates/plotnik-lib/src/query/link_tests.rs | 43 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/plotnik-lib/src/query/link.rs b/crates/plotnik-lib/src/query/link.rs index 1ed26b2b..69bd71b7 100644 --- a/crates/plotnik-lib/src/query/link.rs +++ b/crates/plotnik-lib/src/query/link.rs @@ -418,6 +418,10 @@ impl<'a> Query<'a> { if valid_fields.is_empty() { builder = builder.hint(format!("`{}` has no fields", parent_name)); } else { + let max_dist = (field_name.len() / 3).clamp(2, 4); + if let Some(similar) = find_similar(field_name, &valid_fields, max_dist) { + builder = builder.hint(format!("did you mean `{}`?", similar)); + } builder = builder.hint(format!( "valid fields for `{}`: {}", parent_name, diff --git a/crates/plotnik-lib/src/query/link_tests.rs b/crates/plotnik-lib/src/query/link_tests.rs index de0d15ae..158cf3e5 100644 --- a/crates/plotnik-lib/src/query/link_tests.rs +++ b/crates/plotnik-lib/src/query/link_tests.rs @@ -122,6 +122,28 @@ fn field_not_on_node_type() { "); } +#[test] +fn field_not_on_node_type_with_suggestion() { + let input = indoc! {r#" + (function_declaration + parameter: (formal_parameters) @params) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); + query.link(&plotnik_langs::typescript()); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: field `parameter` is not valid on this node type + | + 2 | parameter: (formal_parameters) @params) @fn + | ^^^^^^^^^ + | + help: did you mean `parameters`? + help: valid fields for `function_declaration`: `body`, `name`, `parameters`, `return_type`, `type_parameters` + "); +} + #[test] fn negated_field_unknown() { let input = indoc! {r#" @@ -162,6 +184,27 @@ fn negated_field_not_on_node_type() { "); } +#[test] +fn negated_field_not_on_node_type_with_suggestion() { + let input = indoc! {r#" + (function_declaration !parameter) @fn + "#}; + + let mut query = Query::try_from(input).unwrap(); + query.link(&plotnik_langs::typescript()); + + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: field `parameter` is not valid on this node type + | + 1 | (function_declaration !parameter) @fn + | ^^^^^^^^^ + | + help: did you mean `parameters`? + help: valid fields for `function_declaration`: `body`, `name`, `parameters`, `return_type`, `type_parameters` + "); +} + #[test] fn negated_field_valid() { let input = indoc! {r#"