From 3bac05bee7ebe43316a0469636d297c9c506ecce Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 6 Aug 2026 09:09:04 +0530 Subject: [PATCH 1/4] use completionProvider and seperate value and key completion --- language-server/src/build-server.ts | 7 +- language-server/src/features/Completion.ts | 44 +-- ...ion.test.ts => PropertyCompletion.test.ts} | 63 ++-- .../src/features/PropertyCompletion.ts | 45 +++ .../src/features/ValueCompletion.test.ts | 301 ++++++++++++++++++ .../src/features/ValueCompletion.ts | 93 ++++++ language-server/src/models/JsonDocument.ts | 29 +- .../src/services/MatchingSchemaCollector.ts | 34 +- 8 files changed, 552 insertions(+), 64 deletions(-) rename language-server/src/features/{Completion.test.ts => PropertyCompletion.test.ts} (95%) create mode 100644 language-server/src/features/PropertyCompletion.ts create mode 100644 language-server/src/features/ValueCompletion.test.ts create mode 100644 language-server/src/features/ValueCompletion.ts 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 95% rename from language-server/src/features/Completion.test.ts rename to language-server/src/features/PropertyCompletion.test.ts index 6ed486c..543e03b 100644 --- a/language-server/src/features/Completion.test.ts +++ b/language-server/src/features/PropertyCompletion.test.ts @@ -2,6 +2,8 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { CompletionRequest, CompletionItemKind, PublishDiagnosticsNotification } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; +import type { CompletionItem } from "vscode-languageserver"; + describe("Completions", () => { let client: TestClient; let fixtureSchemaUri: string; @@ -42,7 +44,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([]); + expect(labels(completions)).toEqual([]); }); test("completion returns properties", async () => { @@ -75,7 +77,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "name", kind: CompletionItemKind.Property } ]); }); @@ -119,7 +121,7 @@ describe("Completions", () => { position: { line: 3, character: 9 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "street", kind: CompletionItemKind.Property }, { label: "city", kind: CompletionItemKind.Property }, { label: "zipCode", kind: CompletionItemKind.Property } @@ -159,7 +161,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "age", kind: CompletionItemKind.Property }, { label: "city", kind: CompletionItemKind.Property } ]); @@ -208,7 +210,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "foo", kind: CompletionItemKind.Property }, { label: "bar", kind: CompletionItemKind.Property }, { label: "baz", kind: CompletionItemKind.Property } @@ -259,7 +261,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "bar", kind: CompletionItemKind.Property }, { label: "baz", kind: CompletionItemKind.Property } ]); @@ -308,7 +310,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "foo", kind: CompletionItemKind.Property }, { label: "bar", kind: CompletionItemKind.Property }, { label: "baz", kind: CompletionItemKind.Property } @@ -359,7 +361,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "bar", kind: CompletionItemKind.Property } ]); }); @@ -407,7 +409,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "foo", kind: CompletionItemKind.Property }, { label: "bar", kind: CompletionItemKind.Property }, { label: "baz", kind: CompletionItemKind.Property } @@ -458,7 +460,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "bar", kind: CompletionItemKind.Property } ]); }); @@ -495,7 +497,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "baz", kind: CompletionItemKind.Property } ]); }); @@ -544,7 +546,7 @@ describe("Completions", () => { position: { line: 4, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "baz", kind: CompletionItemKind.Property } ]); }); @@ -595,7 +597,7 @@ describe("Completions", () => { position: { line: 4, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "baz", kind: CompletionItemKind.Property } ]); }); @@ -642,7 +644,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "a", kind: CompletionItemKind.Property }, { label: "b", kind: CompletionItemKind.Property } ]); @@ -695,7 +697,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "foo", kind: CompletionItemKind.Property }, { label: "c", kind: CompletionItemKind.Property } ]); @@ -746,7 +748,7 @@ describe("Completions", () => { position: { line: 4, character: 7 } }); - expect(completions).toEqual([]); + expect(labels(completions)).toEqual([]); }); test("patternProperties: suggests only the properties declared by properties", async () => { @@ -783,7 +785,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "name", kind: CompletionItemKind.Property } ]); }); @@ -834,7 +836,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "a", kind: CompletionItemKind.Property } ]); }); @@ -885,7 +887,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "a", kind: CompletionItemKind.Property }, { label: "b", kind: CompletionItemKind.Property } ]); @@ -926,7 +928,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "bar", kind: CompletionItemKind.Property }, { label: "foo", kind: CompletionItemKind.Property } ]); @@ -960,7 +962,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "foo", kind: CompletionItemKind.Property } ]); }); @@ -1000,7 +1002,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "bar", kind: CompletionItemKind.Property }, { label: "foo", kind: CompletionItemKind.Property } ]); @@ -1037,7 +1039,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "foo", kind: CompletionItemKind.Property } ]); }); @@ -1074,7 +1076,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "a", kind: CompletionItemKind.Property }, { label: "b", kind: CompletionItemKind.Property } ]); @@ -1113,7 +1115,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([]); + expect(labels(completions)).toEqual([]); }); test("not: excludes required properties wrapped in an anyOf branch", async () => { @@ -1153,7 +1155,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([]); + expect(labels(completions)).toEqual([]); }); test("not: excludes required properties wrapped in a oneOf branch", async () => { @@ -1193,7 +1195,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(completions).toEqual([]); + expect(labels(completions)).toEqual([]); }); test("anyOf: omits a candidate property whose type would violate the additionalProperties constraint of a compatible branch", async () => { @@ -1243,8 +1245,13 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(completions).toEqual([ + expect(labels(completions)).toEqual([ { label: "c", kind: CompletionItemKind.Property } ]); }); }); + +const labels = (completions: CompletionItem[] | { items: CompletionItem[] } | null) => { + const items = Array.isArray(completions) ? completions : completions?.items ?? []; + return items.map((item) => ({ label: item.label, kind: item.kind })); +}; 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..28bd452 --- /dev/null +++ b/language-server/src/features/ValueCompletion.test.ts @@ -0,0 +1,301 @@ +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.skip("Value completion: selecting a property with const shows that tooltip", 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.skip("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", "green", "blue"] } + } + }`); + + 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: `"green"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` "green"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` "blue"` + } + } + ]); + }); +}); diff --git a/language-server/src/features/ValueCompletion.ts b/language-server/src/features/ValueCompletion.ts new file mode 100644 index 0000000..6349461 --- /dev/null +++ b/language-server/src/features/ValueCompletion.ts @@ -0,0 +1,93 @@ +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 isDeclared = await jsonDocument.hasDeclaredProperty(objectNode, propertyName); + if (!isDeclared) { + return []; + } + + const range = { + start: jsonDocument.positionAt(node.colonOffset! + 1), + end: position + }; + + const annotations = await jsonDocument.getAnnotations(node.children![1]); + const types = annotations.reduce((types, annotation) => { + const currentTypes = annotation["https://json-schema.org/keyword/type"]; + const currentTypesArray = Array.isArray(currentTypes) ? currentTypes : [currentTypes]; + const currentTypesSet = new Set(currentTypesArray); + return types.intersection(currentTypesSet); + }, new Set(["object", "array", "string", "number", "integer", "boolean", "null"])); + + 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; + } + + if (type === "number" || type === "integer") { + 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"; + 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..07b7285 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -60,7 +60,18 @@ export class JsonDocument implements TextDocument { return; } - const instance = jsonc.parse(this.getText()); + this.walkNodesWithProperties(this.ast!, (node) => { + if (node.type === "property" && node.children!.length < 2) { + node.children![1] = { + type: "null", + value: null, + offset: 0, + length: 0, + parent: node + }; + } + }); + const instance = structuredClone(jsonc.getNodeValue(this.ast!)); return this.schemaStore.validate(schemaUri, instance, this.uri, [this.matchingSchemaCollector]); }); } @@ -168,6 +179,12 @@ export class JsonDocument implements TextDocument { return this.matchingSchemaCollector.getDeclaredProperties(pointer); } + async hasDeclaredProperty(node: jsonc.Node, propertyName: string) { + await this.schemaErrors; + const pointer = this.getPointerForNode(node); + return this.matchingSchemaCollector.hasDeclaredProperty(pointer, propertyName); + } + findNodeAtPosition(position: Position) { if (!this.ast) { return; @@ -193,4 +210,14 @@ export class JsonDocument implements TextDocument { } } } + + walkNodesWithProperties(node: jsonc.Node, fn: (node: jsonc.Node) => void) { + fn(node); + + if (Array.isArray(node.children)) { + for (const childNode of node.children!) { + this.walkNodes(childNode, fn); + } + } + } } diff --git a/language-server/src/services/MatchingSchemaCollector.ts b/language-server/src/services/MatchingSchemaCollector.ts index 879a2ef..5bc842b 100644 --- a/language-server/src/services/MatchingSchemaCollector.ts +++ b/language-server/src/services/MatchingSchemaCollector.ts @@ -8,6 +8,7 @@ type Annotation = Record; type MatchingSchemaContext = ValidationContext & { pendingAnnotations?: Annotation; + unconditionalAnnotations?: Annotation; declaredProperties?: Set; passedProperties?: Set; failedProperties?: Set; @@ -30,6 +31,7 @@ export class MatchingSchemaCollector implements EvaluationPlugin { beforeSchema(_url: string, _instance: JsonNode, context: MatchingSchemaContext): void { context.pendingAnnotations = {}; + context.unconditionalAnnotations = {}; context.declaredProperties = undefined; context.rejectedProperties = undefined; } @@ -48,11 +50,20 @@ export class MatchingSchemaCollector implements EvaluationPlugin { afterKeyword(node: Node, instance: JsonNode, context: MatchingSchemaContext, _valid: boolean, schemaContext: MatchingSchemaContext, keyword: Keyword): void { const [keywordId, , keywordValue] = node; + // Annotations + if (keyword.annotation) { schemaContext.pendingAnnotations ??= {}; schemaContext.pendingAnnotations[keywordId] = keyword.annotation(keywordValue, instance, context); } + if (keywordId === "https://json-schema.org/keyword/type") { + schemaContext.unconditionalAnnotations ??= {}; + schemaContext.unconditionalAnnotations[keywordId] = keywordValue; + } + + // Property Completion + if (keywordId === "https://json-schema.org/keyword/required" && schemaContext.negated && instance.type === "object") { const required = keywordValue as string[]; const missing = required.filter((propertyName) => !Instance.has(propertyName, instance)); @@ -90,13 +101,15 @@ export class MatchingSchemaCollector implements EvaluationPlugin { } afterSchema(_schemaUri: string, instance: JsonNode, context: MatchingSchemaContext, valid: boolean): void { - if (valid && context.pendingAnnotations) { + const hasAlways = context.unconditionalAnnotations && Object.keys(context.unconditionalAnnotations).length > 0; + const hasGated = valid && context.pendingAnnotations && Object.keys(context.pendingAnnotations).length > 0; + + if (hasAlways || hasGated) { if (!this.annotations.has(instance.pointer)) { this.annotations.set(instance.pointer, []); } - - const existing = this.annotations.get(instance.pointer)!; - existing.push(context.pendingAnnotations); + const merged = { ...(hasGated ? context.pendingAnnotations : {}), ...(hasAlways ? context.unconditionalAnnotations : {}) }; + this.annotations.get(instance.pointer)!.push(merged); } const propertyName = propertyNameOf(instance.pointer); @@ -135,6 +148,19 @@ export class MatchingSchemaCollector implements EvaluationPlugin { const forbiddenProperties = this.forbiddenProperties.get(instanceLocation); return forbiddenProperties ? propertyNames.difference(forbiddenProperties) : propertyNames; } + + hasDeclaredProperty(instanceLocation: string, propertyName: string): boolean { + 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 true; + } + } + return false; + } } const addAll = (target: Set, source?: Iterable) => { From 313b5dd6928f3b6499501bd1f1f050c25e384799 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 6 Aug 2026 09:58:20 +0530 Subject: [PATCH 2/4] cleanup --- language-server/src/services/MatchingSchemaCollector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/language-server/src/services/MatchingSchemaCollector.ts b/language-server/src/services/MatchingSchemaCollector.ts index 5bc842b..b59b7bb 100644 --- a/language-server/src/services/MatchingSchemaCollector.ts +++ b/language-server/src/services/MatchingSchemaCollector.ts @@ -101,8 +101,8 @@ export class MatchingSchemaCollector implements EvaluationPlugin { } afterSchema(_schemaUri: string, instance: JsonNode, context: MatchingSchemaContext, valid: boolean): void { - const hasAlways = context.unconditionalAnnotations && Object.keys(context.unconditionalAnnotations).length > 0; - const hasGated = valid && context.pendingAnnotations && Object.keys(context.pendingAnnotations).length > 0; + const hasAlways = context.unconditionalAnnotations; + const hasGated = valid && context.pendingAnnotations; if (hasAlways || hasGated) { if (!this.annotations.has(instance.pointer)) { From c250df998870280c9716a77cb7acf06ac3f5b265 Mon Sep 17 00:00:00 2001 From: Diya Date: Sat, 8 Aug 2026 02:14:00 +0530 Subject: [PATCH 3/4] redo value completion to read const/enum/type from AST via the Collector --- language-server/package-lock.json | 7 +- .../src/features/ValueCompletion.test.ts | 15 ++-- .../src/features/ValueCompletion.ts | 30 ++++--- language-server/src/models/JsonDocument.ts | 27 +------ .../src/services/MatchingSchemaCollector.ts | 81 ++++++++++++------- 5 files changed, 87 insertions(+), 73 deletions(-) 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/features/ValueCompletion.test.ts b/language-server/src/features/ValueCompletion.test.ts index 28bd452..69c14eb 100644 --- a/language-server/src/features/ValueCompletion.test.ts +++ b/language-server/src/features/ValueCompletion.test.ts @@ -143,6 +143,7 @@ describe("Completions", () => { } ]); }); + test("Value completion : completion should return true & false for type Boolean", async () => { const diagnostics: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, () => { @@ -195,7 +196,7 @@ describe("Completions", () => { ]); }); - test.skip("Value completion: selecting a property with const shows that tooltip", async () => { + test("Value completion: selecting a property with const shows that const value", async () => { const diagnostics: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, () => { resolve(); @@ -238,7 +239,7 @@ describe("Completions", () => { ]); }); - test.skip("Value completion: shows enum suggestion for a property", async () => { + test("Value completion: shows enum suggestion for a property", async () => { const diagnostics: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, () => { resolve(); @@ -249,7 +250,7 @@ describe("Completions", () => { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "color": { "enum": ["red", "green", "blue"] } + "color": { "enum": ["red", null , 42] } } }`); @@ -279,21 +280,21 @@ describe("Completions", () => { } }, { - label: `"green"`, + label: `null`, kind: CompletionItemKind.EnumMember, insertTextFormat: InsertTextFormat.Snippet, textEdit: { range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, - newText: ` "green"` + newText: ` null` } }, { - label: `"blue"`, + label: `42`, kind: CompletionItemKind.EnumMember, insertTextFormat: InsertTextFormat.Snippet, textEdit: { range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, - newText: ` "blue"` + newText: ` 42` } } ]); diff --git a/language-server/src/features/ValueCompletion.ts b/language-server/src/features/ValueCompletion.ts index 6349461..b12053f 100644 --- a/language-server/src/features/ValueCompletion.ts +++ b/language-server/src/features/ValueCompletion.ts @@ -20,8 +20,8 @@ export class ValueCompletion implements CompletionsProvider { const propertyName = node.children![0].value as string; const objectNode = node.parent!; - const isDeclared = await jsonDocument.hasDeclaredProperty(objectNode, propertyName); - if (!isDeclared) { + const valueInfo = await jsonDocument.getPropertyValueInfo(objectNode, propertyName); + if (!valueInfo) { return []; } @@ -30,13 +30,25 @@ export class ValueCompletion implements CompletionsProvider { end: position }; - const annotations = await jsonDocument.getAnnotations(node.children![1]); - const types = annotations.reduce((types, annotation) => { - const currentTypes = annotation["https://json-schema.org/keyword/type"]; - const currentTypesArray = Array.isArray(currentTypes) ? currentTypes : [currentTypes]; - const currentTypesSet = new Set(currentTypesArray); - return types.intersection(currentTypesSet); - }, new Set(["object", "array", "string", "number", "integer", "boolean", "null"])); + 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) { diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 07b7285..b5ac6a0 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -60,18 +60,7 @@ export class JsonDocument implements TextDocument { return; } - this.walkNodesWithProperties(this.ast!, (node) => { - if (node.type === "property" && node.children!.length < 2) { - node.children![1] = { - type: "null", - value: null, - offset: 0, - length: 0, - parent: node - }; - } - }); - const instance = structuredClone(jsonc.getNodeValue(this.ast!)); + const instance = jsonc.getNodeValue(this.ast!); return this.schemaStore.validate(schemaUri, instance, this.uri, [this.matchingSchemaCollector]); }); } @@ -179,10 +168,10 @@ export class JsonDocument implements TextDocument { return this.matchingSchemaCollector.getDeclaredProperties(pointer); } - async hasDeclaredProperty(node: jsonc.Node, propertyName: string) { + async getPropertyValueInfo(node: jsonc.Node, propertyName: string) { await this.schemaErrors; const pointer = this.getPointerForNode(node); - return this.matchingSchemaCollector.hasDeclaredProperty(pointer, propertyName); + return this.matchingSchemaCollector.getPropertyValueInfo(pointer, propertyName); } findNodeAtPosition(position: Position) { @@ -210,14 +199,4 @@ export class JsonDocument implements TextDocument { } } } - - walkNodesWithProperties(node: jsonc.Node, fn: (node: jsonc.Node) => void) { - fn(node); - - if (Array.isArray(node.children)) { - for (const childNode of node.children!) { - this.walkNodes(childNode, fn); - } - } - } } diff --git a/language-server/src/services/MatchingSchemaCollector.ts b/language-server/src/services/MatchingSchemaCollector.ts index b59b7bb..5e1c389 100644 --- a/language-server/src/services/MatchingSchemaCollector.ts +++ b/language-server/src/services/MatchingSchemaCollector.ts @@ -6,10 +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; - unconditionalAnnotations?: Annotation; - declaredProperties?: Set; + declaredProperties?: Map; passedProperties?: Set; failedProperties?: Set; rejectedProperties?: Set; @@ -18,7 +24,7 @@ type MatchingSchemaContext = ValidationContext & { }; type Alternative = { - declaredProperties: Set; + declaredProperties: Map; rejectedProperties: Set; isAlternative: boolean; }; @@ -28,12 +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.unconditionalAnnotations = {}; context.declaredProperties = undefined; context.rejectedProperties = undefined; + this.ast ??= context.ast as Record; } beforeKeyword(node: Node, _instance: JsonNode, context: MatchingSchemaContext, schemaContext: MatchingSchemaContext): void { @@ -50,20 +57,11 @@ export class MatchingSchemaCollector implements EvaluationPlugin { afterKeyword(node: Node, instance: JsonNode, context: MatchingSchemaContext, _valid: boolean, schemaContext: MatchingSchemaContext, keyword: Keyword): void { const [keywordId, , keywordValue] = node; - // Annotations - if (keyword.annotation) { schemaContext.pendingAnnotations ??= {}; schemaContext.pendingAnnotations[keywordId] = keyword.annotation(keywordValue, instance, context); } - if (keywordId === "https://json-schema.org/keyword/type") { - schemaContext.unconditionalAnnotations ??= {}; - schemaContext.unconditionalAnnotations[keywordId] = keywordValue; - } - - // Property Completion - if (keywordId === "https://json-schema.org/keyword/required" && schemaContext.negated && instance.type === "object") { const required = keywordValue as string[]; const missing = required.filter((propertyName) => !Instance.has(propertyName, instance)); @@ -76,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 }); + } } } @@ -101,15 +103,13 @@ export class MatchingSchemaCollector implements EvaluationPlugin { } afterSchema(_schemaUri: string, instance: JsonNode, context: MatchingSchemaContext, valid: boolean): void { - const hasAlways = context.unconditionalAnnotations; - const hasGated = valid && context.pendingAnnotations; - - if (hasAlways || hasGated) { + if (valid && context.pendingAnnotations) { if (!this.annotations.has(instance.pointer)) { this.annotations.set(instance.pointer, []); } - const merged = { ...(hasGated ? context.pendingAnnotations : {}), ...(hasAlways ? context.unconditionalAnnotations : {}) }; - this.annotations.get(instance.pointer)!.push(merged); + + const existing = this.annotations.get(instance.pointer)!; + existing.push(context.pendingAnnotations); } const propertyName = propertyNameOf(instance.pointer); @@ -118,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; @@ -141,7 +141,7 @@ 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()); } } @@ -149,17 +149,17 @@ export class MatchingSchemaCollector implements EvaluationPlugin { return forbiddenProperties ? propertyNames.difference(forbiddenProperties) : propertyNames; } - hasDeclaredProperty(instanceLocation: string, propertyName: string): boolean { + 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 true; + return alternative.declaredProperties.get(propertyName); } } - return false; + return undefined; } } @@ -177,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 }; + } +}; From b37520cea1738ee6f704fb5527e2c99b002bd15a Mon Sep 17 00:00:00 2001 From: Diya Date: Sat, 8 Aug 2026 03:30:32 +0530 Subject: [PATCH 4/4] assert complete response for PropertyCompletion tests --- .../src/features/PropertyCompletion.test.ts | 498 +++++++++++++++--- .../src/features/ValueCompletion.ts | 6 +- 2 files changed, 423 insertions(+), 81 deletions(-) diff --git a/language-server/src/features/PropertyCompletion.test.ts b/language-server/src/features/PropertyCompletion.test.ts index 543e03b..16b31d1 100644 --- a/language-server/src/features/PropertyCompletion.test.ts +++ b/language-server/src/features/PropertyCompletion.test.ts @@ -2,8 +2,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { CompletionRequest, CompletionItemKind, PublishDiagnosticsNotification } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; -import type { CompletionItem } from "vscode-languageserver"; - describe("Completions", () => { let client: TestClient; let fixtureSchemaUri: string; @@ -44,7 +42,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([]); + expect(completions).toEqual([]); }); test("completion returns properties", async () => { @@ -77,8 +75,17 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "name", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -121,10 +128,37 @@ describe("Completions", () => { position: { line: 3, character: 9 } }); - expect(labels(completions)).toEqual([ - { label: "street", kind: CompletionItemKind.Property }, - { label: "city", kind: CompletionItemKind.Property }, - { label: "zipCode", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -161,9 +195,27 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "age", kind: CompletionItemKind.Property }, - { label: "city", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -210,10 +262,37 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -261,9 +340,27 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -310,10 +407,37 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -329,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" } }, @@ -361,8 +485,17 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "bar", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -409,10 +542,37 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -460,8 +620,17 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "bar", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -497,8 +666,17 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -546,8 +724,17 @@ describe("Completions", () => { position: { line: 4, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -597,8 +784,17 @@ describe("Completions", () => { position: { line: 4, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -644,9 +840,27 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -697,9 +911,27 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "c", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -748,7 +980,7 @@ describe("Completions", () => { position: { line: 4, character: 7 } }); - expect(labels(completions)).toEqual([]); + expect(completions).toEqual([]); }); test("patternProperties: suggests only the properties declared by properties", async () => { @@ -785,8 +1017,17 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "name", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -836,8 +1077,17 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "a", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -887,9 +1137,27 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -928,9 +1196,27 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "foo", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -962,8 +1248,17 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "foo", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -982,7 +1277,7 @@ describe("Completions", () => { }, "not": { "not": { - "required": ["bar"] + "required": ["bar"] } } }`); @@ -1002,9 +1297,27 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "foo", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -1039,8 +1352,17 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "foo", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -1076,9 +1398,27 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); @@ -1115,7 +1455,7 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([]); + expect(completions).toEqual([]); }); test("not: excludes required properties wrapped in an anyOf branch", async () => { @@ -1155,7 +1495,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([]); + expect(completions).toEqual([]); }); test("not: excludes required properties wrapped in a oneOf branch", async () => { @@ -1195,7 +1535,7 @@ describe("Completions", () => { position: { line: 2, character: 7 } }); - expect(labels(completions)).toEqual([]); + expect(completions).toEqual([]); }); test("anyOf: omits a candidate property whose type would violate the additionalProperties constraint of a compatible branch", async () => { @@ -1245,13 +1585,17 @@ describe("Completions", () => { position: { line: 3, character: 7 } }); - expect(labels(completions)).toEqual([ - { label: "c", kind: CompletionItemKind.Property } + expect(completions).toEqual([ + { + 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" } + } ]); }); }); - -const labels = (completions: CompletionItem[] | { items: CompletionItem[] } | null) => { - const items = Array.isArray(completions) ? completions : completions?.items ?? []; - return items.map((item) => ({ label: item.label, kind: item.kind })); -}; diff --git a/language-server/src/features/ValueCompletion.ts b/language-server/src/features/ValueCompletion.ts index b12053f..0d0f6d7 100644 --- a/language-server/src/features/ValueCompletion.ts +++ b/language-server/src/features/ValueCompletion.ts @@ -70,10 +70,6 @@ export class ValueCompletion implements CompletionsProvider { continue; } - if (type === "number" || type === "integer") { - continue; - } - completionItems.push({ label: valueLabel(type), kind: CompletionItemKind.Value, @@ -91,6 +87,8 @@ const valuePlaceholder = (type: string, tabIndex: number): string => { case "object": return "{$0}"; case "array": return "[$0]"; case "null": return "null"; + case "number": + case "integer": return " "; default: return `$${tabIndex}`; } };