diff --git a/language-server/package-lock.json b/language-server/package-lock.json index 249b440..0497783 100644 --- a/language-server/package-lock.json +++ b/language-server/package-lock.json @@ -65,10 +65,9 @@ } }, "node_modules/@hyperjump/json-schema": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.17.7.tgz", - "integrity": "sha512-CP4OTm4y5U200z3Ir6SAQk9aGM61m1LZpd4TMNXTZbOgHg02TvYFhtQLQcG2WPx6nmmM6DPS9ha+N7poY6e+uA==", - "license": "MIT", + "version": "1.17.8", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.17.8.tgz", + "integrity": "sha512-XOqbR9GRNHaH4JEXHdbsm7xfYwudZG7HVDq3qPZUb1gi+ZQPklgNvhMi6zf0Plf433qR61MK+xeeprUwUUvGPg==", "dependencies": { "@hyperjump/json-pointer": "^1.1.0", "@hyperjump/json-schema-formats": "^1.0.0", diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index b68dc1c..0588a33 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -8,6 +8,8 @@ import { SchemaValidation } from "./features/SchemaValidation.ts"; import { Formatting } from "./features/Formatting.ts"; import { Hover } from "./features/Hover.ts"; import { Completion } from "./features/Completion.ts"; +import { PropertyCompletion } from "./features/PropertyCompletion.ts"; +import { ValueCompletion } from "./features/ValueCompletion.ts"; import { FoldingRanges } from "./features/FoldingRanges.ts"; import "@hyperjump/json-schema/draft-2020-12"; @@ -37,7 +39,10 @@ export const buildServer = (connection: Connection): Server => { new Formatting(server, documents); new Hover(server, documents); - new Completion(server, documents); + new Completion(server, documents, [ + new PropertyCompletion(), + new ValueCompletion() + ]); new FoldingRanges(server, documents); return server; diff --git a/language-server/src/features/Completion.ts b/language-server/src/features/Completion.ts index 7965d30..721543a 100644 --- a/language-server/src/features/Completion.ts +++ b/language-server/src/features/Completion.ts @@ -1,11 +1,14 @@ -import { CompletionItemKind } from "vscode-languageserver"; -import { JsonDocuments } from "../services/JsonDocuments.ts"; - +import type { CompletionItem, Position, ServerCapabilities } from "vscode-languageserver"; import type { Server } from "../services/Server.ts"; -import type { CompletionItem, ServerCapabilities } from "vscode-languageserver"; +import type { JsonDocument } from "../models/JsonDocument.ts"; +import type { JsonDocuments } from "../services/JsonDocuments.ts"; + +export type CompletionsProvider = { + getCompletions(jsonDocument: JsonDocument, position: Position): Promise; +}; export class Completion { - constructor(server: Server, jsonDocuments: JsonDocuments) { + constructor(server: Server, jsonDocuments: JsonDocuments, providers: CompletionsProvider[]) { server.onInitialize(() => { const serverCapabilities: ServerCapabilities = { completionProvider: { @@ -18,34 +21,15 @@ export class Completion { }; }); - server.onCompletion(async (params) => { - const jsonDocument = jsonDocuments.get(params.textDocument.uri)!; - const keyNode = jsonDocument.findNodeAtPosition(params.position)!; - const propertyNode = keyNode.parent; - - if (propertyNode?.type !== "property" || propertyNode.children![0] !== keyNode) { - return []; - } - - const objectNode = propertyNode.parent!; - - const propertyNames = await jsonDocument.getDeclaredProperties(objectNode); - for (const node of objectNode.children!) { - if (node === propertyNode) { - continue; - } + server.onCompletion(async ({ textDocument, position }) => { + const jsonDocument = jsonDocuments.get(textDocument.uri)!; - propertyNames.delete(node.children![0].value); + const completions: CompletionItem[] = []; + for (const provider of providers) { + completions.push(...await provider.getCompletions(jsonDocument, position)); } - const completionItems: CompletionItem[] = []; - for (const propertyName of propertyNames) { - completionItems.push({ - label: propertyName, - kind: CompletionItemKind.Property - }); - } - return completionItems; + return completions; }); } } diff --git a/language-server/src/features/Completion.test.ts b/language-server/src/features/PropertyCompletion.test.ts similarity index 71% rename from language-server/src/features/Completion.test.ts rename to language-server/src/features/PropertyCompletion.test.ts index 6ed486c..16b31d1 100644 --- a/language-server/src/features/Completion.test.ts +++ b/language-server/src/features/PropertyCompletion.test.ts @@ -76,7 +76,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "name", kind: CompletionItemKind.Property } + { + label: "name", + kind: CompletionItemKind.Property, + filterText: `"name"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"name": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -120,9 +129,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "street", kind: CompletionItemKind.Property }, - { label: "city", kind: CompletionItemKind.Property }, - { label: "zipCode", kind: CompletionItemKind.Property } + { + label: "street", + kind: CompletionItemKind.Property, + filterText: `"street"`, + textEdit: { + range: { start: { line: 3, character: 8 }, end: { line: 3, character: 10 } }, + newText: `"street": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "city", + kind: CompletionItemKind.Property, + filterText: `"city"`, + textEdit: { + range: { start: { line: 3, character: 8 }, end: { line: 3, character: 10 } }, + newText: `"city": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "zipCode", + kind: CompletionItemKind.Property, + filterText: `"zipCode"`, + textEdit: { + range: { start: { line: 3, character: 8 }, end: { line: 3, character: 10 } }, + newText: `"zipCode": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -160,8 +196,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "age", kind: CompletionItemKind.Property }, - { label: "city", kind: CompletionItemKind.Property } + { + label: "age", + kind: CompletionItemKind.Property, + filterText: `"age"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"age": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "city", + kind: CompletionItemKind.Property, + filterText: `"city"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"city": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -209,9 +263,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -260,8 +341,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -309,9 +408,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -327,14 +453,14 @@ describe("Completions", () => { "type": "object", "anyOf": [ { - "properties": { + "properties": { "foo": { "type": "number" }, "bar": { "type": "string" } }, "required": ["foo"] }, { - "properties": { + "properties": { "foo": { "type": "string" }, "baz": { "type": "string" } }, @@ -360,7 +486,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -408,9 +543,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -459,7 +621,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -496,7 +667,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -545,7 +725,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 4, character: 6 }, end: { line: 4, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -596,7 +785,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 4, character: 6 }, end: { line: 4, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -643,8 +841,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "b", + kind: CompletionItemKind.Property, + filterText: `"b"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"b": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -696,8 +912,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "c", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "c", + kind: CompletionItemKind.Property, + filterText: `"c"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"c": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -784,7 +1018,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "name", kind: CompletionItemKind.Property } + { + label: "name", + kind: CompletionItemKind.Property, + filterText: `"name"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"name": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -835,7 +1078,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -886,8 +1138,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "b", + kind: CompletionItemKind.Property, + filterText: `"b"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"b": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -927,8 +1197,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "foo", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -961,7 +1249,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -980,7 +1277,7 @@ describe("Completions", () => { }, "not": { "not": { - "required": ["bar"] + "required": ["bar"] } } }`); @@ -1001,8 +1298,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "foo", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -1038,7 +1353,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -1075,8 +1399,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "b", + kind: CompletionItemKind.Property, + filterText: `"b"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"b": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -1244,7 +1586,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "c", kind: CompletionItemKind.Property } + { + label: "c", + kind: CompletionItemKind.Property, + filterText: `"c"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"c": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); }); diff --git a/language-server/src/features/PropertyCompletion.ts b/language-server/src/features/PropertyCompletion.ts new file mode 100644 index 0000000..c7060f9 --- /dev/null +++ b/language-server/src/features/PropertyCompletion.ts @@ -0,0 +1,45 @@ +import { CompletionItemKind } from "vscode-languageserver"; + +import type { JsonDocument } from "../models/JsonDocument.ts"; +import type { CompletionsProvider } from "./Completion.ts"; +import type { CompletionItem, Position } from "vscode-languageserver"; + +export class PropertyCompletion implements CompletionsProvider { + async getCompletions(jsonDocument: JsonDocument, position: Position) { + const keyNode = jsonDocument.findNodeAtPosition(position)!; + const propertyNode = keyNode.parent; + + if (propertyNode?.type !== "property" || propertyNode.children![0] !== keyNode) { + return []; + } + + const objectNode = propertyNode.parent!; + + const propertyNames = await jsonDocument.getDeclaredProperties(objectNode); + for (const node of objectNode.children!) { + if (node === propertyNode) { + continue; + } + + propertyNames.delete(node.children![0].value); + } + + const completionItems: CompletionItem[] = []; + for (const propertyName of propertyNames) { + completionItems.push({ + label: propertyName, + kind: CompletionItemKind.Property, + filterText: JSON.stringify(propertyName), + textEdit: { + range: { + start: jsonDocument.positionAt(keyNode.offset), + end: jsonDocument.positionAt(keyNode.offset + keyNode.length) + }, + newText: `"${propertyName}": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }); + } + return completionItems; + } +} diff --git a/language-server/src/features/ValueCompletion.test.ts b/language-server/src/features/ValueCompletion.test.ts new file mode 100644 index 0000000..69c14eb --- /dev/null +++ b/language-server/src/features/ValueCompletion.test.ts @@ -0,0 +1,302 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { CompletionRequest, CompletionItemKind, PublishDiagnosticsNotification, InsertTextFormat } from "vscode-languageserver"; +import { TestClient } from "../test/TestClient.ts"; + +describe("Completions", () => { + let client: TestClient; + let fixtureSchemaUri: string; + + beforeEach(async () => { + client = new TestClient(); + await client.start(); + }); + + afterEach(async () => { + await client.stop(); + }); + + test("Value completion : completion should return cursor inside quotes for string", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "name": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: ` "$1"` + } + } + ]); + }); + + test("Value completion : completion should return cursor inside {} for object", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "object" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "name": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `{}`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: ` {$0}` + } + } + ]); + }); + + test("Value completion : completion should return cursor inside [] for array", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "array" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "name": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `[]`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: ` [$0]` + } + } + ]); + }); + + test("Value completion : completion should return true & false for type Boolean", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "test": { "type": "boolean" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "test": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: "true", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: " true" + } + }, + { + label: "false", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: " false" + } + } + ]); + }); + + test("Value completion: selecting a property with const shows that const value", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": { "const": "foo" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "value": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 15 } + }); + + expect(completions).toEqual([ + { + label: `"foo"`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "foo"` + } + } + ]); + }); + + test("Value completion: shows enum suggestion for a property", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "color": { "enum": ["red", null , 42] } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "color": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 16 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` "red"` + } + }, + { + label: `null`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` null` + } + }, + { + label: `42`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` 42` + } + } + ]); + }); +}); diff --git a/language-server/src/features/ValueCompletion.ts b/language-server/src/features/ValueCompletion.ts new file mode 100644 index 0000000..0d0f6d7 --- /dev/null +++ b/language-server/src/features/ValueCompletion.ts @@ -0,0 +1,103 @@ +import { CompletionItemKind, InsertTextFormat } from "vscode-languageserver"; + +import type { CompletionItem, Position } from "vscode-languageserver"; +import type { JsonDocument } from "../models/JsonDocument.ts"; +import type { CompletionsProvider } from "./Completion.ts"; + +export class ValueCompletion implements CompletionsProvider { + async getCompletions(jsonDocument: JsonDocument, position: Position): Promise { + const node = jsonDocument.findNodeAtPosition(position)!; + + if (node.type !== "property" || node.colonOffset === undefined) { + return []; + } + + const offset = jsonDocument.offsetAt(position); + if (offset <= node.colonOffset!) { + return []; + } + + const propertyName = node.children![0].value as string; + const objectNode = node.parent!; + + const valueInfo = await jsonDocument.getPropertyValueInfo(objectNode, propertyName); + if (!valueInfo) { + return []; + } + + const range = { + start: jsonDocument.positionAt(node.colonOffset! + 1), + end: position + }; + + if (valueInfo.hasConst) { + return [{ + label: JSON.stringify(valueInfo.const), + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " " + JSON.stringify(valueInfo.const) } + }]; + } + + if (valueInfo.enum?.length) { + return valueInfo.enum.map((value) => ({ + label: JSON.stringify(value), + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " " + JSON.stringify(value) } + })); + } + + const types = new Set(Array.isArray(valueInfo.type) ? valueInfo.type : valueInfo.type ? [valueInfo.type] : []); + + const completionItems: CompletionItem[] = []; + for (const type of types) { + if (type === "boolean") { + completionItems.push( + { + label: "true", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " true" } + }, + { + label: "false", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " false" } + } + ); + continue; + } + + completionItems.push({ + label: valueLabel(type), + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " " + valuePlaceholder(type, 1) } + }); + } + return completionItems; + } +} + +const valuePlaceholder = (type: string, tabIndex: number): string => { + switch (type) { + case "string": return `"$${tabIndex}"`; + case "object": return "{$0}"; + case "array": return "[$0]"; + case "null": return "null"; + case "number": + case "integer": return " "; + default: return `$${tabIndex}`; + } +}; + +const valueLabel = (type: string): string => { + switch (type) { + case "string": return `""`; + case "object": return "{}"; + case "array": return "[]"; + default: return type; + } +}; diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index cd7565d..b5ac6a0 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -60,7 +60,7 @@ export class JsonDocument implements TextDocument { return; } - const instance = jsonc.parse(this.getText()); + const instance = jsonc.getNodeValue(this.ast!); return this.schemaStore.validate(schemaUri, instance, this.uri, [this.matchingSchemaCollector]); }); } @@ -168,6 +168,12 @@ export class JsonDocument implements TextDocument { return this.matchingSchemaCollector.getDeclaredProperties(pointer); } + async getPropertyValueInfo(node: jsonc.Node, propertyName: string) { + await this.schemaErrors; + const pointer = this.getPointerForNode(node); + return this.matchingSchemaCollector.getPropertyValueInfo(pointer, propertyName); + } + findNodeAtPosition(position: Position) { if (!this.ast) { return; diff --git a/language-server/src/services/MatchingSchemaCollector.ts b/language-server/src/services/MatchingSchemaCollector.ts index 879a2ef..5e1c389 100644 --- a/language-server/src/services/MatchingSchemaCollector.ts +++ b/language-server/src/services/MatchingSchemaCollector.ts @@ -6,9 +6,16 @@ import type { Node, Keyword } from "@hyperjump/json-schema/experimental"; type Annotation = Record; +type PropertyValueInfo = { + type?: string | string[]; + enum?: unknown[]; + const?: unknown; + hasConst: boolean; +}; + type MatchingSchemaContext = ValidationContext & { pendingAnnotations?: Annotation; - declaredProperties?: Set; + declaredProperties?: Map; passedProperties?: Set; failedProperties?: Set; rejectedProperties?: Set; @@ -17,7 +24,7 @@ type MatchingSchemaContext = ValidationContext & { }; type Alternative = { - declaredProperties: Set; + declaredProperties: Map; rejectedProperties: Set; isAlternative: boolean; }; @@ -27,11 +34,13 @@ export class MatchingSchemaCollector implements EvaluationPlugin { private alternatives: Map = new Map(); private acceptedProperties: Map> = new Map(); private forbiddenProperties: Map> = new Map(); + private ast?: Record; beforeSchema(_url: string, _instance: JsonNode, context: MatchingSchemaContext): void { context.pendingAnnotations = {}; context.declaredProperties = undefined; context.rejectedProperties = undefined; + this.ast ??= context.ast as Record; } beforeKeyword(node: Node, _instance: JsonNode, context: MatchingSchemaContext, schemaContext: MatchingSchemaContext): void { @@ -65,16 +74,20 @@ export class MatchingSchemaCollector implements EvaluationPlugin { } if (keywordId === "https://json-schema.org/keyword/properties") { - schemaContext.declaredProperties ??= new Set(); - for (const propertyName in keywordValue as Record) { - schemaContext.declaredProperties.add(propertyName); + schemaContext.declaredProperties ??= new Map(); + for (const [propertyName, schemaUri] of Object.entries(keywordValue as Record)) { + if (!schemaContext.declaredProperties.has(propertyName)) { + schemaContext.declaredProperties.set(propertyName, resolveValueInfo(this.ast, schemaUri)); + } } } if (keywordId === "https://json-schema.org/keyword/required") { - schemaContext.declaredProperties ??= new Set(); + schemaContext.declaredProperties ??= new Map(); for (const propertyName of keywordValue as string[]) { - schemaContext.declaredProperties.add(propertyName); + if (!schemaContext.declaredProperties.has(propertyName)) { + schemaContext.declaredProperties.set(propertyName, { hasConst: false }); + } } } @@ -105,7 +118,7 @@ export class MatchingSchemaCollector implements EvaluationPlugin { outcome.add(propertyName); } - const declaredProperties = context.declaredProperties ?? new Set(); + const declaredProperties = context.declaredProperties ?? new Map(); const rejectedProperties = context.rejectedProperties ?? new Set(); const isAlternative = context.isAlternative ?? false; @@ -128,13 +141,26 @@ export class MatchingSchemaCollector implements EvaluationPlugin { for (const alternative of alternatives) { const isContradicted = [...alternative.rejectedProperties].some((propertyName) => acceptedProperties.has(propertyName)); if (!alternative.isAlternative || !isContradicted) { - addAll(propertyNames, alternative.declaredProperties); + addAll(propertyNames, alternative.declaredProperties?.keys()); } } const forbiddenProperties = this.forbiddenProperties.get(instanceLocation); return forbiddenProperties ? propertyNames.difference(forbiddenProperties) : propertyNames; } + + getPropertyValueInfo(instanceLocation: string, propertyName: string): PropertyValueInfo | undefined { + const alternatives = this.alternatives.get(instanceLocation) ?? []; + const acceptedProperties = this.acceptedProperties.get(instanceLocation) ?? new Set(); + + for (const alternative of alternatives) { + const isContradicted = [...alternative.rejectedProperties].some((p) => acceptedProperties.has(p)); + if ((!alternative.isAlternative || !isContradicted) && alternative.declaredProperties.has(propertyName)) { + return alternative.declaredProperties.get(propertyName); + } + } + return undefined; + } } const addAll = (target: Set, source?: Iterable) => { @@ -151,3 +177,26 @@ const propertyNameOf = (instanceLocation: string) => { const lastSegment = instanceLocation.slice(instanceLocation.lastIndexOf("/") + 1); return lastSegment; }; + +const resolveValueInfo = (ast: Record | undefined, schemaUri: string): PropertyValueInfo => { + try { + const info: PropertyValueInfo = { hasConst: false }; + const node = ast?.[schemaUri]; + if (!Array.isArray(node)) { + return info; + } + for (const [keywordId, , keywordValue] of node as [string, unknown, unknown][]) { + if (keywordId === "https://json-schema.org/keyword/type") { + info.type = keywordValue as string | string[]; + } else if (keywordId === "https://json-schema.org/keyword/enum") { + info.enum = (keywordValue as string[]).map((v) => JSON.parse(v) as unknown); + } else if (keywordId === "https://json-schema.org/keyword/const") { + info.const = JSON.parse(keywordValue as string) as unknown; + info.hasConst = true; + } + } + return info; + } catch { + return { hasConst: false }; + } +};