mirror of
https://github.com/ribru17/ts_query_ls.git
synced 2025-12-23 05:36:52 +00:00
feat(hover): coverage for negated fields (#131)
Also adds some more tests and refactoring to the hover code
This commit is contained in:
parent
ec5827a853
commit
354dc41b43
6 changed files with 301 additions and 147 deletions
|
|
@ -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
12
docs/negation.md
Normal 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
46
queries/query/hover.scm
Normal 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)
|
||||
|
|
@ -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 = ¶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<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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
src/util.rs
33
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<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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue