diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 8af24ca..1f09bc5 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -10,6 +10,7 @@ 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 { SelectionRanges } from "./features/SelectionRanges.ts"; import "@hyperjump/json-schema/draft-2020-12"; import "@hyperjump/json-schema/draft-2019-09"; @@ -41,6 +42,7 @@ export const buildServer = (connection: Connection): Server => { new Completion(server, documents); new FoldingRanges(server, documents); new DocumentSymbols(server, documents); + new SelectionRanges(server, documents); return server; }; diff --git a/language-server/src/features/Formatting.ts b/language-server/src/features/Formatting.ts index 8455cd9..4ed5c72 100644 --- a/language-server/src/features/Formatting.ts +++ b/language-server/src/features/Formatting.ts @@ -57,10 +57,7 @@ export class Formatting { }); return edits.map((edit) => ({ - range: { - start: jsonDocument.positionAt(edit.offset), - end: jsonDocument.positionAt(edit.offset + edit.length) - }, + range: jsonDocument.rangeAt(edit.offset, edit.offset + edit.length), newText: edit.content })); } catch (error: unknown) { diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts index 2299a6f..f484e6a 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -20,10 +20,7 @@ export class SchemaValidation implements DiagnosticsProvider { if (node) { schemaDiagnostics.push({ severity: DiagnosticSeverity.Error, - range: { - start: jsonDocument.positionAt(node.offset), - end: jsonDocument.positionAt(node.offset + node.length) - }, + range: jsonDocument.rangeAt(node.offset, node.offset + node.length), message: formatError(error), source: "hyperjump-json-language-server" }); @@ -35,10 +32,7 @@ export class SchemaValidation implements DiagnosticsProvider { if (schemaNode) { schemaDiagnostics.push({ severity: DiagnosticSeverity.Error, - range: { - start: jsonDocument.positionAt(schemaNode.offset), - end: jsonDocument.positionAt(schemaNode.offset + schemaNode.length) - }, + range: jsonDocument.rangeAt(schemaNode.offset, schemaNode.offset + schemaNode.length), message: error instanceof Error ? error.message : String(error), source: "hyperjump-json-language-server" }); diff --git a/language-server/src/features/SelectionRanges.test.ts b/language-server/src/features/SelectionRanges.test.ts new file mode 100644 index 0000000..8c215a9 --- /dev/null +++ b/language-server/src/features/SelectionRanges.test.ts @@ -0,0 +1,393 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { TestClient } from "../test/TestClient.ts"; +import { SelectionRangeRequest } from "vscode-languageserver"; + +describe("SelectionRanges", () => { + let client: TestClient; + + beforeEach(async () => { + client = new TestClient(); + await client.start(); + }); + + afterEach(async () => { + await client.stop(); + }); + + test("should return a nested chain of selection ranges for a string property value", async () => { + await client.writeDocument("test.json", `{ + "foo": "bar", + "baz": 123 + }`); + + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 1, character: 15 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 17 } + }, + parent: { + range: { + start: { line: 1, character: 13 }, + end: { line: 1, character: 18 } + }, + parent: { + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 18 } + }, + parent: { + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 19 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 3, character: 4 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 3, character: 5 } + } + } + } + } + } + } + } + ]); + }); + + test("should include the trailing comma when the selected element is followed by another array element", async () => { + await client.writeDocument("test.json", `[1, 2, 3]`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 0, character: 4 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 5 } + }, + parent: { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 6 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 8 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 9 } + } + } + } + } + } + ]); + }); + + test("should not include a trailing comma when the selected element is the last element in an array", async () => { + await client.writeDocument("test.json", `[1, 2, 3]`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 0, character: 7 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 0, character: 7 }, + end: { line: 0, character: 8 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 8 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 9 } + } + } + } + } + ]); + }); + + test("should handle multiple positions in a single request, one with a following property and one without", async () => { + await client.writeDocument("test.json", `{ + "foo": 1, + "bar": 2 + }`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [ + { line: 1, character: 13 }, + { line: 2, character: 13 } + ] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 1, character: 13 }, + end: { line: 1, character: 14 } + }, + parent: { + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 14 } + }, + parent: { + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 15 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 3, character: 4 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 3, character: 5 } + } + } + } + } + } + }, + { + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 14 } + }, + parent: { + range: { + start: { line: 2, character: 6 }, + end: { line: 2, character: 14 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 3, character: 4 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 3, character: 5 } + } + } + } + } + } + ]); + }); + + test("should thread the parent chain through mixed nested objects and arrays", async () => { + await client.writeDocument("test.json", `{ + "foo": { + "bar": [1, 2, {"baz": true}] + } + }`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 2, character: 31 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 2, character: 30 }, + end: { line: 2, character: 34 } + }, + parent: { + range: { + start: { line: 2, character: 23 }, + end: { line: 2, character: 34 } + }, + parent: { + range: { + start: { line: 2, character: 23 }, + end: { line: 2, character: 34 } + }, + parent: { + range: { + start: { line: 2, character: 22 }, + end: { line: 2, character: 35 } + }, + parent: { + range: { + start: { line: 2, character: 16 }, + end: { line: 2, character: 35 } + }, + parent: { + range: { + start: { line: 2, character: 15 }, + end: { line: 2, character: 36 } + }, + parent: { + range: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 36 } + }, + parent: { + range: { + start: { line: 1, character: 14 }, + end: { line: 3, character: 6 } + }, + parent: { + range: { + start: { line: 1, character: 13 }, + end: { line: 3, character: 7 } + }, + parent: { + range: { + start: { line: 1, character: 6 }, + end: { line: 3, character: 7 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 4, character: 4 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 4, character: 5 } + } + } + } + } + } + } + } + } + } + } + } + } + } + ]); + }); + + test("should return the whole document as a single flat range for a position at the very start of the document", async () => { + await client.writeDocument("test.json", `{"a": 1}`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 0, character: 0 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 8 } + } + } + ]); + }); + + test("should return a collapsed range with no parent for a position at the very end of the document", async () => { + await client.writeDocument("test.json", `{"a": 1}\n`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 0, character: 8 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 0, character: 8 }, + end: { line: 0, character: 8 } + } + } + ]); + }); + + test("should not return an inner range for an empty array", async () => { + await client.writeDocument("test.json", `{"a": []}`); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 0, character: 7 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 8 } + }, + parent: { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 8 } + }, + parent: { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 9 } + } + } + } + } + } + ]); + }); + + test("should return a collapsed range with no parent when the document has no AST", async () => { + await client.writeDocument("test.json", ``); + const uri = await client.openDocument("test.json"); + + const result = await client.sendRequest(SelectionRangeRequest.type, { + textDocument: { uri }, + positions: [{ line: 0, character: 0 }] + }); + + expect(result).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 } + } + } + ]); + }); +}); diff --git a/language-server/src/features/SelectionRanges.ts b/language-server/src/features/SelectionRanges.ts new file mode 100644 index 0000000..2f36284 --- /dev/null +++ b/language-server/src/features/SelectionRanges.ts @@ -0,0 +1,75 @@ +import * as jsonc from "jsonc-parser"; + +import type { SelectionRange, 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"; + +export class SelectionRanges { + private jsonDocuments: JsonDocuments; + + constructor(server: Server, jsonDocuments: JsonDocuments) { + this.jsonDocuments = jsonDocuments; + + server.onInitialize(() => { + const serverCapabilities: ServerCapabilities = { + selectionRangeProvider: true + }; + + return { + capabilities: serverCapabilities + }; + }); + + server.onSelectionRanges((params) => { + const jsonDocument = this.jsonDocuments.get(params.textDocument.uri)!; + const scanner = jsonc.createScanner(jsonDocument.getText(), true); + + return params.positions.map((position) => { + const offset = jsonDocument.offsetAt(position); + const node = jsonDocument.findNodeAtPosition(position); + + if (!node) { + return { range: { start: position, end: position } }; + } + + return this.buildSelectionRange(node, jsonDocument, scanner, offset); + }); + }); + } + + private buildSelectionRange(node: jsonc.Node, jsonDocument: JsonDocument, scanner: jsonc.JSONScanner, offset: number): SelectionRange { + let selection = node.parent ? this.buildSelectionRange(node.parent, jsonDocument, scanner, offset) : undefined; + + if (node.type === "property" || (node.parent?.type === "array")) { + scanner.setPosition(node.offset + node.length); + if (scanner.scan() === jsonc.SyntaxKind.CommaToken) { + const afterComma = scanner.getTokenOffset() + scanner.getTokenLength(); + selection = { range: jsonDocument.rangeAt(node.offset, afterComma), parent: selection }; + } + } + + switch (node.type) { + case "string": + case "object": + case "array": { + selection = { range: jsonDocument.rangeAt(node.offset, node.offset + node.length), parent: selection }; + + const innerStart = node.offset + 1; + const innerEnd = node.offset + node.length - 1; + if (innerStart < innerEnd && offset >= innerStart) { + selection = { range: jsonDocument.rangeAt(innerStart, innerEnd), parent: selection }; + } + break; + } + case "number": + case "boolean": + case "null": + case "property": + selection = { range: jsonDocument.rangeAt(node.offset, node.offset + node.length), parent: selection }; + break; + } + + return selection; + } +} diff --git a/language-server/src/features/SyntaxValidation.ts b/language-server/src/features/SyntaxValidation.ts index 1dffe8e..0fdb692 100644 --- a/language-server/src/features/SyntaxValidation.ts +++ b/language-server/src/features/SyntaxValidation.ts @@ -8,10 +8,7 @@ export class SyntaxValidation implements DiagnosticsProvider { async getDiagnostics(jsonDocument: JsonDocument) { return jsonDocument.getParseErrors().map((error) => ({ severity: DiagnosticSeverity.Error, - range: { - start: jsonDocument.positionAt(error.offset), - end: jsonDocument.positionAt(error.offset + error.length) - }, + range: jsonDocument.rangeAt(error.offset, error.offset + error.length), message: jsonc.printParseErrorCode(error.error), source: "hyperjump-json-language-server" })); diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index cd7565d..f6d4bf0 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -105,6 +105,13 @@ export class JsonDocument implements TextDocument { return this.textDocument.offsetAt(position); } + rangeAt(startOffset: number, endOffset: number) { + return { + start: this.positionAt(startOffset), + end: this.positionAt(endOffset) + }; + } + update(changes: TextDocumentContentChangeEvent[], version: number) { TextDocument.update(this.textDocument, changes, version); this.validate();