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..4fb8be3 --- /dev/null +++ b/language-server/src/features/DocumentLinks.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { DocumentLinkRequest } 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 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 instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "./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([ + { + target: schemaUri, + tooltip: "Click to open schema file", + range: { + start: { line: 1, character: 18 }, + end: { line: 1, character: 31 } + } + } + ]); + }); + + 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("instance.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([]); + }); + + 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("instance.json"); + + const result = await client.sendRequest(DocumentLinkRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([]); + }); + + 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([]); + }); + + test("should not treat a nested $schema property as the document's dialect schema", async () => { + await client.writeDocument("instance.json", `{ + "properties": { + "$schema": { "type": "string" } + } + }`); + const uri = await client.openDocument("instance.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..baf6e7d --- /dev/null +++ b/language-server/src/features/DocumentLinks.ts @@ -0,0 +1,62 @@ +import { normalizeIri, resolveIri } from "@hyperjump/uri"; + +import type { DocumentLink, ServerCapabilities } from "vscode-languageserver"; +import type { Server } from "../services/Server.ts"; +import type { JsonDocuments } from "../services/JsonDocuments.ts"; +import type { Workspace } from "../services/Workspace.ts"; + +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((params) => { + const jsonDocument = this.jsonDocuments.get(params.textDocument.uri)!; + + const schemaNode = jsonDocument.findNodeAtPointer("/$schema"); + if (schemaNode?.type !== "string") { + return []; + } + + let schemaUri: string; + try { + schemaUri = resolveIri(schemaNode.value as string, jsonDocument.uri); + } catch { + return []; + } + + const isWorkspaceSchema = [...this.workspace.workspaceFolders].some((workspaceFolderUri) => { + const normalized = normalizeIri(workspaceFolderUri); + const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`; + return schemaUri.startsWith(prefix); + }); + if (!isWorkspaceSchema) { + return []; + } + + 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) + } + }; + + return [link]; + }); + } +}