diff --git a/README.md b/README.md index fbe6f2f..3493cc2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/features/definition.rs b/src/features/definition.rs new file mode 100644 index 0000000..97b35e3 --- /dev/null +++ b/src/features/definition.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/src/features/mod.rs b/src/features/mod.rs index 5b7afd6..1cd9df9 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,4 +1,5 @@ pub mod completion; +pub mod definition; pub mod describe; pub mod diagnostics; pub mod formatting; diff --git a/src/integration_tests.rs b/src/integration_tests.rs index 01bc4df..85cc9f9 100644 --- a/src/integration_tests.rs +++ b/src/integration_tests.rs @@ -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(); diff --git a/src/lsp/capabilities.rs b/src/lsp/capabilities.rs index 5586144..0cb7f72 100644 --- a/src/lsp/capabilities.rs +++ b/src/lsp/capabilities.rs @@ -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, diff --git a/src/lsp/mod.rs b/src/lsp/mod.rs index 048d627..ccad748 100644 --- a/src/lsp/mod.rs +++ b/src/lsp/mod.rs @@ -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 { diff --git a/src/schema/matcher.rs b/src/schema/matcher.rs index d171f17..9f88084 100644 --- a/src/schema/matcher.rs +++ b/src/schema/matcher.rs @@ -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 { +pub(crate) fn percent_decode(fragment: &str) -> Option { let bytes = fragment.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; diff --git a/src/server.rs b/src/server.rs index e983405..7047615 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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) -> Vec { + 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,