diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index b68dc1c..8af24ca 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -9,6 +9,7 @@ import { Formatting } from "./features/Formatting.ts"; import { Hover } from "./features/Hover.ts"; import { Completion } from "./features/Completion.ts"; import { FoldingRanges } from "./features/FoldingRanges.ts"; +import { DocumentSymbols } from "./features/DocumentSymbols.ts"; import "@hyperjump/json-schema/draft-2020-12"; import "@hyperjump/json-schema/draft-2019-09"; @@ -39,6 +40,7 @@ export const buildServer = (connection: Connection): Server => { new Hover(server, documents); new Completion(server, documents); new FoldingRanges(server, documents); + new DocumentSymbols(server, documents); return server; }; diff --git a/language-server/src/features/DocumentSymbols.test.ts b/language-server/src/features/DocumentSymbols.test.ts new file mode 100644 index 0000000..148c80c --- /dev/null +++ b/language-server/src/features/DocumentSymbols.test.ts @@ -0,0 +1,273 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { TestClient } from "../test/TestClient.ts"; +import { DocumentSymbolRequest, SymbolKind } from "vscode-languageserver"; + +describe("DocumentSymbols", () => { + let client: TestClient; + + beforeEach(async () => { + client = new TestClient(); + await client.start(); + }); + + afterEach(async () => { + await client.stop(); + }); + + test("should return document symbols for flat JSON object", async () => { + await client.writeDocument("test.json", `{ + "name": "Alice", + "age": 30, + "active": true, + "address": null + }`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(DocumentSymbolRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + name: "name", + kind: SymbolKind.Property, + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 12 } + }, + selectionRange: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 12 } + } + }, + { + name: "Alice", + kind: SymbolKind.String, + range: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 21 } + }, + selectionRange: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 21 } + } + }, + { + name: "age", + kind: SymbolKind.Property, + range: { + start: { line: 2, character: 6 }, + end: { line: 2, character: 11 } + }, + selectionRange: { + start: { line: 2, character: 6 }, + end: { line: 2, character: 11 } + } + }, + { + name: "30", + kind: SymbolKind.Number, + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 15 } + }, + selectionRange: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 15 } + } + }, + { + name: "active", + kind: SymbolKind.Property, + range: { + start: { line: 3, character: 6 }, + end: { line: 3, character: 14 } + }, + selectionRange: { + start: { line: 3, character: 6 }, + end: { line: 3, character: 14 } + } + }, + { + name: "true", + kind: SymbolKind.Boolean, + range: { + start: { line: 3, character: 16 }, + end: { line: 3, character: 20 } + }, + selectionRange: { + start: { line: 3, character: 16 }, + end: { line: 3, character: 20 } + } + }, + { + name: "address", + kind: SymbolKind.Property, + range: { + start: { line: 4, character: 6 }, + end: { line: 4, character: 15 } + }, + selectionRange: { + start: { line: 4, character: 6 }, + end: { line: 4, character: 15 } + } + }, + { + name: "null", + kind: SymbolKind.Null, + range: { + start: { line: 4, character: 17 }, + end: { line: 4, character: 21 } + }, + selectionRange: { + start: { line: 4, character: 17 }, + end: { line: 4, character: 21 } + } + } + ]); + }); + + test("should return document symbols for nested JSON objects", async () => { + await client.writeDocument("test.json", `{ + "server": { + "port": 8080 + } + }`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(DocumentSymbolRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + name: "server", + kind: SymbolKind.Property, + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 14 } + }, + selectionRange: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 14 } + } + }, + { + name: "server", + kind: SymbolKind.Object, + range: { + start: { line: 1, character: 16 }, + end: { line: 3, character: 7 } + }, + selectionRange: { + start: { line: 1, character: 16 }, + end: { line: 3, character: 7 } + }, + children: [ + { + name: "port", + kind: SymbolKind.Property, + range: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 14 } + }, + selectionRange: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 14 } + } + }, + { + name: "8080", + kind: SymbolKind.Number, + range: { + start: { line: 2, character: 16 }, + end: { line: 2, character: 20 } + }, + selectionRange: { + start: { line: 2, character: 16 }, + end: { line: 2, character: 20 } + } + } + ] + } + ]); + }); + + test("should return document symbols for JSON arrays", async () => { + await client.writeDocument("test.json", `{ + "plugins": [ + "auth", + "logger" + ] + }`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(DocumentSymbolRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([ + { + name: "plugins", + kind: SymbolKind.Property, + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 15 } + }, + selectionRange: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 15 } + } + }, + { + name: "plugins", + kind: SymbolKind.Array, + range: { + start: { line: 1, character: 17 }, + end: { line: 4, character: 7 } + }, + selectionRange: { + start: { line: 1, character: 17 }, + end: { line: 4, character: 7 } + }, + children: [ + { + name: "0", + kind: SymbolKind.String, + range: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 14 } + }, + selectionRange: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 14 } + } + }, + { + name: "1", + kind: SymbolKind.String, + range: { + start: { line: 3, character: 8 }, + end: { line: 3, character: 16 } + }, + selectionRange: { + start: { line: 3, character: 8 }, + end: { line: 3, character: 16 } + } + } + ] + } + ]); + }); + + test("should return empty array for empty JSON object or empty array root", async () => { + await client.writeDocument("test.json", "{}\n"); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(DocumentSymbolRequest.type, { + textDocument: { uri } + }); + + expect(result).toEqual([]); + }); +}); diff --git a/language-server/src/features/DocumentSymbols.ts b/language-server/src/features/DocumentSymbols.ts new file mode 100644 index 0000000..221f754 --- /dev/null +++ b/language-server/src/features/DocumentSymbols.ts @@ -0,0 +1,125 @@ +import { SymbolKind } from "vscode-languageserver"; + +import type { DocumentSymbol, ServerCapabilities } from "vscode-languageserver"; +import type { Node } from "jsonc-parser"; +import type { Server } from "../services/Server.ts"; +import type { JsonDocuments } from "../services/JsonDocuments.ts"; +import type { JsonDocument } from "../models/JsonDocument.ts"; + +export class DocumentSymbols { + private jsonDocuments: JsonDocuments; + + constructor(server: Server, jsonDocuments: JsonDocuments) { + this.jsonDocuments = jsonDocuments; + + server.onInitialize(() => { + const serverCapabilities: ServerCapabilities = { + documentSymbolProvider: true + }; + + return { + capabilities: serverCapabilities + }; + }); + + server.onDocumentSymbol((params) => { + const jsonDocument = this.jsonDocuments.get(params.textDocument.uri)!; + const ast = jsonDocument.findNodeAtPointer(""); + if (!ast) { + return []; + } + + return this.collectDocumentSymbols(jsonDocument, ast); + }); + } + + private collectDocumentSymbols(jsonDocument: JsonDocument, node: Node): DocumentSymbol[] { + const symbols: DocumentSymbol[] = []; + + if (node.type === "object") { + for (const propertyNode of node.children!) { + const keyNode = propertyNode.children![0]; + const valueNode = propertyNode.children![1]; + + const keyRange = { + start: jsonDocument.positionAt(keyNode.offset), + end: jsonDocument.positionAt(keyNode.offset + keyNode.length) + }; + const keySymbol: DocumentSymbol = { + name: String(keyNode.value), + kind: SymbolKind.Property, + range: keyRange, + selectionRange: keyRange + }; + symbols.push(keySymbol); + + if (valueNode) { + const valueRange = { + start: jsonDocument.positionAt(valueNode.offset), + end: jsonDocument.positionAt(valueNode.offset + valueNode.length) + }; + const valueName = valueNode.value !== undefined ? String(valueNode.value) : String(keyNode.value); + const kind = this.getSymbolKind(valueNode.type); + const children = this.collectDocumentSymbols(jsonDocument, valueNode); + + const valueSymbol: DocumentSymbol = { + name: valueName, + kind, + range: valueRange, + selectionRange: valueRange + }; + + if (children.length > 0) { + valueSymbol.children = children; + } + + symbols.push(valueSymbol); + } + } + } else if (node.type === "array") { + node.children!.forEach((child, index) => { + const name = String(index); + const range = { + start: jsonDocument.positionAt(child.offset), + end: jsonDocument.positionAt(child.offset + child.length) + }; + const selectionRange = range; + + const kind = this.getSymbolKind(child.type); + const children = this.collectDocumentSymbols(jsonDocument, child); + + const symbol: DocumentSymbol = { name, kind, range, selectionRange }; + + if (children.length > 0) { + symbol.children = children; + } + + symbols.push(symbol); + }); + } + + return symbols; + } + + private getSymbolKind(type?: string): SymbolKind { + switch (type) { + case "object": + return SymbolKind.Object; + case "array": + return SymbolKind.Array; + case "string": + return SymbolKind.String; + case "number": + return SymbolKind.Number; + case "boolean": + return SymbolKind.Boolean; + case "null": + return SymbolKind.Null; + case "property": + return SymbolKind.Property; + default: + // Unreachable code, but typescript needs something to know the function doesn't return undefined + throw Error("Unreachable"); + } + } +}