From 4f97c0071ec1593aecdd1f408ffa6e95e11733b5 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 6 Aug 2026 09:09:04 +0530 Subject: [PATCH 1/5] 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 1f09bc5..bf9f1af 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 { DocumentSymbols } from "./features/DocumentSymbols.ts"; import { SelectionRanges } from "./features/SelectionRanges.ts"; @@ -39,7 +41,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); new DocumentSymbols(server, documents); new SelectionRanges(server, documents); 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 f6d4bf0..529cc69 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]); }); } @@ -175,6 +186,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; @@ -200,4 +217,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 dd91f1fcfa09af69763a01a72668b864f6c8f1ab Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 6 Aug 2026 09:58:20 +0530 Subject: [PATCH 2/5] 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 ec38862dc9f818e673a288965677eec887c5d651 Mon Sep 17 00:00:00 2001 From: Diya Date: Sat, 8 Aug 2026 02:14:00 +0530 Subject: [PATCH 3/5] redo value completion to read const/enum/type from AST via the Collector --- .../src/features/ValueCompletion.test.ts | 15 ++-- .../src/features/ValueCompletion.ts | 30 ++++--- language-server/src/models/JsonDocument.ts | 27 +------ .../src/services/MatchingSchemaCollector.ts | 81 ++++++++++++------- 4 files changed, 84 insertions(+), 69 deletions(-) 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 529cc69..c84bb5b 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]); }); } @@ -186,10 +175,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) { @@ -217,14 +206,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 381608e039e23fdf98ef0ee89515b3cd251ba44d Mon Sep 17 00:00:00 2001 From: Diya Date: Sat, 8 Aug 2026 03:30:32 +0530 Subject: [PATCH 4/5] 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}`; } }; From 3d7462f99733bf63c31e2dd94fadfcd29e001ac4 Mon Sep 17 00:00:00 2001 From: Diya Date: Tue, 18 Aug 2026 01:42:29 +0530 Subject: [PATCH 5/5] value completion with test suite --- .../src/features/ValueCompletion.test.ts | 2432 +++++++++++++++++ .../src/features/ValueCompletion.ts | 45 +- language-server/src/models/JsonDocument.ts | 23 +- .../services/AnnotationEvaluationPlugin.ts | 41 + .../services/CompletionEvaluationPlugin.ts | 568 ++++ .../src/services/MatchingSchemaCollector.ts | 202 -- 6 files changed, 3086 insertions(+), 225 deletions(-) create mode 100644 language-server/src/services/AnnotationEvaluationPlugin.ts create mode 100644 language-server/src/services/CompletionEvaluationPlugin.ts delete mode 100644 language-server/src/services/MatchingSchemaCollector.ts diff --git a/language-server/src/features/ValueCompletion.test.ts b/language-server/src/features/ValueCompletion.test.ts index 69c14eb..f77a4cf 100644 --- a/language-server/src/features/ValueCompletion.test.ts +++ b/language-server/src/features/ValueCompletion.test.ts @@ -15,6 +15,7 @@ describe("Completions", () => { await client.stop(); }); + // basic value types, const and enum test("Value completion : completion should return cursor inside quotes for string", async () => { const diagnostics: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, () => { @@ -299,4 +300,2435 @@ describe("Completions", () => { } ]); }); + + // allOf tests + test("allOf : value completion suggests common enum info from both allOf branch", 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", + "allOf": [ + { + "properties": { + "color": { "enum": ["red", "amber", "pink"] } + } + }, + { + "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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + } + ]); + }); + + test("allOf: value completion intersects compatible enum and type values from branches", 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", + "allOf": [ + { + "properties": { + "color": { "type": "string" } + } + }, + { + "properties": { + "color": { "enum": ["red", 42 , null] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + } + ]); + }); + + test("allOf: value completion suggest nothing for incompatible 'enum' and 'type' values from branches", 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", + "allOf": [ + { + "properties": { + "color": { "type": "string" } + } + }, + { + "properties": { + "color": { "enum": [false, 42 , null] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([]); + }); + + test("allOf: value completion uses type when both branches declare the same type", 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", + "allOf": [ + { + "properties": { + "name": { "type": "string" } + } + }, + { + "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: 14 } + }); + + expect(completions).toEqual([ + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 14 } }, + newText: ` "$1"` + } + } + ]); + }); + + test("allOf: value completion suggests nothing for branches with conflicting types", 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", + "allOf": [ + { + "properties": { + "name": { "type": "string" } + } + }, + { + "properties": { + "name": { "type": "boolean" } + } + } + ] + }`); + + 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: 14 } + }); + + expect(completions).toEqual([]); + }); + + test("allOf: an 'allOf' nested inside allOf branch, processes its own branches then intersects with allOf", 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", + "allOf": [ + { + "properties": { + "color": { "enum": ["baz", "bar"] } + } + }, + { + "allOf": [ + { + "properties": { + "color": { "enum": ["foo", "bar"] } + } + }, + { + "properties": { + "color": { "enum": ["foo", "baz"] } + } + } + ] + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([]); + }); + + test("allOf: an 'anyOf' nested inside allOf branch, processes its own branches then intersects with allOf", 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", + "allOf": [ + { + "properties": { + "color": { "enum": ["red", "blue"] } + } + }, + { + "anyOf": [ + { + "properties": { + "color": { "enum": ["red", "blue"] } + } + }, + { + "properties": { + "color": { "enum": ["green", "yellow"] } + } + } + ] + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + test("allOf: an 'oneOf' nested inside allOf branch, processes its own branches then intersects with allOf", 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", + "allOf": [ + { + "properties": { + "color": { "enum": ["amber", "blue"] } + } + }, + { + "oneOf": [ + { + "properties": { + "color": { "enum": ["red", "blue"] } + } + }, + { + "properties": { + "color": { "enum": ["green", "yellow"] } + } + } + ] + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + // anyOf tests + test("anyOf: offers union of 'types' before a discriminant is typed", 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", + "anyOf": [ + { + "properties": { + "foo": { "const": "a" }, + "bar": { "type": "null" } + }, + "required": ["foo"] + }, + { + "properties": { + "foo": { "const": "b" }, + "bar": { "type": "string" } + }, + "required": ["foo"] + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "bar": + }`; + + 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: `null`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 12 }, end: { line: 2, character: 13 } }, + newText: ` null` + } + }, + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 12 }, end: { line: 2, character: 13 } }, + newText: ` "$1"` + } + } + ]); + }); + + test("anyOf: offers union of 'enum' before a discriminant is typed", 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", + "anyOf": [ + { + "properties": { + "color": { "enum": ["red", "amber"] } + } + }, + { + "properties": { + "color": { "enum": ["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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"amber"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "amber"` + } + }, + { + label: `"green"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "green"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + test("anyOf: offers union of 'const' values before a discriminant is typed", 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", + "anyOf": [ + { + "properties": { + "kind": { "const": "a" } + } + }, + { + "properties": { + "kind": { "const": "b" } + } + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "kind": + }`; + + 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: 14 } + }); + + expect(completions).toEqual([ + { + label: `"a"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 14 } }, + newText: ` "a"` + } + }, + { + label: `"b"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 14 } }, + newText: ` "b"` + } + } + ]); + }); + + test("anyOf: an 'allOf' nested inside an 'anyOf' branch, first intersected then unioned", 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", + "anyOf": [ + { + "allOf": [ + { + "properties": { + "color": { "enum": ["red", "amber", "pink"] } + } + }, + { + "properties": { + "color": { "enum": ["red", "green"] } + } + } + ] + }, + { + "properties": { + "color": { "enum": ["black"] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"black"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "black"` + } + } + ]); + }); + + test("anyOf: value completion after a discriminant is typed", 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", + "anyOf": [ + { + "properties": { + "foo": { "const": "a" }, + "bar": { "enum": ["foo", "bar"] } + }, + "required": ["foo"] + }, + { + "properties": { + "foo": { "const": "b" }, + "bar": { "enum": ["a", "b"] } + }, + "required": ["foo"] + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "foo": "a", + "bar": + }`; + + 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: 3, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `"foo"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 12 }, end: { line: 3, character: 13 } }, + newText: ` "foo"` + } + }, + { + label: `"bar"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 12 }, end: { line: 3, character: 13 } }, + newText: ` "bar"` + } + } + ]); + }); + + // oneOf Tests + test("oneOf: completion unions enum values from every branch before a discriminant is typed", 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", + "oneOf": [ + { + "properties": { + "color": { "enum": ["red", "amber"] } + } + }, + { + "properties": { + "color": { "enum": ["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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"amber"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "amber"` + } + }, + { + label: `"green"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "green"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + test("oneOf: Symmetric-difference, a value offered by more than one branch is dropped", 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", + "oneOf": [ + { + "properties": { + "color": { "enum": ["red", "amber"] } + } + }, + { + "properties": { + "color": { "enum": ["amber", "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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + test("oneOf: Symmetric-difference, but with odd number of branches", 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", + "oneOf": [ + { + "properties": { + "color": { "enum": ["red", "amber"] } + } + }, + { + "properties": { + "color": { "enum": ["amber", "blue"] } + } + }, + { + "properties": { + "color": { "enum": ["amber", "pink"] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + }, + { + label: `"pink"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "pink"` + } + } + ]); + }); + + test("oneOf: value completion offers both branches' const values", 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", + "oneOf": [ + { + "properties": { + "kind": { "const": "a" } + } + }, + { + "properties": { + "kind": { "const": "b" } + } + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "kind": + }`; + + 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: 14 } + }); + + expect(completions).toEqual([ + { + label: `"a"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 14 } }, + newText: ` "a"` + } + }, + { + label: `"b"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 14 } }, + newText: ` "b"` + } + } + ]); + }); + + test("oneOf: value completion narrows to a single branch once the discriminant is typed", 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", + "oneOf": [ + { + "properties": { + "foo": { "const": "a" }, + "bar": { "enum": ["baz", "bar"] } + }, + "required": ["foo"] + }, + { + "properties": { + "foo": { "const": "b" }, + "bar": { "enum": ["qwe", "abc"] } + }, + "required": ["foo"] + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "foo": "a", + "bar": + }`; + + 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: 3, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `"baz"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 12 }, end: { line: 3, character: 13 } }, + newText: ` "baz"` + } + }, + { + label: `"bar"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 12 }, end: { line: 3, character: 13 } }, + newText: ` "bar"` + } + } + ]); + }); + + test("oneOf + allOf: an allOf alongside a oneOf filters the unioned values by type", 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", + "allOf": [ + { + "properties": { + "color": { "type": "string" } + } + } + ], + "oneOf": [ + { + "properties": { + "color": { "enum": ["red", 42] } + } + }, + { + "properties": { + "color": { "enum": ["blue", null] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + test("oneOf + allOf: an allOf inside a oneOf branch is intersected, not flattened into the union", 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", + "oneOf": [ + { + "allOf": [ + { + "properties": { + "color": { "enum": ["red", "amber", "pink"] } + } + }, + { + "properties": { + "color": { "enum": ["red", "green"] } + } + } + ] + }, + { + "properties": { + "color": { "enum": ["black"] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"black"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "black"` + } + } + ]); + }); + + // not keyword Tests + test("not: excludes a single 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": { + "color": { + "enum": ["red", "green", "blue"], + "not": { "const": "green" } + } + } + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"blue"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "blue"` + } + } + ]); + }); + + test("not: excludes every value listed in a 'not' enum", 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", "yellow"], + "not": { "enum": ["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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"yellow"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "yellow"` + } + } + ]); + }); + + test("not: excludes true from a boolean property's completions", 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": { + "allowConnection": { + "type": "boolean", + "not": { "const": true } + } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "allowConnection": + }`; + + 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: 25 } + }); + + expect(completions).toEqual([ + { + label: "false", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 24 }, end: { line: 2, character: 25 } }, + newText: " false" + } + } + ]); + }); + + test("not + allOf: a 'not' in any branch excludes an enum value from the intersected result", 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", + "allOf": [ + { + "properties": { + "color": { "enum": ["red", "green", "blue"] } + } + }, + { + "properties": { + "color": { "enum": ["blue", "red"] } + } + }, + { + "properties": { + "color": { "not": { "const": "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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + } + ]); + }); + + test("not + anyOf: a value stays offered unless EVERY branch excludes it", 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", + "anyOf": [ + { + "properties": { + "runner": { + "enum": ["linux", "windows", "mac"], + "not": { "const": "linux" } + } + } + }, + { + "properties": { + "runner": { + "enum": ["linux", "windows"], + "not": { "const": "windows" } + } + } + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "runner": + }`; + + 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: `"windows"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 16 } }, + newText: ` "windows"` + } + }, + { + label: `"mac"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 16 } }, + newText: ` "mac"` + } + }, + { + label: `"linux"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 16 } }, + newText: ` "linux"` + } + } + ]); + }); + + test("not: a 'not' on a const has no effect on a type's completion, when type is general", 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", + "not": { "const": "foo" } + } + } + }`); + + 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("not + oneOf: a 'not' in one branch narrows completion values before symmetric-difference", 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", + "oneOf": [ + { + "properties": { + "color": { + "enum": ["red", "green", "blue"], + "not": { "const": "green" } + } + } + }, + { + "properties": { + "color": { "enum": ["yellow", "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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + }, + { + label: `"yellow"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "yellow"` + } + } + ]); + }); + + test("not + oneOf: enum values are filtered by type and 'not', then unioned", 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", + "oneOf": [ + { + "properties": { + "level": { + "type": "string", + "enum": ["warn", "debug", 0, 1], + "not": { "const": "debug" } + } + } + }, + { + "properties": { + "level": { + "enum": ["debug", "trace"], + "not": { "const": "debug" } + } + } + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "level": + }`; + + 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: `"warn"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "warn"` + } + }, + { + label: `"trace"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "trace"` + } + } + ]); + }); + + test("not: excludes a type from the property's own type 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": { + "value": { + "type": ["string", "number"], + "not": { "type": "string" } + } + } + }`); + + 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: "number", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` $0` + } + } + ]); + }); + + test("not + allOf: a 'not' type in one branch narrows the type array declared in another", 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", + "allOf": [ + { + "properties": { + "value": { "type": ["string", "number", "boolean"] } + } + }, + { + "properties": { + "value": { "not": { "type": "boolean" } } + } + } + ] + }`); + + 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: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "$1"` + } + }, + { + label: "number", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " $0" + } + } + ]); + }); + + // additionalProperties Tests + test("additionalProperties: value completion for a property not covered by 'properties'", 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": { + "version": { "type": "number" } + }, + "additionalProperties": { "enum": ["read", "write"] } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "access": + }`; + + 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: `"read"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 16 } }, + newText: ` "read"` + } + }, + { + label: `"write"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 16 } }, + newText: ` "write"` + } + } + ]); + }); + + test("additionalProperties: narrowing when oneOf branches", 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", + "oneOf": [ + { + "type": "object", + "properties": { + "version": { "const": "web" } + }, + "additionalProperties": { "type": "boolean" } + }, + { + "type": "object", + "properties": { + "version": { "const": "desktop" } + }, + "additionalProperties": { "type": "object" } + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "version": "web", + "darkmode": + }`; + + 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: 3, character: 18 } + }); + + expect(completions).toEqual([ + { + label: "true", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 17 }, end: { line: 3, character: 18 } }, + newText: " true" + } + }, + { + label: "false", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 17 }, end: { line: 3, character: 18 } }, + newText: " false" + } + } + ]); + }); + + test("additionalProperties: no completion could be given because both alternatives get filtered.", 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", + "oneOf": [ + { + "type": "object", + "properties": { + "version": { "const": "web" } + }, + "additionalProperties": { "type": "boolean" } + }, + { + "type": "object", + "properties": { + "version": { "const": "desktop" } + }, + "additionalProperties": { "type": "object" } + } + ] + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "version": "web", + "rollout": {}, + "darkmode": + }`; + + 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: 4, character: 18 } + }); + + expect(completions).toEqual([]); + }); + + // if / then / else Tests + test("if/then: 'then' applies when the 'if' condition is true", 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": { + "shape": { "const": "circle" } + }, + "if": { + "properties": { "shape": { "const": "circle" } } + }, + "then": { + "properties": { "radius": { "type": "number" } } + }, + "else": { + "properties": { "radius": { "type": "string" } } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "shape": "circle", + "radius": + }`; + + 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: 3, character: 15 } + }); + + expect(completions).toEqual([ + { + label: "number", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 15 }, end: { line: 3, character: 15 } }, + newText: " $0" + } + } + ]); + }); + + test("if/else: 'else' applies when the 'if' condition isn't true", 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": { + "shape": { "const": "square" } + }, + "if": { + "properties": { "shape": { "const": "circle" } } + }, + "then": { + "properties": { "radius": { "type": "number" } } + }, + "else": { + "properties": { "radius": { "type": "string" } } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "shape": "square", + "radius": + }`; + + 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: 3, character: 15 } + }); + + expect(completions).toEqual([ + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 3, character: 15 }, end: { line: 3, character: 15 } }, + newText: ` "$1"` + } + } + ]); + }); + + // combinators inside the property's own schema, rather than at the root level + test("anyOf: an unconstrained branch in one variant offers its value plus every basic type", 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", + "anyOf": [ + { + "properties": { + "mode": { "const": "strict" }, + "value": { "enum": ["auto", "off"] } + } + }, + { + "properties": { + "mode": { "const": "legacy" }, + "value": {} + } + } + ] + }`); + + 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: `"auto"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "auto"` + } + }, + { + label: `"off"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "off"` + } + }, + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "$1"` + } + }, + { + label: "number", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " $0" + } + }, + { + label: "true", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " true" + } + }, + { + label: "false", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " false" + } + }, + { + label: "null", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " null" + } + }, + { + label: "{}", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " {$0}" + } + }, + { + label: "[]", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " [$0]" + } + }, + { + label: "integer", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: " $0" + } + } + ]); + }); + + test("a combinator inside a property's own schema UNIONS its branches", 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": { + "format": { + "anyOf": [ + { "enum": ["uri", "ipv4", "email", "date"] }, + { "type": "string" } + ] + } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "format": + }`; + + 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: `"uri"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 15 } }, + newText: ` "uri"` + } + }, + { + label: `"ipv4"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 15 } }, + newText: ` "ipv4"` + } + }, + { + label: `"email"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 15 } }, + newText: ` "email"` + } + }, + { + label: `"date"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 15 } }, + newText: ` "date"` + } + }, + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 15 }, end: { line: 2, character: 15 } }, + newText: ` "$1"` + } + } + ]); + }); + + test("a combinator inside a property's own schema INTERSECTS its branches", 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": { + "allOf": [ + { "enum": ["red", "blue", "green"] }, + { "enum": ["red", "green"] } + ] + } + } + }`); + + 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: 14 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 14 } }, + newText: ` "red"` + } + }, + { + label: `"green"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 14 } }, + newText: ` "green"` + } + } + ]); + }); + + // TODO: implement filtering keywords (minLength, maximum, minimum etc) + test.skip("anyOf + allOf: an outer type constraint filters the union of anyOf enum values", 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", + "allOf": [ + { + "properties": { + "color": { "type": "string", "maxLength": 3 } + } + } + ], + "anyOf": [ + { + "properties": { + "color": { "enum": ["red", 42] } + } + }, + { + "properties": { + "color": { "enum": ["blue", null] } + } + } + ] + }`); + + 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: 15 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "red"` + } + } + ]); + }); }); diff --git a/language-server/src/features/ValueCompletion.ts b/language-server/src/features/ValueCompletion.ts index 0d0f6d7..5554aee 100644 --- a/language-server/src/features/ValueCompletion.ts +++ b/language-server/src/features/ValueCompletion.ts @@ -30,43 +30,58 @@ export class ValueCompletion implements CompletionsProvider { end: position }; + const completionItems: CompletionItem[] = []; + if (valueInfo.hasConst) { - return [{ + completionItems.push({ 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) => ({ + }); + } else if (valueInfo.enum) { + completionItems.push(...valueInfo.enum.map((value) => ({ label: JSON.stringify(value), kind: CompletionItemKind.EnumMember, insertTextFormat: InsertTextFormat.Snippet, textEdit: { range, newText: " " + JSON.stringify(value) } - })); + }))); + } + + if ((!valueInfo.hasConst && !valueInfo.enum) || valueInfo.permitsAnyValue) { + completionItems.push(...this.genericTypeCompletions(valueInfo, range)); } - const types = new Set(Array.isArray(valueInfo.type) ? valueInfo.type : valueInfo.type ? [valueInfo.type] : []); + return completionItems; + } + + private genericTypeCompletions(valueInfo: { type?: string | string[]; excluded?: unknown[] }, range: { start: Position; end: Position }): CompletionItem[] { + const types = new Set(Array.isArray(valueInfo.type) ? valueInfo.type : valueInfo.type ? [valueInfo.type] : ["string", "number", "boolean", "null", "object", "array", "integer"]); + const excluded = new Set((valueInfo.excluded ?? []).map((value) => JSON.stringify(value))); const completionItems: CompletionItem[] = []; for (const type of types) { if (type === "boolean") { - completionItems.push( - { + if (!excluded.has("true")) { + completionItems.push({ label: "true", kind: CompletionItemKind.Value, insertTextFormat: InsertTextFormat.Snippet, textEdit: { range, newText: " true" } - }, - { + }); + } + if (!excluded.has("false")) { + completionItems.push({ label: "false", kind: CompletionItemKind.Value, insertTextFormat: InsertTextFormat.Snippet, textEdit: { range, newText: " false" } - } - ); + }); + } + continue; + } + + if (type === "null" && excluded.has("null")) { continue; } @@ -88,7 +103,7 @@ const valuePlaceholder = (type: string, tabIndex: number): string => { case "array": return "[$0]"; case "null": return "null"; case "number": - case "integer": return " "; + case "integer": return "$0"; default: return `$${tabIndex}`; } }; diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index c84bb5b..5b362d2 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -5,7 +5,8 @@ import * as JsonPointer from "@hyperjump/json-pointer"; import { resolveIri } from "@hyperjump/uri"; import { SchemaStore } from "../services/SchemaStore.ts"; import { Server } from "../services/Server.ts"; -import { MatchingSchemaCollector } from "../services/MatchingSchemaCollector.ts"; +import { AnnotationEvaluationPlugin } from "../services/AnnotationEvaluationPlugin.ts"; +import { CompletionEvaluationPlugin } from "../services/CompletionEvaluationPlugin.ts"; import { abbreviateUri } from "../util/utils.ts"; import type { Position, Range } from "vscode-languageserver-textdocument"; @@ -19,7 +20,8 @@ export class JsonDocument implements TextDocument { private parseErrors: jsonc.ParseError[] = []; private schemaErrors: Promise = Promise.resolve(undefined); private schemaUri: Promise = Promise.resolve(undefined); - private matchingSchemaCollector = new MatchingSchemaCollector(); + private annotationEvaluationPlugin = new AnnotationEvaluationPlugin(); + private completionEvaluationPlugin = new CompletionEvaluationPlugin(); constructor(textDocument: TextDocument, schemaStore: SchemaStore, server: Server) { this.textDocument = textDocument; @@ -35,7 +37,8 @@ export class JsonDocument implements TextDocument { this.parseErrors = []; this.schemaErrors = Promise.resolve(undefined); this.schemaUri = Promise.resolve(undefined); - this.matchingSchemaCollector = new MatchingSchemaCollector(); + this.annotationEvaluationPlugin = new AnnotationEvaluationPlugin(); + this.completionEvaluationPlugin = new CompletionEvaluationPlugin(); this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors); @@ -54,14 +57,18 @@ export class JsonDocument implements TextDocument { } validateSchema() { - this.matchingSchemaCollector = new MatchingSchemaCollector(); + this.annotationEvaluationPlugin = new AnnotationEvaluationPlugin(); + this.completionEvaluationPlugin = new CompletionEvaluationPlugin(); this.schemaErrors = this.schemaUri.then((schemaUri) => { if (!schemaUri) { return; } const instance = jsonc.getNodeValue(this.ast!); - return this.schemaStore.validate(schemaUri, instance, this.uri, [this.matchingSchemaCollector]); + return this.schemaStore.validate(schemaUri, instance, this.uri, [ + this.annotationEvaluationPlugin, + this.completionEvaluationPlugin + ]); }); } @@ -165,20 +172,20 @@ export class JsonDocument implements TextDocument { await this.schemaErrors; const pointer = this.getPointerForNode(node!); - return this.matchingSchemaCollector.getAnnotations(pointer); + return this.annotationEvaluationPlugin.getAnnotations(pointer); } async getDeclaredProperties(node: jsonc.Node) { await this.schemaErrors; const pointer = this.getPointerForNode(node); - return this.matchingSchemaCollector.getDeclaredProperties(pointer); + return this.completionEvaluationPlugin.getDeclaredProperties(pointer); } async getPropertyValueInfo(node: jsonc.Node, propertyName: string) { await this.schemaErrors; const pointer = this.getPointerForNode(node); - return this.matchingSchemaCollector.getPropertyValueInfo(pointer, propertyName); + return this.completionEvaluationPlugin.getPropertyValueInfo(pointer, propertyName); } findNodeAtPosition(position: Position) { diff --git a/language-server/src/services/AnnotationEvaluationPlugin.ts b/language-server/src/services/AnnotationEvaluationPlugin.ts new file mode 100644 index 0000000..98bafab --- /dev/null +++ b/language-server/src/services/AnnotationEvaluationPlugin.ts @@ -0,0 +1,41 @@ +import type { EvaluationPlugin, ValidationContext } from "@hyperjump/json-schema/experimental"; +import type { JsonNode } from "@hyperjump/json-schema/instance/experimental"; +import type { Node, Keyword } from "@hyperjump/json-schema/experimental"; + +type Annotation = Record; + +type AnnotationContext = ValidationContext & { + pendingAnnotations?: Annotation; +}; + +export class AnnotationEvaluationPlugin implements EvaluationPlugin { + private annotations: Map = new Map(); + + beforeSchema(_url: string, _instance: JsonNode, context: AnnotationContext): void { + context.pendingAnnotations = {}; + } + + afterKeyword(node: Node, instance: JsonNode, context: AnnotationContext, _valid: boolean, schemaContext: AnnotationContext, keyword: Keyword): void { + const [keywordId, , keywordValue] = node; + + if (keyword.annotation) { + schemaContext.pendingAnnotations ??= {}; + schemaContext.pendingAnnotations[keywordId] = keyword.annotation(keywordValue, instance, context); + } + } + + afterSchema(_schemaUri: string, instance: JsonNode, context: AnnotationContext, valid: boolean): void { + if (valid && context.pendingAnnotations) { + if (!this.annotations.has(instance.pointer)) { + this.annotations.set(instance.pointer, []); + } + + const existing = this.annotations.get(instance.pointer)!; + existing.push(context.pendingAnnotations); + } + } + + getAnnotations(instanceLocation: string): Annotation[] { + return this.annotations.get(instanceLocation) ?? []; + } +} diff --git a/language-server/src/services/CompletionEvaluationPlugin.ts b/language-server/src/services/CompletionEvaluationPlugin.ts new file mode 100644 index 0000000..f6816d5 --- /dev/null +++ b/language-server/src/services/CompletionEvaluationPlugin.ts @@ -0,0 +1,568 @@ +import * as Instance from "@hyperjump/json-schema/instance/experimental"; + +import type { EvaluationPlugin, ValidationContext } from "@hyperjump/json-schema/experimental"; +import type { JsonNode } from "@hyperjump/json-schema/instance/experimental"; +import type { Node } from "@hyperjump/json-schema/experimental"; + +type PropertyValueInfo = { + type?: string | string[]; + enum?: unknown[]; + const?: unknown; + hasConst: boolean; + excluded?: unknown[]; + excludedTypes?: string[]; + permitsAnyValue?: boolean; +}; + +type CompletionContext = ValidationContext & { + declaredProperties?: Map; + additionalPropertiesInfo?: PropertyValueInfo; + passedProperties?: Set; + failedProperties?: Set; + rejectedProperties?: Set; + negated?: boolean; + isAnyOf?: boolean; + isOneOf?: boolean; + groupId?: number; + inIfCondition?: boolean; +}; + +type Alternative = { + declaredProperties: Map; + additionalPropertiesInfo?: PropertyValueInfo; + rejectedProperties: Set; + isAnyOf?: boolean; + isOneOf?: boolean; + groupId?: number; +}; + +export class CompletionEvaluationPlugin implements EvaluationPlugin { + private alternatives: Map = new Map(); + private acceptedProperties: Map> = new Map(); + private forbiddenProperties: Map> = new Map(); + + private allOfCheckpoints: Map = new Map(); + private nextGroupId = 0; + + private ast?: Record; + + beforeSchema(_url: string, _instance: JsonNode, context: CompletionContext): void { + context.declaredProperties = undefined; + context.additionalPropertiesInfo = undefined; + context.rejectedProperties = undefined; + this.ast ??= context.ast as Record; + } + + beforeKeyword(node: Node, instance: JsonNode, context: CompletionContext, schemaContext: CompletionContext): void { + const [keywordId] = node; + const negated = schemaContext.negated ?? false; + context.negated = keywordId === "https://json-schema.org/keyword/not" ? !negated : negated; + + const anyOf = schemaContext.isAnyOf ?? false; + context.isAnyOf = keywordId === "https://json-schema.org/keyword/anyOf" ? true : anyOf; + + const oneOf = schemaContext.isOneOf ?? false; + context.isOneOf = keywordId === "https://json-schema.org/keyword/oneOf" ? true : oneOf; + + context.inIfCondition = keywordId === "https://json-schema.org/keyword/if" ? true : (schemaContext.inIfCondition ?? false); + + const isCombinator = keywordId === "https://json-schema.org/keyword/allOf" || keywordId === "https://json-schema.org/keyword/anyOf" || keywordId === "https://json-schema.org/keyword/oneOf"; + context.groupId = isCombinator ? this.nextGroupId++ : schemaContext.groupId; + + if (keywordId === "https://json-schema.org/keyword/allOf") { + const checkpoints = this.allOfCheckpoints.get(instance.pointer) ?? []; + checkpoints.push((this.alternatives.get(instance.pointer) ?? []).length); + this.allOfCheckpoints.set(instance.pointer, checkpoints); + } + } + + afterKeyword(node: Node, instance: JsonNode, context: CompletionContext, _valid: boolean, schemaContext: CompletionContext): void { + const [keywordId, , keywordValue] = node; + + 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)); + + if (missing.length === 1) { + const forbiddenProperties = this.forbiddenProperties.get(instance.pointer) ?? new Set(); + forbiddenProperties.add(missing[0]); + this.forbiddenProperties.set(instance.pointer, forbiddenProperties); + } + } + + if (keywordId === "https://json-schema.org/keyword/properties") { + 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 Map(); + for (const propertyName of keywordValue as string[]) { + if (!schemaContext.declaredProperties.has(propertyName)) { + schemaContext.declaredProperties.set(propertyName, { hasConst: false }); + } + } + } + + if (keywordId === "https://json-schema.org/keyword/additionalProperties") { + const [, schemaUri] = keywordValue as [unknown, string]; + schemaContext.additionalPropertiesInfo = resolveValueInfo(this.ast, schemaUri); + } + + if (keywordId === "https://json-schema.org/keyword/properties" || keywordId === "https://json-schema.org/keyword/additionalProperties" || keywordId === "https://json-schema.org/keyword/patternProperties") { + if (!this.acceptedProperties.has(instance.pointer)) { + this.acceptedProperties.set(instance.pointer, new Set()); + } + addAll(this.acceptedProperties.get(instance.pointer)!, context.passedProperties); + + schemaContext.rejectedProperties ??= new Set(); + addAll(schemaContext.rejectedProperties, context.failedProperties); + } + + if (keywordId === "https://json-schema.org/keyword/allOf") { + const checkpoints = this.allOfCheckpoints.get(instance.pointer); + const checkpoint = checkpoints?.pop() ?? 0; + + const bucket = this.alternatives.get(instance.pointer); + if (bucket && bucket.length > checkpoint) { + const branches = bucket.splice(checkpoint); + const mine = branches.filter((branch) => branch.groupId === context.groupId); + const other = branches.filter((branch) => branch.groupId !== context.groupId); + + bucket.push(...other); + if (mine.length > 0) { + bucket.push(collapseAllOfBranches(mine, schemaContext.groupId)); + } + } + } + } + + afterSchema(_schemaUri: string, instance: JsonNode, context: CompletionContext, valid: boolean): void { + const propertyName = propertyNameOf(instance.pointer); + if (propertyName !== undefined) { + const outcome = valid ? (context.passedProperties ??= new Set()) : (context.failedProperties ??= new Set()); + outcome.add(propertyName); + } + + const declaredProperties = context.declaredProperties ?? new Map(); + const rejectedProperties = context.rejectedProperties ?? new Set(); + const isAnyOf = context.isAnyOf ?? false; + const isOneOf = context.isOneOf ?? false; + + if (!context.inIfCondition && (declaredProperties.size > 0 || rejectedProperties.size > 0 || context.additionalPropertiesInfo)) { + const alternatives = this.alternatives.get(instance.pointer) ?? []; + alternatives.push({ + declaredProperties, + additionalPropertiesInfo: context.additionalPropertiesInfo, + rejectedProperties, + isAnyOf, + isOneOf, + groupId: context.groupId + }); + this.alternatives.set(instance.pointer, alternatives); + } + } + + getDeclaredProperties(instanceLocation: string): Set { + const alternatives = this.alternatives.get(instanceLocation) ?? []; + const acceptedProperties = this.acceptedProperties.get(instanceLocation) ?? new Set(); + + const propertyNames = new Set(); + for (const alternative of alternatives) { + const isContradicted = [...alternative.rejectedProperties].some((propertyName) => acceptedProperties.has(propertyName)); + if ((!alternative.isAnyOf && !alternative.isOneOf) || !isContradicted) { + 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(); + + let constraints: PropertyValueInfo | undefined; + let choices: PropertyValueInfo | undefined; + const oneOfInfos: PropertyValueInfo[] = []; + + for (const alternative of alternatives) { + const isContradicted = [...alternative.rejectedProperties].some((p) => acceptedProperties.has(p)); + if ((alternative.isAnyOf || alternative.isOneOf) && isContradicted) { + continue; + } + + const info = alternative.declaredProperties.get(propertyName) ?? alternative.additionalPropertiesInfo; + if (!info) { + continue; + } + + if (alternative.isAnyOf) { + choices = choices ? unionValueInfo(choices, info) : info; + } else if (alternative.isOneOf) { + oneOfInfos.push(info); + } else { + constraints = constraints ? intersectValueInfo(constraints, info) : info; + } + } + + if (oneOfInfos.length > 0) { + const oneOfResult = exactlyOneValueInfo(oneOfInfos); + choices = choices ? unionValueInfo(choices, oneOfResult) : oneOfResult; + } + + return constraints && choices ? intersectValueInfo(constraints, choices) : choices ?? constraints; + } +} + +const addAll = (target: Set, source?: Iterable) => { + for (const entry of source ?? []) { + target.add(entry); + } +}; + +const collapseAllOfBranches = (branches: Alternative[], groupId: number | undefined): Alternative => { + const declaredProperties = new Map(); + const rejectedProperties = new Set(); + let additionalPropertiesInfo: PropertyValueInfo | undefined; + + for (const branch of branches) { + addAll(rejectedProperties, branch.rejectedProperties); + for (const [propertyName, info] of branch.declaredProperties) { + const existing = declaredProperties.get(propertyName); + declaredProperties.set(propertyName, existing ? intersectValueInfo(existing, info) : info); + } + if (branch.additionalPropertiesInfo) { + additionalPropertiesInfo = additionalPropertiesInfo + ? intersectValueInfo(additionalPropertiesInfo, branch.additionalPropertiesInfo) + : branch.additionalPropertiesInfo; + } + } + + return { declaredProperties, additionalPropertiesInfo, rejectedProperties, isAnyOf: branches[0].isAnyOf, isOneOf: branches[0].isOneOf, groupId }; +}; + +const propertyNameOf = (instanceLocation: string) => { + if (instanceLocation === "") { + return undefined; + } + + const lastSegment = instanceLocation.slice(instanceLocation.lastIndexOf("/") + 1); + return lastSegment; +}; + +const resolveValueInfo = (ast: Record | undefined, schemaUri: string, visited: Set = new Set()): PropertyValueInfo => { + try { + const info: PropertyValueInfo = { hasConst: false }; + const node = ast?.[schemaUri]; + // `$ref` can point back into a schema we're already inside, so stop rather than recurse forever + if (!Array.isArray(node) || visited.has(schemaUri)) { + return info; + } + const path = new Set(visited).add(schemaUri); + + // Combinators nested inside a leaf schema are resolved here rather than by the evaluation hooks, which only see combinators at object lvl + const nestedInfos: PropertyValueInfo[] = []; + let thenInfo: PropertyValueInfo | undefined; + let elseInfo: PropertyValueInfo | undefined; + + 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; + } else if (keywordId === "https://json-schema.org/keyword/not") { + info.excluded = resolveExcludedValues(ast, keywordValue as string); + info.excludedTypes = resolveExcludedTypes(ast, keywordValue as string); + } else if (keywordId === "https://json-schema.org/keyword/allOf") { + const branches = (keywordValue as string[]).map((uri) => resolveValueInfo(ast, uri, path)); + if (branches.length > 0) { + nestedInfos.push(branches.reduce((a, b) => intersectValueInfo(a, b))); + } + } else if (keywordId === "https://json-schema.org/keyword/anyOf") { + const branches = (keywordValue as string[]).map((uri) => resolveValueInfo(ast, uri, path)); + if (branches.length > 0) { + nestedInfos.push(branches.reduce((a, b) => unionValueInfo(a, b))); + } + } else if (keywordId === "https://json-schema.org/keyword/oneOf") { + const branches = (keywordValue as string[]).map((uri) => resolveValueInfo(ast, uri, path)); + if (branches.length > 0) { + nestedInfos.push(exactlyOneValueInfo(branches)); + } + } else if (keywordId === "https://json-schema.org/keyword/ref") { + // A `$ref` in the schema being validated against, not one in the document being edited, only reaches $refs that appear in a property's own subschema + nestedInfos.push(resolveValueInfo(ast, keywordValue as string, path)); + } else if (keywordId === "https://json-schema.org/keyword/then") { + const [, thenUri] = keywordValue as string[]; + thenInfo = thenUri ? resolveValueInfo(ast, thenUri, path) : undefined; + } else if (keywordId === "https://json-schema.org/keyword/else") { + const [, elseUri] = keywordValue as string[]; + elseInfo = elseUri ? resolveValueInfo(ast, elseUri, path) : undefined; + } + } + + if (thenInfo ?? elseInfo) { + nestedInfos.push(unionValueInfo(thenInfo ?? { hasConst: false }, elseInfo ?? { hasConst: false })); + } + + if (info.excludedTypes && info.type) { + const excludedTypesSet = new Set(info.excludedTypes); + info.type = (Array.isArray(info.type) ? info.type : [info.type]).filter((t) => !excludedTypesSet.has(t)); + } + + if (info.type) { + const allowedTypes = new Set(Array.isArray(info.type) ? info.type : [info.type]); + if (info.enum) { + info.enum = info.enum.filter((value) => allowedTypes.has(jsonTypeOf(value))); + } + if (info.hasConst && !allowedTypes.has(jsonTypeOf(info.const))) { + info.hasConst = false; + info.const = undefined; + } + } + + if (info.excluded) { + const excludedKeys = new Set(info.excluded.map((value) => JSON.stringify(value))); + if (info.enum) { + info.enum = info.enum.filter((value) => !excludedKeys.has(JSON.stringify(value))); + } + if (info.hasConst && excludedKeys.has(JSON.stringify(info.const))) { + info.hasConst = false; + info.const = undefined; + } + } + + if (nestedInfos.length > 0) { + const combined = nestedInfos.reduce((a, b) => intersectValueInfo(a, b)); + return isUnconstrained(info) ? combined : intersectValueInfo(info, combined); + } + + return info; + } catch { + return { hasConst: false }; + } +}; + +const resolveExcludedValues = (ast: Record | undefined, schemaUri: string): unknown[] | undefined => { + const node = ast?.[schemaUri]; + if (!Array.isArray(node)) { + return undefined; + } + + const values: unknown[] = []; + for (const [keywordId, , keywordValue] of node as [string, unknown, unknown][]) { + if (keywordId === "https://json-schema.org/keyword/const") { + values.push(JSON.parse(keywordValue as string) as unknown); + } else if (keywordId === "https://json-schema.org/keyword/enum") { + values.push(...(keywordValue as string[]).map((v) => JSON.parse(v) as unknown)); + } + } + return values.length > 0 ? values : undefined; +}; + +const resolveExcludedTypes = (ast: Record | undefined, schemaUri: string): string[] | undefined => { + const node = ast?.[schemaUri]; + if (!Array.isArray(node)) { + return undefined; + } + + for (const [keywordId, , keywordValue] of node as [string, unknown, unknown][]) { + if (keywordId === "https://json-schema.org/keyword/type") { + const type = keywordValue as string | string[]; + return Array.isArray(type) ? type : [type]; + } + } + return undefined; +}; + +const intersectValueInfo = (a: PropertyValueInfo, b: PropertyValueInfo): PropertyValueInfo => { + let excludedTypes: string[] | undefined; + if (a.excludedTypes ?? b.excludedTypes) { + const seen = new Set(); + excludedTypes = []; + for (const t of [...(a.excludedTypes ?? []), ...(b.excludedTypes ?? [])]) { + if (!seen.has(t)) { + seen.add(t); + excludedTypes.push(t); + } + } + } + + let type = a.type !== undefined && b.type !== undefined + ? (JSON.stringify(a.type) === JSON.stringify(b.type) ? a.type : []) + : a.type ?? b.type; + + if (excludedTypes && type) { + const excludedTypesSet = new Set(excludedTypes); + type = (Array.isArray(type) ? type : [type]).filter((t) => !excludedTypesSet.has(t)); + } + + let enumValues: unknown[] | undefined; + if (a.enum && b.enum) { + const enumA = new Set(a.enum.map((v) => JSON.stringify(v))); + const enumB = new Set(b.enum.map((v) => JSON.stringify(v))); + enumValues = [...enumA.intersection(enumB)].map((v) => JSON.parse(v)); + } else { + enumValues = a.enum ?? b.enum; + } + + const bothHaveConst = a.hasConst && b.hasConst; + const constsMatch = bothHaveConst && JSON.stringify(a.const) === JSON.stringify(b.const); + let hasConst = bothHaveConst ? constsMatch : (a.hasConst || b.hasConst); + let constValue = bothHaveConst ? (constsMatch ? a.const : undefined) : (a.hasConst ? a.const : b.const); + + if (enumValues && type) { + const allowedTypes = new Set(Array.isArray(type) ? type : [type]); + enumValues = enumValues.filter((value) => allowedTypes.has(jsonTypeOf(value))); + } + + let excluded: unknown[] | undefined; + if (a.excluded ?? b.excluded) { + const seen = new Set(); + excluded = []; + for (const value of [...(a.excluded ?? []), ...(b.excluded ?? [])]) { + const key = JSON.stringify(value); + if (!seen.has(key)) { + seen.add(key); + excluded.push(value); + } + } + } + + if (excluded) { + const excludedKeys = new Set(excluded.map((value) => JSON.stringify(value))); + if (enumValues) { + enumValues = enumValues.filter((value) => !excludedKeys.has(JSON.stringify(value))); + } + if (hasConst && excludedKeys.has(JSON.stringify(constValue))) { + hasConst = false; + constValue = undefined; + } + } + + return { type, enum: enumValues, const: constValue, hasConst, excluded, excludedTypes, permitsAnyValue: (isOpen(a) && isOpen(b)) || undefined }; +}; + +const isOpen = (info: PropertyValueInfo): boolean => { + return info.permitsAnyValue === true || (!info.hasConst && info.enum === undefined); +}; + +const exactlyOneValueInfo = (infos: PropertyValueInfo[]): PropertyValueInfo => { + const unioned = infos.reduce((a, b) => unionValueInfo(a, b)); + + const valuesOf = (info: PropertyValueInfo) => info.hasConst ? [info.const] : info.enum; + const counts = new Map(); + for (const info of infos) { + for (const value of valuesOf(info) ?? []) { + const key = JSON.stringify(value); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + + const enumValues = unioned.enum?.filter((value) => counts.get(JSON.stringify(value)) === 1); + + return { ...unioned, enum: enumValues }; +}; + +const isUnconstrained = (info: PropertyValueInfo): boolean => { + return info.type === undefined && !info.hasConst && info.enum === undefined + && info.excluded === undefined && info.excludedTypes === undefined; +}; + +const unionValueInfo = (a: PropertyValueInfo, b: PropertyValueInfo): PropertyValueInfo => { + const aIsUnconstrained = isUnconstrained(a); + const bIsUnconstrained = isUnconstrained(b); + if (aIsUnconstrained && bIsUnconstrained) { + return { hasConst: false, permitsAnyValue: true }; + } + if (aIsUnconstrained) { + return { ...b, type: undefined, excluded: undefined, excludedTypes: undefined, permitsAnyValue: true }; + } + if (bIsUnconstrained) { + return { ...a, type: undefined, excluded: undefined, excludedTypes: undefined, permitsAnyValue: true }; + } + + let type: string | string[] | undefined; + let namesTypeAndValues = false; + if (a.type !== undefined && b.type !== undefined) { + const types = new Set([ + ...(Array.isArray(a.type) ? a.type : [a.type]), + ...(Array.isArray(b.type) ? b.type : [b.type]) + ]); + type = types.size === 1 ? [...types][0] : [...types]; + } else if (a.type ?? b.type) { + const typeSide = a.type !== undefined ? a : b; + if (!typeSide.hasConst && typeSide.enum === undefined) { + type = typeSide.type; + namesTypeAndValues = true; + } + } + + let excludedTypes: string[] | undefined; + if (a.excludedTypes && b.excludedTypes) { + const bSet = new Set(b.excludedTypes); + excludedTypes = a.excludedTypes.filter((t) => bSet.has(t)); + if (excludedTypes.length === 0) { + excludedTypes = undefined; + } + } + + if (excludedTypes && type) { + const excludedTypesSet = new Set(excludedTypes); + type = (Array.isArray(type) ? type : [type]).filter((t) => !excludedTypesSet.has(t)); + } + + const valuesOf = (info: PropertyValueInfo) => info.hasConst ? [info.const] : info.enum; + const aValues = valuesOf(a); + const bValues = valuesOf(b); + + let enumValues: unknown[] | undefined; + if (aValues && bValues) { + const seen = new Set(aValues.map((value) => JSON.stringify(value))); + enumValues = [...aValues]; + for (const value of bValues) { + const key = JSON.stringify(value); + if (!seen.has(key)) { + seen.add(key); + enumValues.push(value); + } + } + } else { + enumValues = aValues ?? bValues; + } + + let excluded: unknown[] | undefined; + if (a.excluded && b.excluded) { + const bKeys = new Set(b.excluded.map((value) => JSON.stringify(value))); + excluded = a.excluded.filter((value) => bKeys.has(JSON.stringify(value))); + if (excluded.length === 0) { + excluded = undefined; + } + } + + if (excluded && enumValues) { + const excludedKeys = new Set(excluded.map((value) => JSON.stringify(value))); + enumValues = enumValues.filter((value) => !excludedKeys.has(JSON.stringify(value))); + } + + return { type, enum: enumValues, hasConst: false, excluded, excludedTypes, permitsAnyValue: namesTypeAndValues || undefined }; +}; + +const jsonTypeOf = (value: unknown): string => { + if (value === null) { + return "null"; + } + if (Array.isArray(value)) { + return "array"; + } + const t = typeof value; + return t === "number" ? "number" : t; +}; diff --git a/language-server/src/services/MatchingSchemaCollector.ts b/language-server/src/services/MatchingSchemaCollector.ts deleted file mode 100644 index 5e1c389..0000000 --- a/language-server/src/services/MatchingSchemaCollector.ts +++ /dev/null @@ -1,202 +0,0 @@ -import * as Instance from "@hyperjump/json-schema/instance/experimental"; - -import type { EvaluationPlugin, ValidationContext } from "@hyperjump/json-schema/experimental"; -import type { JsonNode } from "@hyperjump/json-schema/instance/experimental"; -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?: Map; - passedProperties?: Set; - failedProperties?: Set; - rejectedProperties?: Set; - negated?: boolean; - isAlternative?: boolean; -}; - -type Alternative = { - declaredProperties: Map; - rejectedProperties: Set; - isAlternative: boolean; -}; - -export class MatchingSchemaCollector implements EvaluationPlugin { - private annotations: Map = new Map(); - 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 { - const [keywordId] = node; - const negated = schemaContext.negated ?? false; - context.negated = keywordId === "https://json-schema.org/keyword/not" ? !negated : negated; - - const alternative = schemaContext.isAlternative ?? false; - context.isAlternative = keywordId === "https://json-schema.org/keyword/anyOf" || keywordId === "https://json-schema.org/keyword/oneOf" - ? true - : alternative; - } - - afterKeyword(node: Node, instance: JsonNode, context: MatchingSchemaContext, _valid: boolean, schemaContext: MatchingSchemaContext, keyword: Keyword): void { - const [keywordId, , keywordValue] = node; - - if (keyword.annotation) { - schemaContext.pendingAnnotations ??= {}; - schemaContext.pendingAnnotations[keywordId] = keyword.annotation(keywordValue, instance, context); - } - - 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)); - - if (missing.length === 1) { - const forbiddenProperties = this.forbiddenProperties.get(instance.pointer) ?? new Set(); - forbiddenProperties.add(missing[0]); - this.forbiddenProperties.set(instance.pointer, forbiddenProperties); - } - } - - if (keywordId === "https://json-schema.org/keyword/properties") { - 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 Map(); - for (const propertyName of keywordValue as string[]) { - if (!schemaContext.declaredProperties.has(propertyName)) { - schemaContext.declaredProperties.set(propertyName, { hasConst: false }); - } - } - } - - if (keywordId === "https://json-schema.org/keyword/properties" || keywordId === "https://json-schema.org/keyword/additionalProperties" || keywordId === "https://json-schema.org/keyword/patternProperties") { - if (!this.acceptedProperties.has(instance.pointer)) { - this.acceptedProperties.set(instance.pointer, new Set()); - } - addAll(this.acceptedProperties.get(instance.pointer)!, context.passedProperties); - - schemaContext.rejectedProperties ??= new Set(); - addAll(schemaContext.rejectedProperties, context.failedProperties); - } - } - - afterSchema(_schemaUri: string, instance: JsonNode, context: MatchingSchemaContext, valid: boolean): void { - if (valid && context.pendingAnnotations) { - if (!this.annotations.has(instance.pointer)) { - this.annotations.set(instance.pointer, []); - } - - const existing = this.annotations.get(instance.pointer)!; - existing.push(context.pendingAnnotations); - } - - const propertyName = propertyNameOf(instance.pointer); - if (propertyName !== undefined) { - const outcome = valid ? (context.passedProperties ??= new Set()) : (context.failedProperties ??= new Set()); - outcome.add(propertyName); - } - - const declaredProperties = context.declaredProperties ?? new Map(); - const rejectedProperties = context.rejectedProperties ?? new Set(); - const isAlternative = context.isAlternative ?? false; - - if (declaredProperties.size > 0 || rejectedProperties.size > 0) { - const alternatives = this.alternatives.get(instance.pointer) ?? []; - alternatives.push({ declaredProperties, rejectedProperties, isAlternative }); - this.alternatives.set(instance.pointer, alternatives); - } - } - - getAnnotations(instanceLocation: string): Annotation[] { - return this.annotations.get(instanceLocation) ?? []; - } - - getDeclaredProperties(instanceLocation: string): Set { - const alternatives = this.alternatives.get(instanceLocation) ?? []; - const acceptedProperties = this.acceptedProperties.get(instanceLocation) ?? new Set(); - - const propertyNames = new Set(); - for (const alternative of alternatives) { - const isContradicted = [...alternative.rejectedProperties].some((propertyName) => acceptedProperties.has(propertyName)); - if (!alternative.isAlternative || !isContradicted) { - 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) => { - for (const entry of source ?? []) { - target.add(entry); - } -}; - -const propertyNameOf = (instanceLocation: string) => { - if (instanceLocation === "") { - return undefined; - } - - 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 }; - } -};