feat(hover): coverage for negated fields (#131)

Also adds some more tests and refactoring to the hover code
This commit is contained in:
Riley Bruins 2025-05-19 13:52:48 -07:00 committed by GitHub
parent ec5827a853
commit 354dc41b43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 301 additions and 147 deletions

View file

@ -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
```

12
docs/negation.md Normal file
View file

@ -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)
```

46
queries/query/hover.scm Normal file
View file

@ -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)

View file

@ -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<Query> = 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/<name>.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<HashMap<&'static str, &'static str>> = include_docs_map!(
"missing",
"wildcard",
"anchor",
"quantifier",
"alternation",
"error",
"negation",
);
pub async fn hover(backend: &Backend, params: HoverParams) -> Result<Option<Hover>> {
let uri = &params.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<Option<Hove
.and_then(|name| backend.language_map.get(name));
let supertypes = language_data.as_ref().map(|ld| &ld.supertype_map);
let Some(node) = tree
.root_node()
.descendant_for_point_range(position.to_ts_point(rope), position.to_ts_point(rope))
else {
let Some(capture) = capture_at_pos(tree, rope, &HOVER_QUERY, position.to_ts_point(rope)) else {
return Ok(None);
};
let node_text = node.text(rope);
let node_range = node.lsp_range(rope);
let sym = SymbolInfo {
label: node_text.clone(),
named: true,
};
let capture_name = HOVER_QUERY.capture_names()[capture.index as usize];
let capture_text = capture.node.text(rope);
let range = Some(capture.node.lsp_range(rope));
let node_parent = node.parent();
if node.kind() == "identifier"
&& node_parent.is_some_and(|p| {
p.kind() == "named_node" || p.kind() == "missing_node" || p.kind() == "predicate"
})
{
let node_parent = node_parent.unwrap();
if node_parent.kind() == "predicate" {
let is_predicate = node_parent
.named_child(1)
.is_some_and(|c| c.text(rope) == "?");
let validator = if is_predicate {
Ok(match capture_name {
doc_name if DOCS.contains_key(capture_name) => {
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<Option<Hove
value += format!(" - {}\n", desc).as_str();
}
}
return Ok(Some(Hover {
range: Some(node_range),
Some(Hover {
range: Some(range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
}));
}
return Ok(None);
}
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 `({node_text})`:\n\n```query"),
|acc, subtype| format!("{acc}\n{}", subtype),
) + "\n```"
};
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
}));
} else if node_text == "ERROR" {
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: String::from(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/error.md"
))),
}),
}));
None
}
}
} else if node.kind() == "MISSING" {
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: String::from(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/missing.md"
))),
}),
}));
} else if node.kind() == "_" {
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: String::from(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/wildcard.md"
))),
}),
}));
} else if let Some(capture) =
get_current_capture_node(tree.root_node(), position.to_ts_point(rope))
{
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(rope)[1..].to_string()))
}) {
let value = format!("## `{}`\n\n{}", capture.text(rope), description);
return Ok(Some(Hover {
range: Some(capture.lsp_range(rope)),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
}));
}
} else if node.kind() == "." && node_parent.is_some_and(|p| p.kind() != "predicate") {
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: String::from(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/anchor.md"
))),
}),
}));
} else if node.kind() == "?" || node.kind() == "*" || node.kind() == "+" {
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: String::from(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/quantification.md"
))),
}),
}));
} else if node.kind() == "[" || node.kind() == "]" {
return Ok(Some(Hover {
range: Some(node_range),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: String::from(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/alternation.md"
))),
}),
}));
}
Ok(None)
_ => 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::<HoverRequest>(expected)),
tokens,
Some(lsp_response_to_jsonrpc_response::<HoverRequest>(actual))
);
}
}

View file

@ -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<String> {
.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<QueryCapture<'t>> {
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
}