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-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/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..e48a4b55 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(), @@ -272,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 { @@ -283,6 +302,7 @@ impl DiagnosticMessage { message: message.into(), fix: None, related: Vec::new(), + hints: Vec::new(), } } @@ -325,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 new file mode 100644 index 00000000..69bd71b7 --- /dev/null +++ b/crates/plotnik-lib/src/query/link.rs @@ -0,0 +1,493 @@ +//! 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, 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. + /// + /// 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; + }; + 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() { + 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); + + if let Some(similar) = suggestion { + builder = builder.hint(format!("did you mean `{}`?", similar)); + } + builder.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); + } + 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() { + 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_by_token(f.name(), lang); + let Some(value) = f.value() else { return }; + self.collect_fields(&value, lang); + } + Expr::AnonymousNode(_) | Expr::Ref(_) => {} + } + } + + 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(); + 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_some() { + return; + } + 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) { + 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); + } + 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 }; + 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(); + + 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.emit_field_not_on_node(name_token.text_range(), field_name, parent_id, lang); + return; + } + + let Some(value) = field.value() else { + return; + }; + let Some(child_id) = self.get_expr_type_id(&value) else { + return; + }; + 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( + &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) { + 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 { + 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, + format_list(&valid_fields, 5) + )); + } + builder.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/link_tests.rs b/crates/plotnik-lib/src/query/link_tests.rs new file mode 100644 index 00000000..158cf3e5 --- /dev/null +++ b/crates/plotnik-lib/src/query/link_tests.rs @@ -0,0 +1,477 @@ +use crate::Query; +use indoc::indoc; + +#[test] +fn valid_query_with_field() { + 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 + Def + CapturedExpr @fn + NamedNode function_declaration + CapturedExpr @name + FieldExpr name: + NamedNode identifier + "); +} + +#[test] +fn unknown_node_type_with_suggestion() { + 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 + | + 1 | (function_declaraton) @fn + | ^^^^^^^^^^^^^^^^^^^ + | + help: did you mean `function_declaration`? + "); +} + +#[test] +fn unknown_node_type_no_suggestion() { + 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 + | + 1 | (xyzzy_foobar_baz) @fn + | ^^^^^^^^^^^^^^^^ + "); +} + +#[test] +fn unknown_field_with_suggestion() { + 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 + | + 2 | nme: (identifier) @name) @fn + | ^^^ + | + help: did you mean `name`? + "); +} + +#[test] +fn unknown_field_no_suggestion() { + 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 + | + 2 | xyzzy: (identifier) @name) @fn + | ^^^^^ + "); +} + +#[test] +fn field_not_on_node_type() { + 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 + | + 2 | condition: (identifier) @name) @fn + | ^^^^^^^^^ + | + help: valid fields for `function_declaration`: `body`, `name`, `parameters` + "); +} + +#[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#" + (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 + | + 1 | (function_declaration !nme) @fn + | ^^^ + | + help: did you mean `name`? + "); +} + +#[test] +fn negated_field_not_on_node_type() { + 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 + | + 1 | (function_declaration !condition) @fn + | ^^^^^^^^^ + | + help: valid fields for `function_declaration`: `body`, `name`, `parameters` + "); +} + +#[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#" + (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 + Def + CapturedExpr @fn + NamedNode function_declaration + NegatedField !name + "); +} + +#[test] +fn anonymous_node_unknown() { + 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 + | + 1 | (function_declaration "xyzzy_fake_token") @fn + | ^^^^^^^^^^^^^^^^ + "#); +} + +#[test] +fn error_and_missing_nodes_skip_validation() { + let input = indoc! {r#" + (ERROR) @err + "#}; + + let mut query = Query::try_from(input).unwrap(); + query.link(&plotnik_langs::javascript()); + + assert!(query.is_valid()); + + 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 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 + | ^^^^^^^^^^^^^^^^^^^ + | + help: did you mean `function_declaration`? + + error: `nme` is not a valid field + | + 2 | nme: (identifer) @name) @fn + | ^^^ + | + help: did you mean `name`? + + error: `identifer` is not a valid node type + | + 2 | nme: (identifer) @name) @fn + | ^^^^^^^^^ + | + help: did you mean `identifier`? + "); +} + +#[test] +fn nested_field_validation() { + 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 + 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 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 + | + 2 | name: (statement_block) @name) @fn + | ^^^^^^^^^^^^^^^^^ + | + help: valid types for `name`: `identifier` + "); +} + +#[test] +fn alternation_with_link_errors() { + 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) + | ^^^^^^^^^^^^^^^^^^^ + | + help: did you mean `function_declaration`? + + error: `class_declaraton` is not a valid node type + | + 2 | (class_declaraton)] @decl + | ^^^^^^^^^^^^^^^^ + | + help: did you mean `class_declaration`? + "); +} + +#[test] +fn sequence_with_link_errors() { + 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 + | + 2 | {(identifer) + | ^^^^^^^^^ + | + help: did you mean `identifier`? + "); +} + +#[test] +fn quantified_expr_validation() { + 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 + Def + CapturedExpr @fn + NamedNode function_declaration + CapturedExpr @names + QuantifiedExpr + + NamedNode identifier + "); +} + +#[test] +fn wildcard_node_skips_validation() { + 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 input = indoc! {r#" + Func = (function_declaration + name: (identifier) @name) @fn + (program (Func)+) + "#}; + + 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 + 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 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 + | + 2 | 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 c768deac..203b4197 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -10,12 +10,16 @@ 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; #[cfg(test)] mod alt_kinds_tests; +#[cfg(all(test, feature = "plotnik-langs"))] +mod link_tests; #[cfg(test)] mod mod_tests; #[cfg(test)] @@ -29,6 +33,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 +63,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 +75,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 +97,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 +109,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 +218,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 +232,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()