feat(definition): add go-to-definition for internal $ref
Resolve a JSON Schema `$ref` under the cursor against the document's own tree and point at the referenced node, so authoring a schema can navigate its `#/...` pointers. Only internal refs resolve, mirroring the validator: an external ref returns nothing. Resolution reuses the matcher's percent-decoding and then applies RFC 6901 token unescaping, so definition lands where validation would. The feature reads the document tree alone and needs no bound schema.
This commit is contained in:
@@ -12,6 +12,8 @@ Protocol over stdio.
|
||||
`const`, and boolean values.
|
||||
- **Hover.** Titles, descriptions, types, and constraints from the schema at
|
||||
the cursor.
|
||||
- **Go to definition.** On an internal `$ref` (`#/...`), jumps to the node the
|
||||
JSON Pointer names in the same document.
|
||||
- **Document symbols.** An outline of the object and array structure.
|
||||
- **Formatting.** Re-emits the syntax tree, preserving comments.
|
||||
- **JSONC.** Line and block comments and trailing commas are part of the
|
||||
@@ -113,6 +115,8 @@ The boundaries to be aware of:
|
||||
gets no proposals. Item documentation is built on demand through
|
||||
`completionItem/resolve`.
|
||||
- `textDocument/hover`
|
||||
- `textDocument/definition`, resolving an internal `$ref` pointer against the
|
||||
document's own tree
|
||||
- `textDocument/documentSymbol`
|
||||
- `textDocument/formatting`
|
||||
- `textDocument/codeLens`, `textDocument/codeAction`, and
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::document::Document;
|
||||
use crate::lsp::Location;
|
||||
use crate::lsp::Position;
|
||||
use crate::lsp::capabilities::PositionEncoding;
|
||||
use crate::schema::matcher::percent_decode;
|
||||
use crate::syntax::token::SyntaxKind;
|
||||
use crate::syntax::tree::Node;
|
||||
|
||||
/// Go-to-definition for an internal JSON Schema `$ref`. When the cursor sits
|
||||
/// on a `"$ref": "#/..."` string value, resolves the JSON Pointer against the
|
||||
/// same document and points at the referenced node.
|
||||
///
|
||||
/// External refs (anything not starting with `#`) resolve nothing, matching
|
||||
/// the validator, which only follows internal refs. This runs on the
|
||||
/// document's own tree, so it needs no bound schema and works while editing a
|
||||
/// schema document itself.
|
||||
pub fn definition(
|
||||
doc: &Document,
|
||||
position: Position,
|
||||
encoding: PositionEncoding,
|
||||
) -> Option<Location> {
|
||||
let offset = doc.line_index.offset(&doc.text, position, encoding);
|
||||
let root = doc.parse.value()?;
|
||||
let reference = ref_at(root, &doc.text, offset)?;
|
||||
let target = resolve_pointer(root, &doc.text, reference)?;
|
||||
Some(Location {
|
||||
uri: doc.uri.clone(),
|
||||
range: doc.line_index.range(&doc.text, target.range, encoding),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `$ref` string value the offset falls on, or `None` when the cursor is
|
||||
/// elsewhere or on a non-`$ref` string.
|
||||
fn ref_at<'a>(node: &'a Node, src: &'a str, offset: usize) -> Option<&'a str> {
|
||||
match node.kind {
|
||||
SyntaxKind::Object => {
|
||||
for member in node.members() {
|
||||
let Some(value) = member.value_node() else {
|
||||
continue;
|
||||
};
|
||||
if !value.range.touches(offset) {
|
||||
continue;
|
||||
}
|
||||
if value.kind == SyntaxKind::StringValue
|
||||
&& member.key_text(src).as_deref() == Some("$ref")
|
||||
{
|
||||
return value
|
||||
.scalar_token()
|
||||
.map(|t| trim_quotes(t.text(src)));
|
||||
}
|
||||
return ref_at(value, src, offset);
|
||||
}
|
||||
None
|
||||
}
|
||||
SyntaxKind::Array => node
|
||||
.elements()
|
||||
.find(|el| el.range.touches(offset))
|
||||
.and_then(|el| ref_at(el, src, offset)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an internal JSON Pointer reference (`#`, `#/...`) against the
|
||||
/// document tree. Percent-decoding then per-token `~` unescaping mirror how
|
||||
/// [`crate::schema::matcher`] resolves the same reference for validation.
|
||||
fn resolve_pointer<'a>(
|
||||
root: &'a Node,
|
||||
src: &str,
|
||||
reference: &str,
|
||||
) -> Option<&'a Node> {
|
||||
let fragment = reference.strip_prefix('#')?;
|
||||
let decoded;
|
||||
let fragment = if fragment.contains('%') {
|
||||
decoded = percent_decode(fragment)?;
|
||||
decoded.as_str()
|
||||
} else {
|
||||
fragment
|
||||
};
|
||||
if fragment.is_empty() {
|
||||
return Some(root);
|
||||
}
|
||||
let mut current = root;
|
||||
for token in fragment.strip_prefix('/')?.split('/') {
|
||||
current = child_by_token(current, &unescape_token(token), src)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn child_by_token<'a>(
|
||||
node: &'a Node,
|
||||
token: &str,
|
||||
src: &str,
|
||||
) -> Option<&'a Node> {
|
||||
match node.kind {
|
||||
SyntaxKind::Object => node
|
||||
.members()
|
||||
.find(|m| m.key_text(src).as_deref() == Some(token))
|
||||
.and_then(|m| m.value_node()),
|
||||
SyntaxKind::Array => node.elements().nth(token.parse().ok()?),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a JSON Pointer reference token per RFC 6901: `~1` is `/` and `~0`
|
||||
/// is `~`. The order matters so an encoded `~1` does not become `/`.
|
||||
fn unescape_token(token: &str) -> Cow<'_, str> {
|
||||
if token.contains('~') {
|
||||
Cow::Owned(token.replace("~1", "/").replace("~0", "~"))
|
||||
} else {
|
||||
Cow::Borrowed(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the surrounding quotes of a string token, tolerating a token whose
|
||||
/// closing quote the error-tolerant parser never saw.
|
||||
fn trim_quotes(raw: &str) -> &str {
|
||||
let body = raw.strip_prefix('"').unwrap_or(raw);
|
||||
body.strip_suffix('"').unwrap_or(body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::definition;
|
||||
use crate::document::Dialect;
|
||||
use crate::document::Document;
|
||||
use crate::lsp::Position;
|
||||
use crate::lsp::Range;
|
||||
use crate::lsp::capabilities::PositionEncoding::Utf16;
|
||||
|
||||
fn target_at(text: &str, line: u32, character: u32) -> Option<Range> {
|
||||
let doc = Document::new(
|
||||
"file:///t.json".to_owned(),
|
||||
1,
|
||||
text.into(),
|
||||
Dialect::Json,
|
||||
);
|
||||
definition(&doc, Position::new(line, character), Utf16).map(|loc| {
|
||||
assert_eq!(loc.uri, "file:///t.json");
|
||||
loc.range
|
||||
})
|
||||
}
|
||||
|
||||
/// The byte offset just after the last `needle` in `text`, as a
|
||||
/// single-line character position.
|
||||
fn after(text: &str, needle: &str) -> u32 {
|
||||
u32::try_from(text.rfind(needle).unwrap() + needle.len()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumps_to_a_defs_target() {
|
||||
let text = r##"{"a": {"$ref": "#/$defs/str"}, "$defs": {"str": {"type": "string"}}}"##;
|
||||
let range = target_at(text, 0, after(text, "#/$defs/str"))
|
||||
.expect("a definition");
|
||||
// The target is the `str` definition object.
|
||||
let start = usize::try_from(range.start.character).unwrap();
|
||||
assert!(text[start..].starts_with(r#"{"type": "string"}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_the_whole_document_for_a_bare_hash() {
|
||||
let text = r##"{"self": {"$ref": "#"}}"##;
|
||||
let range = target_at(text, 0, after(text, "#")).expect("a definition");
|
||||
assert_eq!(range.start.character, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_array_indices() {
|
||||
let text = r##"{"a": {"$ref": "#/items/1"}, "items": [10, 20]}"##;
|
||||
let range =
|
||||
target_at(text, 0, after(text, "#/items/1")).expect("a definition");
|
||||
let start = usize::try_from(range.start.character).unwrap();
|
||||
assert_eq!(&text[start..start + 2], "20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescapes_pointer_tokens() {
|
||||
let text = r##"{"a": {"$ref": "#/$defs/a~1b"}, "$defs": {"a/b": {"type": "null"}}}"##;
|
||||
assert!(
|
||||
target_at(text, 0, after(text, "a~1b")).is_some(),
|
||||
"the `~1` token should resolve to the key `a/b`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_tokens() {
|
||||
let text = r##"{"a": {"$ref": "#/$defs/a%20b"}, "$defs": {"a b": {"type": "null"}}}"##;
|
||||
assert!(target_at(text, 0, after(text, "a%20b")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_ref_resolves_nothing() {
|
||||
let text = r#"{"a": {"$ref": "https://x.test/s.json#/A"}}"#;
|
||||
assert!(target_at(text, 0, after(text, "s.json#/A")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_pointer_resolves_nothing() {
|
||||
let text = r##"{"a": {"$ref": "#/$defs/missing"}, "$defs": {}}"##;
|
||||
assert!(target_at(text, 0, after(text, "missing")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ref_string_resolves_nothing() {
|
||||
let text = r##"{"name": "#/$defs/str", "$defs": {"str": {}}}"##;
|
||||
assert!(target_at(text, 0, after(text, "$defs/str")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_off_the_ref_resolves_nothing() {
|
||||
let text = r#"{"$defs": {"str": {"type": "string"}}}"#;
|
||||
// On the `type` value, not a `$ref`.
|
||||
assert!(target_at(text, 0, after(text, "\"string")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod completion;
|
||||
pub mod definition;
|
||||
pub mod describe;
|
||||
pub mod diagnostics;
|
||||
pub mod formatting;
|
||||
|
||||
@@ -129,6 +129,7 @@ fn initialize_reports_expected_capabilities() {
|
||||
assert!(caps["completionProvider"].is_object());
|
||||
assert_eq!(caps["documentFormattingProvider"], json!(true));
|
||||
assert_eq!(caps["hoverProvider"], json!(true));
|
||||
assert_eq!(caps["definitionProvider"], json!(true));
|
||||
assert!(caps["codeLensProvider"].is_object());
|
||||
assert_eq!(caps["codeActionProvider"], json!(true));
|
||||
assert!(caps["executeCommandProvider"].is_object());
|
||||
@@ -409,6 +410,33 @@ fn hover_describes_the_schema() {
|
||||
assert!(contents.as_str().unwrap().contains("a count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_jumps_to_an_internal_ref_target() {
|
||||
let (mut server, _rx) = new_server();
|
||||
server.handle(request(1, "initialize", json!({})));
|
||||
let text =
|
||||
r##"{"a": {"$ref": "#/$defs/n"}, "$defs": {"n": {"type": "number"}}}"##;
|
||||
open(&mut server, text);
|
||||
|
||||
let character =
|
||||
u32::try_from(text.rfind("#/$defs/n").unwrap() + 2).unwrap();
|
||||
let out = server.handle(request(
|
||||
2,
|
||||
"textDocument/definition",
|
||||
json!({
|
||||
"textDocument": {"uri": DOC_URI},
|
||||
"position": {"line": 0, "character": character}
|
||||
}),
|
||||
));
|
||||
let location = response_result(&out);
|
||||
assert_eq!(location["uri"], json!(DOC_URI));
|
||||
let start = usize::try_from(
|
||||
location["range"]["start"]["character"].as_u64().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(text[start..].starts_with(r#"{"type": "number"}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_change_updates_diagnostics() {
|
||||
let (mut server, _rx) = new_server();
|
||||
|
||||
@@ -204,6 +204,7 @@ pub struct ServerCapabilities {
|
||||
pub completion_provider: CompletionOptions,
|
||||
pub document_formatting_provider: bool,
|
||||
pub hover_provider: bool,
|
||||
pub definition_provider: bool,
|
||||
pub document_symbol_provider: bool,
|
||||
pub code_lens_provider: CodeLensOptions,
|
||||
pub code_action_provider: bool,
|
||||
|
||||
@@ -164,6 +164,13 @@ pub struct Hover {
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
/// A span within a document, the result of `textDocument/definition`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Location {
|
||||
pub uri: String,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentFormattingParams {
|
||||
|
||||
@@ -553,7 +553,8 @@ fn property_subschemas<'a>(
|
||||
out.push(sub);
|
||||
matched = true;
|
||||
}
|
||||
if let Some(pats) = map.get("patternProperties").and_then(Value::as_object) {
|
||||
if let Some(pats) = map.get("patternProperties").and_then(Value::as_object)
|
||||
{
|
||||
for (pattern, sub) in pats {
|
||||
match cached_regex(pattern) {
|
||||
Some(re) if re.is_match(key) => {
|
||||
@@ -639,7 +640,7 @@ fn resolve_internal<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
|
||||
/// A `$ref` fragment is percent-encoded per RFC 3986, so `#/$defs/a%20b`
|
||||
/// points at the key `a b`. A malformed escape fails the lookup rather than
|
||||
/// being passed through raw.
|
||||
fn percent_decode(fragment: &str) -> Option<String> {
|
||||
pub(crate) fn percent_decode(fragment: &str) -> Option<String> {
|
||||
let bytes = fragment.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::Value;
|
||||
use crate::document::Dialect;
|
||||
use crate::document::Documents;
|
||||
use crate::features::completion;
|
||||
use crate::features::definition;
|
||||
use crate::features::diagnostics;
|
||||
use crate::features::formatting;
|
||||
use crate::features::hover;
|
||||
@@ -211,6 +212,7 @@ impl Server {
|
||||
"textDocument/completion" => self.on_completion(id, params),
|
||||
"completionItem/resolve" => self.on_completion_resolve(id, params),
|
||||
"textDocument/hover" => self.on_hover(id, params),
|
||||
"textDocument/definition" => self.on_definition(id, params),
|
||||
"textDocument/documentSymbol" => {
|
||||
self.on_document_symbol(id, params)
|
||||
}
|
||||
@@ -539,6 +541,23 @@ impl Server {
|
||||
ok(id, hover)
|
||||
}
|
||||
|
||||
fn on_definition(&self, id: Value, params: Option<Value>) -> Vec<Outgoing> {
|
||||
let params: TextDocumentPositionParams =
|
||||
match require_params(&id, params) {
|
||||
Ok(params) => params,
|
||||
Err(out) => return out,
|
||||
};
|
||||
// Resolved from the document's own tree, so no schema binding is
|
||||
// required.
|
||||
let location =
|
||||
self.documents
|
||||
.get(¶ms.text_document.uri)
|
||||
.and_then(|doc| {
|
||||
definition::definition(doc, params.position, self.encoding)
|
||||
});
|
||||
ok(id, location)
|
||||
}
|
||||
|
||||
fn on_document_symbol(
|
||||
&self,
|
||||
id: Value,
|
||||
@@ -702,6 +721,7 @@ impl Server {
|
||||
},
|
||||
document_formatting_provider: true,
|
||||
hover_provider: true,
|
||||
definition_provider: true,
|
||||
document_symbol_provider: true,
|
||||
code_lens_provider: CodeLensOptions {
|
||||
resolve_provider: false,
|
||||
|
||||
Reference in New Issue
Block a user