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 71% rename from language-server/src/features/Completion.test.ts rename to language-server/src/features/PropertyCompletion.test.ts index 6ed486c..16b31d1 100644 --- a/language-server/src/features/Completion.test.ts +++ b/language-server/src/features/PropertyCompletion.test.ts @@ -76,7 +76,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "name", kind: CompletionItemKind.Property } + { + label: "name", + kind: CompletionItemKind.Property, + filterText: `"name"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"name": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -120,9 +129,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "street", kind: CompletionItemKind.Property }, - { label: "city", kind: CompletionItemKind.Property }, - { label: "zipCode", kind: CompletionItemKind.Property } + { + label: "street", + kind: CompletionItemKind.Property, + filterText: `"street"`, + textEdit: { + range: { start: { line: 3, character: 8 }, end: { line: 3, character: 10 } }, + newText: `"street": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "city", + kind: CompletionItemKind.Property, + filterText: `"city"`, + textEdit: { + range: { start: { line: 3, character: 8 }, end: { line: 3, character: 10 } }, + newText: `"city": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "zipCode", + kind: CompletionItemKind.Property, + filterText: `"zipCode"`, + textEdit: { + range: { start: { line: 3, character: 8 }, end: { line: 3, character: 10 } }, + newText: `"zipCode": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -160,8 +196,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "age", kind: CompletionItemKind.Property }, - { label: "city", kind: CompletionItemKind.Property } + { + label: "age", + kind: CompletionItemKind.Property, + filterText: `"age"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"age": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "city", + kind: CompletionItemKind.Property, + filterText: `"city"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"city": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -209,9 +263,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -260,8 +341,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -309,9 +408,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -327,14 +453,14 @@ describe("Completions", () => { "type": "object", "anyOf": [ { - "properties": { + "properties": { "foo": { "type": "number" }, "bar": { "type": "string" } }, "required": ["foo"] }, { - "properties": { + "properties": { "foo": { "type": "string" }, "baz": { "type": "string" } }, @@ -360,7 +486,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -408,9 +543,36 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "bar", kind: CompletionItemKind.Property }, - { label: "baz", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -459,7 +621,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -496,7 +667,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -545,7 +725,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 4, character: 6 }, end: { line: 4, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -596,7 +785,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "baz", kind: CompletionItemKind.Property } + { + label: "baz", + kind: CompletionItemKind.Property, + filterText: `"baz"`, + textEdit: { + range: { start: { line: 4, character: 6 }, end: { line: 4, character: 8 } }, + newText: `"baz": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -643,8 +841,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "b", + kind: CompletionItemKind.Property, + filterText: `"b"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"b": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -696,8 +912,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property }, - { label: "c", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "c", + kind: CompletionItemKind.Property, + filterText: `"c"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"c": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -784,7 +1018,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "name", kind: CompletionItemKind.Property } + { + label: "name", + kind: CompletionItemKind.Property, + filterText: `"name"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"name": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -835,7 +1078,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -886,8 +1138,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "b", + kind: CompletionItemKind.Property, + filterText: `"b"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"b": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -927,8 +1197,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "foo", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -961,7 +1249,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -980,7 +1277,7 @@ describe("Completions", () => { }, "not": { "not": { - "required": ["bar"] + "required": ["bar"] } } }`); @@ -1001,8 +1298,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "bar", kind: CompletionItemKind.Property }, - { label: "foo", kind: CompletionItemKind.Property } + { + label: "bar", + kind: CompletionItemKind.Property, + filterText: `"bar"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"bar": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -1038,7 +1353,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "foo", kind: CompletionItemKind.Property } + { + label: "foo", + kind: CompletionItemKind.Property, + filterText: `"foo"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"foo": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -1075,8 +1399,26 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "a", kind: CompletionItemKind.Property }, - { label: "b", kind: CompletionItemKind.Property } + { + label: "a", + kind: CompletionItemKind.Property, + filterText: `"a"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"a": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }, + { + label: "b", + kind: CompletionItemKind.Property, + filterText: `"b"`, + textEdit: { + range: { start: { line: 2, character: 6 }, end: { line: 2, character: 8 } }, + newText: `"b": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); @@ -1244,7 +1586,16 @@ describe("Completions", () => { }); expect(completions).toEqual([ - { label: "c", kind: CompletionItemKind.Property } + { + label: "c", + kind: CompletionItemKind.Property, + filterText: `"c"`, + textEdit: { + range: { start: { line: 3, character: 6 }, end: { line: 3, character: 8 } }, + newText: `"c": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + } ]); }); }); diff --git a/language-server/src/features/PropertyCompletion.ts b/language-server/src/features/PropertyCompletion.ts new file mode 100644 index 0000000..c7060f9 --- /dev/null +++ b/language-server/src/features/PropertyCompletion.ts @@ -0,0 +1,45 @@ +import { CompletionItemKind } from "vscode-languageserver"; + +import type { JsonDocument } from "../models/JsonDocument.ts"; +import type { CompletionsProvider } from "./Completion.ts"; +import type { CompletionItem, Position } from "vscode-languageserver"; + +export class PropertyCompletion implements CompletionsProvider { + async getCompletions(jsonDocument: JsonDocument, position: Position) { + const keyNode = jsonDocument.findNodeAtPosition(position)!; + const propertyNode = keyNode.parent; + + if (propertyNode?.type !== "property" || propertyNode.children![0] !== keyNode) { + return []; + } + + const objectNode = propertyNode.parent!; + + const propertyNames = await jsonDocument.getDeclaredProperties(objectNode); + for (const node of objectNode.children!) { + if (node === propertyNode) { + continue; + } + + propertyNames.delete(node.children![0].value); + } + + const completionItems: CompletionItem[] = []; + for (const propertyName of propertyNames) { + completionItems.push({ + label: propertyName, + kind: CompletionItemKind.Property, + filterText: JSON.stringify(propertyName), + textEdit: { + range: { + start: jsonDocument.positionAt(keyNode.offset), + end: jsonDocument.positionAt(keyNode.offset + keyNode.length) + }, + newText: `"${propertyName}": ` + }, + command: { title: "Suggest", command: "editor.action.triggerSuggest" } + }); + } + return completionItems; + } +} diff --git a/language-server/src/features/ValueCompletion.test.ts b/language-server/src/features/ValueCompletion.test.ts new file mode 100644 index 0000000..f77a4cf --- /dev/null +++ b/language-server/src/features/ValueCompletion.test.ts @@ -0,0 +1,2734 @@ +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(); + }); + + // 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, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "name": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `""`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: ` "$1"` + } + } + ]); + }); + + test("Value completion : completion should return cursor inside {} for object", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "object" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "name": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `{}`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: ` {$0}` + } + } + ]); + }); + + test("Value completion : completion should return cursor inside [] for array", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "array" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "name": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: `[]`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: ` [$0]` + } + } + ]); + }); + + test("Value completion : completion should return true & false for type Boolean", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "test": { "type": "boolean" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "test": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 13 } + }); + + expect(completions).toEqual([ + { + label: "true", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: " true" + } + }, + { + label: "false", + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 13 }, end: { line: 2, character: 13 } }, + newText: " false" + } + } + ]); + }); + + test("Value completion: selecting a property with const shows that const value", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": { "const": "foo" } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "value": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 15 } + }); + + expect(completions).toEqual([ + { + label: `"foo"`, + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 15 } }, + newText: ` "foo"` + } + } + ]); + }); + + test("Value completion: shows enum suggestion for a property", async () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { + resolve(); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "color": { "enum": ["red", null , 42] } + } + }`); + + const instanceText = `{ + "$schema": "${fixtureSchemaUri}", + "color": + }`; + + await client.writeDocument("instance.json", instanceText); + const uri = await client.openDocument("instance.json"); + + await diagnostics; + + const completions = await client.sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position: { line: 2, character: 16 } + }); + + expect(completions).toEqual([ + { + label: `"red"`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` "red"` + } + }, + { + label: `null`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` null` + } + }, + { + label: `42`, + kind: CompletionItemKind.EnumMember, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { + range: { start: { line: 2, character: 14 }, end: { line: 2, character: 16 } }, + newText: ` 42` + } + } + ]); + }); + + // 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 new file mode 100644 index 0000000..5554aee --- /dev/null +++ b/language-server/src/features/ValueCompletion.ts @@ -0,0 +1,118 @@ +import { CompletionItemKind, InsertTextFormat } from "vscode-languageserver"; + +import type { CompletionItem, Position } from "vscode-languageserver"; +import type { JsonDocument } from "../models/JsonDocument.ts"; +import type { CompletionsProvider } from "./Completion.ts"; + +export class ValueCompletion implements CompletionsProvider { + async getCompletions(jsonDocument: JsonDocument, position: Position): Promise { + const node = jsonDocument.findNodeAtPosition(position)!; + + if (node.type !== "property" || node.colonOffset === undefined) { + return []; + } + + const offset = jsonDocument.offsetAt(position); + if (offset <= node.colonOffset!) { + return []; + } + + const propertyName = node.children![0].value as string; + const objectNode = node.parent!; + + const valueInfo = await jsonDocument.getPropertyValueInfo(objectNode, propertyName); + if (!valueInfo) { + return []; + } + + const range = { + start: jsonDocument.positionAt(node.colonOffset! + 1), + end: position + }; + + const completionItems: CompletionItem[] = []; + + if (valueInfo.hasConst) { + completionItems.push({ + label: JSON.stringify(valueInfo.const), + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " " + JSON.stringify(valueInfo.const) } + }); + } 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)); + } + + 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") { + 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; + } + + completionItems.push({ + label: valueLabel(type), + kind: CompletionItemKind.Value, + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range, newText: " " + valuePlaceholder(type, 1) } + }); + } + return completionItems; + } +} + +const valuePlaceholder = (type: string, tabIndex: number): string => { + switch (type) { + case "string": return `"$${tabIndex}"`; + case "object": return "{$0}"; + case "array": return "[$0]"; + case "null": return "null"; + case "number": + case "integer": return "$0"; + 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..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.parse(this.getText()); - return this.schemaStore.validate(schemaUri, instance, this.uri, [this.matchingSchemaCollector]); + const instance = jsonc.getNodeValue(this.ast!); + return this.schemaStore.validate(schemaUri, instance, this.uri, [ + this.annotationEvaluationPlugin, + this.completionEvaluationPlugin + ]); }); } @@ -165,14 +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.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 879a2ef..0000000 --- a/language-server/src/services/MatchingSchemaCollector.ts +++ /dev/null @@ -1,153 +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 MatchingSchemaContext = ValidationContext & { - pendingAnnotations?: Annotation; - declaredProperties?: Set; - passedProperties?: Set; - failedProperties?: Set; - rejectedProperties?: Set; - negated?: boolean; - isAlternative?: boolean; -}; - -type Alternative = { - declaredProperties: Set; - 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(); - - beforeSchema(_url: string, _instance: JsonNode, context: MatchingSchemaContext): void { - context.pendingAnnotations = {}; - context.declaredProperties = undefined; - context.rejectedProperties = undefined; - } - - 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 Set(); - for (const propertyName in keywordValue as Record) { - schemaContext.declaredProperties.add(propertyName); - } - } - - if (keywordId === "https://json-schema.org/keyword/required") { - schemaContext.declaredProperties ??= new Set(); - for (const propertyName of keywordValue as string[]) { - schemaContext.declaredProperties.add(propertyName); - } - } - - 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 Set(); - 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); - } - } - - const forbiddenProperties = this.forbiddenProperties.get(instanceLocation); - return forbiddenProperties ? propertyNames.difference(forbiddenProperties) : propertyNames; - } -} - -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; -};