From 8c895b358f74bbea1e97315f594c2309e7344e21 Mon Sep 17 00:00:00 2001 From: Diya Date: Tue, 11 Aug 2026 07:44:38 +0530 Subject: [PATCH 1/3] add DocumentLink support --- language-server/src/build-server.ts | 2 + .../src/features/DocumentLinks.test.ts | 376 ++++++++++++++++++ language-server/src/features/DocumentLinks.ts | 292 ++++++++++++++ language-server/src/models/JsonDocument.ts | 4 +- 4 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 language-server/src/features/DocumentLinks.test.ts create mode 100644 language-server/src/features/DocumentLinks.ts diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 1f09bc5..1a9091c 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -11,6 +11,7 @@ import { Completion } from "./features/Completion.ts"; import { FoldingRanges } from "./features/FoldingRanges.ts"; import { DocumentSymbols } from "./features/DocumentSymbols.ts"; import { SelectionRanges } from "./features/SelectionRanges.ts"; +import { DocumentLinks } from "./features/DocumentLinks.ts"; import "@hyperjump/json-schema/draft-2020-12"; import "@hyperjump/json-schema/draft-2019-09"; @@ -43,6 +44,7 @@ export const buildServer = (connection: Connection): Server => { new FoldingRanges(server, documents); new DocumentSymbols(server, documents); new SelectionRanges(server, documents); + new DocumentLinks(server, documents, workspace); return server; }; diff --git a/language-server/src/features/DocumentLinks.test.ts b/language-server/src/features/DocumentLinks.test.ts new file mode 100644 index 0000000..266a519 --- /dev/null +++ b/language-server/src/features/DocumentLinks.test.ts @@ -0,0 +1,376 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { DocumentLinkRequest, PublishDiagnosticsNotification } from "vscode-languageserver"; +import { TestClient } from "../test/TestClient.ts"; + +describe("DocumentLinks", () => { + let client: TestClient; + + beforeEach(async () => { + client = new TestClient(); + await client.start(); + }); + + afterEach(async () => { + await client.stop(); + }); + + test("should resolve a plain JSON Pointer $ref", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { "type": "string" } + }, + "properties": { + "bar": { "$ref": "#/definitions/foo" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 5, character: 26 }, end: { line: 5, character: 43 } }, + target: `${uri}#3,16` + } + ]); + }); + + test("should resolve a $ref to the whole document", async () => { + await client.writeDocument("schema.json", `{ + "properties": { + "self": { "$ref": "#" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 2, character: 27 }, end: { line: 2, character: 28 } }, + target: `${uri}#1,1` + } + ]); + }); + + test("should resolve a $anchor reference", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { "$anchor": "fooAnchor", "type": "string" } + }, + "properties": { + "bar": { "$ref": "#fooAnchor" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 5, character: 26 }, end: { line: 5, character: 36 } }, + target: `${uri}#3,16` + } + ]); + }); + + test("should resolve a legacy $id anchor reference (\"$id\": \"#name\")", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { "$id": "#fooAnchor", "type": "string" } + }, + "properties": { + "bar": { "$ref": "#fooAnchor" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 5, character: 26 }, end: { line: 5, character: 36 } }, + target: `${uri}#3,16` + } + ]); + }); + + test("should resolve a $ref to an embedded schema by $id (no fragment)", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { "$id": "sub-schema", "type": "string" } + }, + "properties": { + "bar": { "$ref": "sub-schema" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 5, character: 26 }, end: { line: 5, character: 36 } }, + target: `${uri}#3,16` + } + ]); + }); + + test("should resolve a $ref to an embedded schema by $id with a JSON Pointer fragment", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { + "$id": "sub-schema", + "properties": { + "x": { "type": "number" } + } + } + }, + "properties": { + "bar": { "$ref": "sub-schema#/properties/x" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 10, character: 26 }, end: { line: 10, character: 50 } }, + target: `${uri}#6,18` + } + ]); + }); + + test("should resolve a $ref to an embedded schema by $id with an anchor fragment", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { + "$id": "sub-schema", + "properties": { + "x": { "$anchor": "xAnchor", "type": "number" } + } + } + }, + "properties": { + "bar": { "$ref": "sub-schema#xAnchor" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 10, character: 26 }, end: { line: 10, character: 44 } }, + target: `${uri}#6,18` + } + ]); + }); + + test("should not emit a link for an unresolvable $ref", async () => { + await client.writeDocument("schema.json", `{ + "properties": { + "bar": { "$ref": "#/definitions/missing" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([]); + }); + + test("should return multiple links for multiple $refs", async () => { + await client.writeDocument("schema.json", `{ + "definitions": { + "foo": { "type": "string" }, + "bar": { "type": "number" } + }, + "properties": { + "a": { "$ref": "#/definitions/foo" }, + "b": { "$ref": "#/definitions/bar" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 6, character: 24 }, end: { line: 6, character: 41 } }, + target: `${uri}#3,16` + }, + { + range: { start: { line: 7, character: 24 }, end: { line: 7, character: 41 } }, + target: `${uri}#4,16` + } + ]); + }); + + test("should return an empty array when the document has no $ref", async () => { + await client.writeDocument("schema.json", `{"type": "string"}`); + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([]); + }); + + test("should resolve a $ref to a JSON Pointer fragment in a different, unopened file", async () => { + const otherUri = await client.writeDocument("other.json", `{ + "properties": { + "x": { "type": "number" } + } + }`); + + await client.writeDocument("schema.json", `{ + "properties": { + "bar": { "$ref": "other.json#/properties/x" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 2, character: 26 }, end: { line: 2, character: 50 } }, + target: `${otherUri}#3,14` + } + ]); + }); + + test("should resolve a $ref to a different file using its live unsaved content when that file is open", async () => { + const otherUri = await client.writeDocument("other.json", `{ + "properties": { + "x": { "type": "number" } + } + }`); + + await client.openDocument("other.json"); + + const otherDiagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === otherUri) { + resolve(); + } + }); + }); + + await client.changeDocument("other.json", `{ + "properties": { + "x": { "type": "number" }, + "y": { "type": "string" } + } + }`); + + await otherDiagnostics; + + await client.writeDocument("schema.json", `{ + "properties": { + "bar": { "$ref": "other.json#/properties/y" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 2, character: 26 }, end: { line: 2, character: 50 } }, + target: `${otherUri}#4,14` + } + ]); + }); + + test("should follow a $ref chain across multiple files to the final non-$ref node", async () => { + const cUri = await client.writeDocument("c.json", `{ + "definitions": { + "leaf": { "type": "string" } + } + }`); + + await client.writeDocument("b.json", `{ + "definitions": { + "middle": { "$ref": "c.json#/definitions/leaf" } + } + }`); + + await client.writeDocument("a.json", `{ + "properties": { + "bar": { "$ref": "b.json#/definitions/middle" } + } + }`); + + const uri = await client.openDocument("a.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + range: { start: { line: 2, character: 26 }, end: { line: 2, character: 52 } }, + target: `${cUri}#3,17` + } + ]); + }); + + test("should not hang and should emit no link for a circular $ref chain", async () => { + await client.writeDocument("b.json", `{ + "definitions": { + "loop": { "$ref": "schema.json#/definitions/loop" } + } + }`); + + await client.writeDocument("schema.json", `{ + "definitions": { + "loop": { "$ref": "b.json#/definitions/loop" } + } + }`); + + const uri = await client.openDocument("schema.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([]); + }); +}); diff --git a/language-server/src/features/DocumentLinks.ts b/language-server/src/features/DocumentLinks.ts new file mode 100644 index 0000000..ebbbba9 --- /dev/null +++ b/language-server/src/features/DocumentLinks.ts @@ -0,0 +1,292 @@ +import * as jsonc from "jsonc-parser"; +import * as JsonPointer from "@hyperjump/json-pointer"; +import { resolveIri } from "@hyperjump/uri"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +import type { DocumentLink, Position, ServerCapabilities } from "vscode-languageserver"; +import type { Server } from "../services/Server.ts"; +import type { JsonDocuments } from "../services/JsonDocuments.ts"; +import type { JsonDocument } from "../models/JsonDocument.ts"; +import type { Workspace } from "../services/Workspace.ts"; + +type ResolvedTarget = { + uri: string; + node: jsonc.Node; + positionAt: (offset: number) => Position; +}; + +type NavigableDocument = { + uri: string; + root: jsonc.Node; + positionAt: (offset: number) => Position; + pointerFrom: (pointer: string, from?: jsonc.Node) => jsonc.Node | undefined; +}; + +export class DocumentLinks { + private jsonDocuments: JsonDocuments; + private workspace: Workspace; + + constructor(server: Server, jsonDocuments: JsonDocuments, workspace: Workspace) { + this.jsonDocuments = jsonDocuments; + this.workspace = workspace; + + server.onInitialize(() => { + const serverCapabilities: ServerCapabilities = { + documentLinkProvider: {} + }; + + return { + capabilities: serverCapabilities + }; + }); + + server.onDocumentLinks(async (params) => { + const jsonDocument = this.jsonDocuments.get(params.textDocument.uri)!; + + const root = jsonDocument.findNodeAtPointer(""); + if (!root) { + return []; + } + + const refValueNodes: jsonc.Node[] = []; + walkProperties(root, (propertyNode) => { + const [keyNode, valueNode] = propertyNode.children ?? []; + if (keyNode?.value === "$ref" && valueNode?.type === "string") { + refValueNodes.push(valueNode); + } + }); + + const links: DocumentLink[] = []; + for (const valueNode of refValueNodes) { + let target: ResolvedTarget | undefined; + try { + target = await findRefTarget(this.jsonDocuments, this.workspace, toNavigableDocument(jsonDocument, root), valueNode.value as string); + } catch { + // Unresolvable $ref skip + } + + if (!target) { + continue; + } + + const targetPosition = target.positionAt(target.node.offset); + links.push({ + target: `${target.uri}#${targetPosition.line + 1},${targetPosition.character + 1}`, + range: { + start: jsonDocument.positionAt(valueNode.offset + 1), + end: jsonDocument.positionAt(valueNode.offset + valueNode.length - 1) + } + }); + } + + return links; + }); + } +} + +const walkProperties = (node: jsonc.Node | undefined, fn: (propertyNode: jsonc.Node) => void): void => { + if (!node) { + return; + } + + if (node.type === "object") { + for (const propertyNode of node.children ?? []) { + fn(propertyNode); + walkProperties(propertyNode.children?.[1], fn); + } + } else if (node.type === "array") { + for (const itemNode of node.children ?? []) { + walkProperties(itemNode, fn); + } + } +}; + +const findRefTarget = async (jsonDocuments: JsonDocuments, workspace: Workspace, doc: NavigableDocument, ref: string): Promise => { + const visited = new Set(); + + while (true) { + const resolved = await resolveOneHop(jsonDocuments, workspace, doc, ref); + if (!resolved) { + return undefined; + } + + const location = `${resolved.doc.uri}#${resolved.node.offset}`; + if (visited.has(location)) { + return undefined; + } + visited.add(location); + + const nextRef = getRefValue(resolved.node); + if (nextRef === undefined) { + return { uri: resolved.doc.uri, node: resolved.node, positionAt: resolved.doc.positionAt }; + } + + doc = resolved.doc; + ref = nextRef; + } +}; + +const getRefValue = (node: jsonc.Node): string | undefined => { + if (node.type !== "object") { + return undefined; + } + + for (const propertyNode of node.children ?? []) { + const [keyNode, valueNode] = propertyNode.children ?? []; + if (keyNode?.value === "$ref" && valueNode?.type === "string") { + return valueNode.value as string; + } + } + + return undefined; +}; + +const resolveOneHop = async (jsonDocuments: JsonDocuments, workspace: Workspace, doc: NavigableDocument, ref: string): Promise<{ doc: NavigableDocument; node: jsonc.Node } | undefined> => { + if (ref === "#" || ref.startsWith("#/")) { + const node = doc.pointerFrom(ref.slice(1)); + return node && { doc, node }; + } + + if (ref.startsWith("#")) { + const anchor = ref.slice(1); + if (anchor.length === 0) { + return undefined; + } + const node = findAnchorNode(doc.root, anchor); + return node && { doc, node }; + } + + const hashIndex = ref.indexOf("#"); + const uri = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref; + const fragment = hashIndex >= 0 ? ref.slice(hashIndex + 1) : undefined; + + if (uri.length === 0) { + return undefined; + } + + const embeddedNode = findEmbeddedSchemaNode(doc, uri); + if (embeddedNode) { + if (!fragment) { + return { doc, node: embeddedNode }; + } + + const node = fragment.startsWith("/") + ? doc.pointerFrom(fragment, embeddedNode) + : findAnchorNode(embeddedNode, fragment); + return node && { doc, node }; + } + + const targetUri = resolveAgainstDocument(uri, doc.uri); + const targetDoc = await getNavigableDocument(targetUri, jsonDocuments, workspace); + if (!targetDoc) { + return undefined; + } + + if (!fragment) { + return { doc: targetDoc, node: targetDoc.root }; + } + + const node = fragment.startsWith("/") + ? targetDoc.pointerFrom(fragment) + : findAnchorNode(targetDoc.root, fragment); + + return node && { doc: targetDoc, node }; +}; + +const getNavigableDocument = async (uri: string, jsonDocuments: JsonDocuments, workspace: Workspace): Promise => { + const openDocument = jsonDocuments.get(uri); + if (openDocument) { + const root = openDocument.findNodeAtPointer(""); + return root && toNavigableDocument(openDocument, root); + } + + const text = await workspace.readFile(uri); + const root = jsonc.parseTree(text); + if (!root) { + return undefined; + } + + const textDocument = TextDocument.create(uri, "json", 0, text); + return { + uri, + root, + positionAt: (offset) => textDocument.positionAt(offset), + pointerFrom: (pointer, from) => pointerLookup(from ?? root, pointer) + }; +}; + +const toNavigableDocument = (jsonDocument: JsonDocument, root: jsonc.Node): NavigableDocument => ({ + uri: jsonDocument.uri, + root, + positionAt: (offset) => jsonDocument.positionAt(offset), + pointerFrom: (pointer, from) => jsonDocument.findNodeAtPointer(pointer, from) +}); + +const pointerLookup = (root: jsonc.Node, pointer: string): jsonc.Node | undefined => { + let node: jsonc.Node | undefined = root; + + for (const segment of JsonPointer.pointerSegments(pointer)) { + if (!node) { + return undefined; + } + + const key = node.type === "array" ? parseInt(segment) : segment; + node = jsonc.findNodeAtLocation(node, [key]); + } + + return node; +}; + +const findAnchorNode = (subtreeRoot: jsonc.Node, anchor: string): jsonc.Node | undefined => { + let result: jsonc.Node | undefined; + + walkProperties(subtreeRoot, (propertyNode) => { + if (result) { + return; + } + + const [keyNode, valueNode] = propertyNode.children ?? []; + if (valueNode?.type !== "string") { + return; + } + + if (keyNode.value === "$anchor" && valueNode.value === anchor) { + result = propertyNode.parent; + } else if (keyNode.value === "$id" && valueNode.value === `#${anchor}`) { + result = propertyNode.parent; + } + }); + + return result; +}; + +const findEmbeddedSchemaNode = (doc: NavigableDocument, uri: string): jsonc.Node | undefined => { + const absoluteUri = resolveAgainstDocument(uri, doc.uri); + + let result: jsonc.Node | undefined; + + walkProperties(doc.root, (propertyNode) => { + if (result || propertyNode.parent === doc.root) { + return; + } + + const [keyNode, valueNode] = propertyNode.children ?? []; + if (valueNode?.type !== "string" || (keyNode.value !== "$id" && keyNode.value !== "id")) { + return; + } + + if (resolveAgainstDocument(valueNode.value as string, doc.uri) === absoluteUri) { + result = propertyNode.parent; + } + }); + + return result; +}; + +const resolveAgainstDocument = (uri: string, baseUri: string): string => { + try { + return resolveIri(uri, baseUri); + } catch { + return uri; + } +}; diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index f6d4bf0..a0e0803 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -125,8 +125,8 @@ export class JsonDocument implements TextDocument { return this.schemaErrors; } - findNodeAtPointer(pointer: string) { - let node = this.ast; + findNodeAtPointer(pointer: string, from: jsonc.Node | undefined = this.ast) { + let node = from; for (let segment of JsonPointer.pointerSegments(pointer)) { if (!node) { From fb3e0f4c99700922b10816fd1b39de6883fbed0a Mon Sep 17 00:00:00 2001 From: Diya Date: Sun, 16 Aug 2026 02:02:40 +0530 Subject: [PATCH 2/3] restrict document links to workspace $ schema --- .../src/features/DocumentLinks.test.ts | 339 ++---------------- language-server/src/features/DocumentLinks.ts | 284 ++------------- 2 files changed, 56 insertions(+), 567 deletions(-) diff --git a/language-server/src/features/DocumentLinks.test.ts b/language-server/src/features/DocumentLinks.test.ts index 266a519..4fb8be3 100644 --- a/language-server/src/features/DocumentLinks.test.ts +++ b/language-server/src/features/DocumentLinks.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { DocumentLinkRequest, PublishDiagnosticsNotification } from "vscode-languageserver"; +import { DocumentLinkRequest } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; describe("DocumentLinks", () => { @@ -14,189 +14,38 @@ describe("DocumentLinks", () => { await client.stop(); }); - test("should resolve a plain JSON Pointer $ref", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { "type": "string" } - }, - "properties": { - "bar": { "$ref": "#/definitions/foo" } - } - }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 5, character: 26 }, end: { line: 5, character: 43 } }, - target: `${uri}#3,16` - } - ]); - }); - - test("should resolve a $ref to the whole document", async () => { - await client.writeDocument("schema.json", `{ - "properties": { - "self": { "$ref": "#" } - } - }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 2, character: 27 }, end: { line: 2, character: 28 } }, - target: `${uri}#1,1` - } - ]); - }); - - test("should resolve a $anchor reference", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { "$anchor": "fooAnchor", "type": "string" } - }, - "properties": { - "bar": { "$ref": "#fooAnchor" } - } - }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 5, character: 26 }, end: { line: 5, character: 36 } }, - target: `${uri}#3,16` - } - ]); - }); - - test("should resolve a legacy $id anchor reference (\"$id\": \"#name\")", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { "$id": "#fooAnchor", "type": "string" } - }, - "properties": { - "bar": { "$ref": "#fooAnchor" } - } + test("should return a link for a $schema that resolves to a schema file in the workspace", async () => { + const schemaUri = await client.writeDocument("schema.json", `{ + "type": "object" }`); - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 5, character: 26 }, end: { line: 5, character: 36 } }, - target: `${uri}#3,16` - } - ]); - }); - - test("should resolve a $ref to an embedded schema by $id (no fragment)", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { "$id": "sub-schema", "type": "string" } - }, - "properties": { - "bar": { "$ref": "sub-schema" } - } + const instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "./schema.json" }`); - - const uri = await client.openDocument("schema.json"); + const uri = await client.openDocument("instance.json"); const result = await client.sendRequest(DocumentLinkRequest.type, { textDocument: { uri } }); + expect(uri).toBe(instanceUri); expect(result).toEqual([ { - range: { start: { line: 5, character: 26 }, end: { line: 5, character: 36 } }, - target: `${uri}#3,16` - } - ]); - }); - - test("should resolve a $ref to an embedded schema by $id with a JSON Pointer fragment", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { - "$id": "sub-schema", - "properties": { - "x": { "type": "number" } - } + target: schemaUri, + tooltip: "Click to open schema file", + range: { + start: { line: 1, character: 18 }, + end: { line: 1, character: 31 } } - }, - "properties": { - "bar": { "$ref": "sub-schema#/properties/x" } - } - }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 10, character: 26 }, end: { line: 10, character: 50 } }, - target: `${uri}#6,18` } ]); }); - test("should resolve a $ref to an embedded schema by $id with an anchor fragment", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { - "$id": "sub-schema", - "properties": { - "x": { "$anchor": "xAnchor", "type": "number" } - } - } - }, - "properties": { - "bar": { "$ref": "sub-schema#xAnchor" } - } + test("should not return a link for a $schema resolved from SchemaStore.org", async () => { + await client.writeDocument("instance.json", `{ + "$schema": "https://json.schemastore.org/package.json" }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 10, character: 26 }, end: { line: 10, character: 44 } }, - target: `${uri}#6,18` - } - ]); - }); - - test("should not emit a link for an unresolvable $ref", async () => { - await client.writeDocument("schema.json", `{ - "properties": { - "bar": { "$ref": "#/definitions/missing" } - } - }`); - - const uri = await client.openDocument("schema.json"); + const uri = await client.openDocument("instance.json"); const result = await client.sendRequest(DocumentLinkRequest.type, { textDocument: { uri } @@ -205,39 +54,11 @@ describe("DocumentLinks", () => { expect(result).toEqual([]); }); - test("should return multiple links for multiple $refs", async () => { - await client.writeDocument("schema.json", `{ - "definitions": { - "foo": { "type": "string" }, - "bar": { "type": "number" } - }, - "properties": { - "a": { "$ref": "#/definitions/foo" }, - "b": { "$ref": "#/definitions/bar" } - } + test("should not return a link when $schema resolves outside the workspace", async () => { + await client.writeDocument("instance.json", `{ + "$schema": "../../schema.json" }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 6, character: 24 }, end: { line: 6, character: 41 } }, - target: `${uri}#3,16` - }, - { - range: { start: { line: 7, character: 24 }, end: { line: 7, character: 41 } }, - target: `${uri}#4,16` - } - ]); - }); - - test("should return an empty array when the document has no $ref", async () => { - await client.writeDocument("schema.json", `{"type": "string"}`); - const uri = await client.openDocument("schema.json"); + const uri = await client.openDocument("instance.json"); const result = await client.sendRequest(DocumentLinkRequest.type, { textDocument: { uri } @@ -246,126 +67,24 @@ describe("DocumentLinks", () => { expect(result).toEqual([]); }); - test("should resolve a $ref to a JSON Pointer fragment in a different, unopened file", async () => { - const otherUri = await client.writeDocument("other.json", `{ - "properties": { - "x": { "type": "number" } - } - }`); - - await client.writeDocument("schema.json", `{ - "properties": { - "bar": { "$ref": "other.json#/properties/x" } - } - }`); - - const uri = await client.openDocument("schema.json"); + test("should return an empty array when the document has no $schema", async () => { + await client.writeDocument("instance.json", `{"type": "string"}`); + const uri = await client.openDocument("instance.json"); const result = await client.sendRequest(DocumentLinkRequest.type, { textDocument: { uri } }); - expect(result).toEqual([ - { - range: { start: { line: 2, character: 26 }, end: { line: 2, character: 50 } }, - target: `${otherUri}#3,14` - } - ]); - }); - - test("should resolve a $ref to a different file using its live unsaved content when that file is open", async () => { - const otherUri = await client.writeDocument("other.json", `{ - "properties": { - "x": { "type": "number" } - } - }`); - - await client.openDocument("other.json"); - - const otherDiagnostics: Promise = new Promise((resolve) => { - client.onNotification(PublishDiagnosticsNotification.type, (params) => { - if (params.uri === otherUri) { - resolve(); - } - }); - }); - - await client.changeDocument("other.json", `{ - "properties": { - "x": { "type": "number" }, - "y": { "type": "string" } - } - }`); - - await otherDiagnostics; - - await client.writeDocument("schema.json", `{ - "properties": { - "bar": { "$ref": "other.json#/properties/y" } - } - }`); - - const uri = await client.openDocument("schema.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 2, character: 26 }, end: { line: 2, character: 50 } }, - target: `${otherUri}#4,14` - } - ]); + expect(result).toEqual([]); }); - test("should follow a $ref chain across multiple files to the final non-$ref node", async () => { - const cUri = await client.writeDocument("c.json", `{ - "definitions": { - "leaf": { "type": "string" } - } - }`); - - await client.writeDocument("b.json", `{ - "definitions": { - "middle": { "$ref": "c.json#/definitions/leaf" } - } - }`); - - await client.writeDocument("a.json", `{ + test("should not treat a nested $schema property as the document's dialect schema", async () => { + await client.writeDocument("instance.json", `{ "properties": { - "bar": { "$ref": "b.json#/definitions/middle" } + "$schema": { "type": "string" } } }`); - - const uri = await client.openDocument("a.json"); - - const result = await client.sendRequest(DocumentLinkRequest.type, { - textDocument: { uri } - }); - - expect(result).toEqual([ - { - range: { start: { line: 2, character: 26 }, end: { line: 2, character: 52 } }, - target: `${cUri}#3,17` - } - ]); - }); - - test("should not hang and should emit no link for a circular $ref chain", async () => { - await client.writeDocument("b.json", `{ - "definitions": { - "loop": { "$ref": "schema.json#/definitions/loop" } - } - }`); - - await client.writeDocument("schema.json", `{ - "definitions": { - "loop": { "$ref": "b.json#/definitions/loop" } - } - }`); - - const uri = await client.openDocument("schema.json"); + const uri = await client.openDocument("instance.json"); const result = await client.sendRequest(DocumentLinkRequest.type, { textDocument: { uri } diff --git a/language-server/src/features/DocumentLinks.ts b/language-server/src/features/DocumentLinks.ts index ebbbba9..baf6e7d 100644 --- a/language-server/src/features/DocumentLinks.ts +++ b/language-server/src/features/DocumentLinks.ts @@ -1,27 +1,10 @@ -import * as jsonc from "jsonc-parser"; -import * as JsonPointer from "@hyperjump/json-pointer"; -import { resolveIri } from "@hyperjump/uri"; -import { TextDocument } from "vscode-languageserver-textdocument"; +import { normalizeIri, resolveIri } from "@hyperjump/uri"; -import type { DocumentLink, Position, ServerCapabilities } from "vscode-languageserver"; +import type { DocumentLink, ServerCapabilities } from "vscode-languageserver"; import type { Server } from "../services/Server.ts"; import type { JsonDocuments } from "../services/JsonDocuments.ts"; -import type { JsonDocument } from "../models/JsonDocument.ts"; import type { Workspace } from "../services/Workspace.ts"; -type ResolvedTarget = { - uri: string; - node: jsonc.Node; - positionAt: (offset: number) => Position; -}; - -type NavigableDocument = { - uri: string; - root: jsonc.Node; - positionAt: (offset: number) => Position; - pointerFrom: (pointer: string, from?: jsonc.Node) => jsonc.Node | undefined; -}; - export class DocumentLinks { private jsonDocuments: JsonDocuments; private workspace: Workspace; @@ -40,253 +23,40 @@ export class DocumentLinks { }; }); - server.onDocumentLinks(async (params) => { + server.onDocumentLinks((params) => { const jsonDocument = this.jsonDocuments.get(params.textDocument.uri)!; - const root = jsonDocument.findNodeAtPointer(""); - if (!root) { + const schemaNode = jsonDocument.findNodeAtPointer("/$schema"); + if (schemaNode?.type !== "string") { return []; } - const refValueNodes: jsonc.Node[] = []; - walkProperties(root, (propertyNode) => { - const [keyNode, valueNode] = propertyNode.children ?? []; - if (keyNode?.value === "$ref" && valueNode?.type === "string") { - refValueNodes.push(valueNode); - } - }); + let schemaUri: string; + try { + schemaUri = resolveIri(schemaNode.value as string, jsonDocument.uri); + } catch { + return []; + } - const links: DocumentLink[] = []; - for (const valueNode of refValueNodes) { - let target: ResolvedTarget | undefined; - try { - target = await findRefTarget(this.jsonDocuments, this.workspace, toNavigableDocument(jsonDocument, root), valueNode.value as string); - } catch { - // Unresolvable $ref skip - } + const isWorkspaceSchema = [...this.workspace.workspaceFolders].some((workspaceFolderUri) => { + const normalized = normalizeIri(workspaceFolderUri); + const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`; + return schemaUri.startsWith(prefix); + }); + if (!isWorkspaceSchema) { + return []; + } - if (!target) { - continue; + const link: DocumentLink = { + target: schemaUri, + tooltip: "Click to open schema file", + range: { + start: jsonDocument.positionAt(schemaNode.offset + 1), + end: jsonDocument.positionAt(schemaNode.offset + schemaNode.length - 1) } + }; - const targetPosition = target.positionAt(target.node.offset); - links.push({ - target: `${target.uri}#${targetPosition.line + 1},${targetPosition.character + 1}`, - range: { - start: jsonDocument.positionAt(valueNode.offset + 1), - end: jsonDocument.positionAt(valueNode.offset + valueNode.length - 1) - } - }); - } - - return links; + return [link]; }); } } - -const walkProperties = (node: jsonc.Node | undefined, fn: (propertyNode: jsonc.Node) => void): void => { - if (!node) { - return; - } - - if (node.type === "object") { - for (const propertyNode of node.children ?? []) { - fn(propertyNode); - walkProperties(propertyNode.children?.[1], fn); - } - } else if (node.type === "array") { - for (const itemNode of node.children ?? []) { - walkProperties(itemNode, fn); - } - } -}; - -const findRefTarget = async (jsonDocuments: JsonDocuments, workspace: Workspace, doc: NavigableDocument, ref: string): Promise => { - const visited = new Set(); - - while (true) { - const resolved = await resolveOneHop(jsonDocuments, workspace, doc, ref); - if (!resolved) { - return undefined; - } - - const location = `${resolved.doc.uri}#${resolved.node.offset}`; - if (visited.has(location)) { - return undefined; - } - visited.add(location); - - const nextRef = getRefValue(resolved.node); - if (nextRef === undefined) { - return { uri: resolved.doc.uri, node: resolved.node, positionAt: resolved.doc.positionAt }; - } - - doc = resolved.doc; - ref = nextRef; - } -}; - -const getRefValue = (node: jsonc.Node): string | undefined => { - if (node.type !== "object") { - return undefined; - } - - for (const propertyNode of node.children ?? []) { - const [keyNode, valueNode] = propertyNode.children ?? []; - if (keyNode?.value === "$ref" && valueNode?.type === "string") { - return valueNode.value as string; - } - } - - return undefined; -}; - -const resolveOneHop = async (jsonDocuments: JsonDocuments, workspace: Workspace, doc: NavigableDocument, ref: string): Promise<{ doc: NavigableDocument; node: jsonc.Node } | undefined> => { - if (ref === "#" || ref.startsWith("#/")) { - const node = doc.pointerFrom(ref.slice(1)); - return node && { doc, node }; - } - - if (ref.startsWith("#")) { - const anchor = ref.slice(1); - if (anchor.length === 0) { - return undefined; - } - const node = findAnchorNode(doc.root, anchor); - return node && { doc, node }; - } - - const hashIndex = ref.indexOf("#"); - const uri = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref; - const fragment = hashIndex >= 0 ? ref.slice(hashIndex + 1) : undefined; - - if (uri.length === 0) { - return undefined; - } - - const embeddedNode = findEmbeddedSchemaNode(doc, uri); - if (embeddedNode) { - if (!fragment) { - return { doc, node: embeddedNode }; - } - - const node = fragment.startsWith("/") - ? doc.pointerFrom(fragment, embeddedNode) - : findAnchorNode(embeddedNode, fragment); - return node && { doc, node }; - } - - const targetUri = resolveAgainstDocument(uri, doc.uri); - const targetDoc = await getNavigableDocument(targetUri, jsonDocuments, workspace); - if (!targetDoc) { - return undefined; - } - - if (!fragment) { - return { doc: targetDoc, node: targetDoc.root }; - } - - const node = fragment.startsWith("/") - ? targetDoc.pointerFrom(fragment) - : findAnchorNode(targetDoc.root, fragment); - - return node && { doc: targetDoc, node }; -}; - -const getNavigableDocument = async (uri: string, jsonDocuments: JsonDocuments, workspace: Workspace): Promise => { - const openDocument = jsonDocuments.get(uri); - if (openDocument) { - const root = openDocument.findNodeAtPointer(""); - return root && toNavigableDocument(openDocument, root); - } - - const text = await workspace.readFile(uri); - const root = jsonc.parseTree(text); - if (!root) { - return undefined; - } - - const textDocument = TextDocument.create(uri, "json", 0, text); - return { - uri, - root, - positionAt: (offset) => textDocument.positionAt(offset), - pointerFrom: (pointer, from) => pointerLookup(from ?? root, pointer) - }; -}; - -const toNavigableDocument = (jsonDocument: JsonDocument, root: jsonc.Node): NavigableDocument => ({ - uri: jsonDocument.uri, - root, - positionAt: (offset) => jsonDocument.positionAt(offset), - pointerFrom: (pointer, from) => jsonDocument.findNodeAtPointer(pointer, from) -}); - -const pointerLookup = (root: jsonc.Node, pointer: string): jsonc.Node | undefined => { - let node: jsonc.Node | undefined = root; - - for (const segment of JsonPointer.pointerSegments(pointer)) { - if (!node) { - return undefined; - } - - const key = node.type === "array" ? parseInt(segment) : segment; - node = jsonc.findNodeAtLocation(node, [key]); - } - - return node; -}; - -const findAnchorNode = (subtreeRoot: jsonc.Node, anchor: string): jsonc.Node | undefined => { - let result: jsonc.Node | undefined; - - walkProperties(subtreeRoot, (propertyNode) => { - if (result) { - return; - } - - const [keyNode, valueNode] = propertyNode.children ?? []; - if (valueNode?.type !== "string") { - return; - } - - if (keyNode.value === "$anchor" && valueNode.value === anchor) { - result = propertyNode.parent; - } else if (keyNode.value === "$id" && valueNode.value === `#${anchor}`) { - result = propertyNode.parent; - } - }); - - return result; -}; - -const findEmbeddedSchemaNode = (doc: NavigableDocument, uri: string): jsonc.Node | undefined => { - const absoluteUri = resolveAgainstDocument(uri, doc.uri); - - let result: jsonc.Node | undefined; - - walkProperties(doc.root, (propertyNode) => { - if (result || propertyNode.parent === doc.root) { - return; - } - - const [keyNode, valueNode] = propertyNode.children ?? []; - if (valueNode?.type !== "string" || (keyNode.value !== "$id" && keyNode.value !== "id")) { - return; - } - - if (resolveAgainstDocument(valueNode.value as string, doc.uri) === absoluteUri) { - result = propertyNode.parent; - } - }); - - return result; -}; - -const resolveAgainstDocument = (uri: string, baseUri: string): string => { - try { - return resolveIri(uri, baseUri); - } catch { - return uri; - } -}; From 133f95ed9ee850ee9a51e6751a8d07ee078e8dc7 Mon Sep 17 00:00:00 2001 From: Diya Date: Sun, 16 Aug 2026 02:08:11 +0530 Subject: [PATCH 3/3] revert jsonDocument.ts to drop unused param in findNodeAtPointer --- language-server/src/models/JsonDocument.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index a0e0803..f6d4bf0 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -125,8 +125,8 @@ export class JsonDocument implements TextDocument { return this.schemaErrors; } - findNodeAtPointer(pointer: string, from: jsonc.Node | undefined = this.ast) { - let node = from; + findNodeAtPointer(pointer: string) { + let node = this.ast; for (let segment of JsonPointer.pointerSegments(pointer)) { if (!node) {