diff --git a/docs/missing.md b/docs/missing.md index acbeaab..093dd54 100644 --- a/docs/missing.md +++ b/docs/missing.md @@ -11,3 +11,12 @@ special missing nodes can be queried using `(MISSING)`: ```query (MISSING) @missing-node ``` + +This is useful when attempting to detect all syntax errors in a given parse +tree, since these missing node are not captured by `(ERROR)` queries. Specific +missing node types can also be queried: + +```query +(MISSING identifier) @missing-identifier +(MISSING ";") @missing-semicolon +``` diff --git a/docs/negation.md b/docs/negation.md new file mode 100644 index 0000000..1d95a38 --- /dev/null +++ b/docs/negation.md @@ -0,0 +1,12 @@ +## Negated Fields + +You can also constrain a pattern so that it only matches nodes that _lack_ a +certain field. To do this, add a field name prefixed by a `!` within the parent +pattern. For example, this pattern would match a class declaration with no type +parameters: + +```query +(class_declaration + name: (identifier) @class_name + !type_parameters) +``` diff --git a/docs/quantification.md b/docs/quantifier.md similarity index 100% rename from docs/quantification.md rename to docs/quantifier.md diff --git a/queries/query/hover.scm b/queries/query/hover.scm new file mode 100644 index 0000000..5220281 --- /dev/null +++ b/queries/query/hover.scm @@ -0,0 +1,46 @@ +"MISSING" @missing + +"_" @wildcard + +(capture) @capture + +(named_node + "." @anchor) + +(grouping + "." @anchor) + +[ + "?" + "+" + "*" +] @quantifier + +[ + "[" + "]" +] @alternation + +(negated_field + "!" @negation) + +(definition/named_node) @capture + +(named_node + (identifier) @identifier.node) + +(named_node + name: (identifier) @error + (#eq? @error "ERROR")) + +(missing_node + name: (identifier) @identifier.node) + +(missing_node + name: (identifier) @error + (#eq? @error "ERROR")) + +(predicate + name: _ @predicate + name: (identifier) @predicate + (predicate_type) @predicate) diff --git a/src/handlers/hover.rs b/src/handlers/hover.rs index 6ba83a4..9d6f115 100644 --- a/src/handlers/hover.rs +++ b/src/handlers/hover.rs @@ -1,14 +1,49 @@ +use std::{collections::HashMap, sync::LazyLock}; + use tower_lsp::{ jsonrpc::Result, lsp_types::{Hover, HoverContents, HoverParams, MarkupContent, MarkupKind}, }; use tracing::warn; +use tree_sitter::Query; use crate::{ - Backend, SymbolInfo, - util::{NodeUtil, ToTsPoint, get_current_capture_node, uri_to_basename}, + Backend, QUERY_LANGUAGE, SymbolInfo, + util::{NodeUtil, ToTsPoint, capture_at_pos, uri_to_basename}, }; +static HOVER_QUERY: LazyLock = LazyLock::new(|| { + Query::new( + &QUERY_LANGUAGE, + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/queries/query/hover.scm" + )), + ) + .unwrap() +}); + +/// Create a static hashmap from doc name to doc file (found in "docs/.md") +macro_rules! include_docs_map { + ($($name:literal),* $(,)?) => { + LazyLock::new(|| { + HashMap::from([$( + ($name, include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/", $name, ".md"))), + )*]) + }) + }; +} + +static DOCS: LazyLock> = include_docs_map!( + "missing", + "wildcard", + "anchor", + "quantifier", + "alternation", + "error", + "negation", +); + pub async fn hover(backend: &Backend, params: HoverParams) -> Result> { let uri = ¶ms.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; @@ -27,36 +62,89 @@ pub async fn hover(backend: &Backend, params: HoverParams) -> Result { + let value = DOCS.get(doc_name).unwrap().to_string(); + Some(Hover { + range, + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + }) + } + "capture" => { + let options = backend.options.read().await; + if let Some(description) = uri_to_basename(uri).and_then(|base| { + options + .valid_captures + .get(&base) + .and_then(|c| c.get(&capture_text[1..].to_string())) + }) { + let value = format!("## `{}`\n\n{}", capture_text, description); + Some(Hover { + range, + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + }) + } else { + None + } + } + "identifier.node" => { + let sym = SymbolInfo { + label: capture_text, + named: true, + }; + if let Some(subtypes) = supertypes.and_then(|supertypes| supertypes.get(&sym)) { + let value = if subtypes.is_empty() { + String::from("Subtypes could not be determined (parser ABI < 15)") + } else { + subtypes.iter().fold( + format!("Subtypes of `({})`:\n\n```query", sym.label), + |acc, subtype| format!("{acc}\n{}", subtype), + ) + "\n```" + }; + Some(Hover { + range, + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + }) + } else { + None + } + } + "predicate" => { + let parent = capture + .node + .parent() + .expect("Should be children of the `(predicate)` node"); + let (Some(predicate_name), Some(predicate_type)) = + (parent.named_child(0), parent.named_child(1)) + else { + return Ok(None); + }; + let validator = if predicate_type.text(rope) == "?" { &options.valid_predicates } else { &options.valid_directives }; - if let Some(predicate) = validator.get(&node_text) { + let mut range = predicate_name.lsp_range(rope); + // Include # and ? in the range + range.start.character -= 1; + range.end.character += 1; + if let Some(predicate) = validator.get(&predicate_name.text(rope)) { let mut value = format!("{}\n\n---\n\n## Parameters:\n\n", predicate.description); for param in &predicate.parameters { value += format!("- Type: `{}` ({})\n", param.type_, param.arity).as_str(); @@ -64,128 +152,28 @@ pub async fn hover(backend: &Backend, params: HoverParams) -> Result None, + }) } #[cfg(test)] mod test { use std::collections::{BTreeMap, HashMap}; - use ts_query_ls::Options; + use ts_query_ls::{ + Options, Predicate, PredicateParameter, PredicateParameterArity, PredicateParameterType, + }; use pretty_assertions::assert_eq; use rstest::rstest; @@ -215,6 +203,12 @@ _ @any (function (identifier)+)* @cap [ (number) (boolean) ] @const + +((number) @const (.set! foo bar)) + +(identifier !fieldname) + +((number) @const (#eq? @const self)) "; #[rstest] @@ -291,21 +285,21 @@ An error node", BTreeMap::from([(String::from("error"), String::from("An error n Position { line: 9, character: 25 } ), include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/docs/quantification.md" + "/docs/quantifier.md" )), BTreeMap::from([(String::from("error"), String::from("An error node"))]))] #[case(SOURCE, vec![], Position { line: 11, character: 24 }, Range::new( Position { line: 11, character: 24 }, Position { line: 11, character: 25 } ), include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/docs/quantification.md" + "/docs/quantifier.md" )), BTreeMap::from([(String::from("error"), String::from("An error node"))]))] #[case(SOURCE, vec![], Position { line: 11, character: 22 }, Range::new( Position { line: 11, character: 22 }, Position { line: 11, character: 23 } ), include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/docs/quantification.md" + "/docs/quantifier.md" )), BTreeMap::from([(String::from("error"), String::from("An error node"))]))] #[case(SOURCE, vec![], Position { line: 13, character: 0 }, Range::new( Position { line: 13, character: 0 }, @@ -321,6 +315,35 @@ An error node", BTreeMap::from([(String::from("error"), String::from("An error n env!("CARGO_MANIFEST_DIR"), "/docs/alternation.md" )), BTreeMap::from([(String::from("error"), String::from("An error node"))]))] + #[case(SOURCE, vec![], Position { line: 15, character: 18 }, Range { + start: Position::new(15, 18), + end: Position::new(15, 23) + }, + "Set a property\n\n---\n\n## Parameters:\n\n- Type: `string` (required)\n - A property\n", BTreeMap::default())] + #[case(SOURCE, vec![], Position { line: 15, character: 22 }, Range { + start: Position::new(15, 18), + end: Position::new(15, 23) + }, + "Set a property\n\n---\n\n## Parameters:\n\n- Type: `string` (required)\n - A property\n", BTreeMap::default())] + #[case(SOURCE, vec![], Position { line: 15, character: 23 }, Range::default(), "", BTreeMap::default())] + #[case(SOURCE, vec![], Position { line: 15, character: 21 }, Range { + start: Position::new(15, 18), + end: Position::new(15, 23) + }, + "Set a property\n\n---\n\n## Parameters:\n\n- Type: `string` (required)\n - A property\n", BTreeMap::default())] + #[case(SOURCE, vec![], Position { line: 17, character: 12 }, Range { + start: Position::new(17, 12), + end: Position::new(17, 13), + }, + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/docs/negation.md" + )), BTreeMap::default())] + #[case(SOURCE, vec![], Position { line: 19, character: 18 }, Range { + start: Position::new(19, 18), + end: Position::new(19, 22) + }, + "Check for equality\n\n---\n\n## Parameters:\n\n- Type: `capture` (required)\n - A capture\n- Type: `string` (required)\n - A string\n", BTreeMap::default())] #[tokio::test(flavor = "current_thread")] async fn hover( #[case] source: &str, @@ -335,6 +358,35 @@ An error node", BTreeMap::from([(String::from("error"), String::from("An error n &[(TEST_URI.clone(), source, Vec::new(), Vec::new(), supertypes)], &Options { valid_captures: HashMap::from([(String::from("test"), captures)]), + valid_predicates: BTreeMap::from([( + String::from("eq"), + Predicate { + description: String::from("Check for equality"), + parameters: vec![ + PredicateParameter { + description: Some(String::from("A capture")), + type_: PredicateParameterType::Capture, + arity: PredicateParameterArity::Required, + }, + PredicateParameter { + description: Some(String::from("A string")), + type_: PredicateParameterType::String, + arity: PredicateParameterArity::Required, + }, + ], + }, + )]), + valid_directives: BTreeMap::from([( + String::from("set"), + Predicate { + description: String::from("Set a property"), + parameters: vec![PredicateParameter { + description: Some(String::from("A property")), + type_: PredicateParameterType::String, + arity: PredicateParameterArity::Required, + }], + }, + )]), ..Default::default() }, ) @@ -360,16 +412,20 @@ An error node", BTreeMap::from([(String::from("error"), String::from("An error n .unwrap(); // Assert - let actual = Some(Hover { - range: Some(range), - contents: HoverContents::Markup(MarkupContent { - kind: MarkupKind::Markdown, - value: String::from(hover_content), - }), - }); + let expected = if hover_content.is_empty() { + None + } else { + Some(Hover { + range: Some(range), + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: String::from(hover_content), + }), + }) + }; assert_eq!( + Some(lsp_response_to_jsonrpc_response::(expected)), tokens, - Some(lsp_response_to_jsonrpc_response::(actual)) ); } } diff --git a/src/util.rs b/src/util.rs index ba716ff..e7ccbe0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -10,7 +10,10 @@ use serde_json::Value; use streaming_iterator::StreamingIterator; use tower_lsp::lsp_types::{Position, Range, TextDocumentContentChangeEvent, Url}; use tracing::warn; -use tree_sitter::{InputEdit, Language, Node, Point, Query, QueryCursor, TextProvider, WasmStore}; +use tree_sitter::{ + InputEdit, Language, Node, Point, Query, QueryCapture, QueryCursor, TextProvider, Tree, + WasmStore, +}; use crate::{Backend, ENGINE, Options, QUERY_LANGUAGE}; @@ -348,3 +351,31 @@ pub fn uri_to_basename(uri: &Url) -> Option { .map(|os_str| os_str.to_string_lossy().into_owned()) }) } + +/// Return the innermost capture at the given position, if any. +pub fn capture_at_pos<'t>( + tree: &'t Tree, + rope: &Rope, + query: &Query, + point: Point, +) -> Option> { + let provider = TextProviderRope(rope); + let mut cursor = QueryCursor::new(); + let mut p2 = point; + p2.column += 1; + + cursor.set_point_range(point..p2); + let mut matches = cursor.matches(query, tree.root_node(), &provider); + + let mut innermost_capture = None; + while let Some(match_) = matches.next() { + for capture in match_.captures { + if capture.node.start_position() > point || capture.node.end_position() <= point { + continue; + } + innermost_capture = Some(*capture) + } + } + + innermost_capture +}