diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 84ca587..3225607 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -1,14 +1,14 @@ +import { Server } from "./services/server.ts"; +import { JsonDocuments } from "./services/JsonDocuments.ts"; +import { Diagnostics } from "./features/Diagnostics.ts"; +import { SyntaxValidation } from "./features/SyntaxValidation.ts"; +import { SchemaValidation } from "./features/SchemaValidation.ts"; + import "@hyperjump/json-schema/draft-2020-12"; import "@hyperjump/json-schema/draft-2019-09"; import "@hyperjump/json-schema/draft-07"; import "@hyperjump/json-schema/draft-06"; import "@hyperjump/json-schema/draft-04"; -import { TextDocuments } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { Server } from "./services/server.ts"; -import { Diagnostics } from "./features/Diagnostics.ts"; -import { SyntaxValidation } from "./features/SyntaxValidation.ts"; -import { SchemaValidation } from "./features/SchemaValidation.ts"; import type { Connection } from "vscode-languageserver"; @@ -18,7 +18,7 @@ export type LanguageServerSettings = { export const buildServer = (connection: Connection): Connection => { const server = new Server(connection); - const documents = new TextDocuments(TextDocument); + const documents = new JsonDocuments(server); documents.listen(server); new Diagnostics(server, documents, [ diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 36e89b1..54cbaeb 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -1,29 +1,19 @@ -import { TextDocuments, TextDocumentSyncKind } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; import { Server } from "../services/server.ts"; +import { JsonDocuments } from "../services/JsonDocuments.ts"; +import { JsonDocument } from "../models/JsonDocument.ts"; -import type { ServerCapabilities, Diagnostic } from "vscode-languageserver"; +import type { Diagnostic } from "vscode-languageserver"; export type DiagnosticsProvider = { - getDiagnostics(textDocument: TextDocument): Promise; + getDiagnostics(jsonDocument: JsonDocument): Promise; }; export class Diagnostics { private providers: DiagnosticsProvider[]; - constructor(server: Server, documents: TextDocuments, providers: DiagnosticsProvider[]) { + constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) { this.providers = providers; - server.onInitialize(() => { - const serverCapabilities: ServerCapabilities = { - textDocumentSync: TextDocumentSyncKind.Incremental - }; - - return { - capabilities: serverCapabilities - }; - }); - documents.onDidChangeContent(async (change) => { const diagnostics = []; for (const provider of this.providers) { diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 79e845b..0bdfb80 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; import { TestClient } from "../test/test-client.ts"; -import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; -import { Diagnostic, PublishDiagnosticsParams } from "vscode-languageserver"; +import { unregisterSchema } from "@hyperjump/json-schema"; + +import type { Diagnostic, PublishDiagnosticsParams } from "vscode-languageserver"; describe("Schema Validation", () => { let client: TestClient; - const fixtureSchemaUri = "https://example.com/person"; + let fixtureSchemaUri: string; beforeAll(async () => { client = new TestClient(); @@ -27,16 +28,14 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -56,16 +55,14 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -85,6 +82,33 @@ describe("Schema Validation", () => { ); }); + test("schema validation is skipped if the JSON is invalid", async () => { + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + } + }`); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "name" 42, + "age" : "not a number" + }`); + await client.openDocument("instance.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toHaveLength(1); + }); + test("JSON Validation using Hyperjump - anyOf Formatting Case", async () => { const diagnosticsPromise = new Promise((resolve) => { client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { @@ -92,20 +116,18 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - value: { - anyOf: [ - { type: "string" }, - { type: "number" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "type": "number" } ] } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -126,24 +148,22 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - value: { - oneOf: [ - { type: "string" }, - { type: "number" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": { + "oneOf": [ + { "type": "string" }, + { "type": "number" } ] } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ - "$schema": "${fixtureSchemaUri}", - "value": true + "$schema": "${fixtureSchemaUri}", + "value": true }`); await client.openDocument("instance.json"); @@ -160,15 +180,13 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - "foo/bar": { type: "string" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "foo/bar": { "type": "string" } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -189,15 +207,13 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - 0: { type: "string" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "0": { "type": "string" } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -217,15 +233,13 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - "foo bar": { type: "string" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "foo bar": { "type": "string" } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -245,18 +259,16 @@ describe("Schema Validation", () => { }); }); - const testSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - 42: { - type: "array", - items: { type: "number" } + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "42": { + "type": "array", + "items": { "type": "number" } } } - }; - - registerSchema(testSchema, fixtureSchemaUri); + }`); await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", @@ -268,4 +280,46 @@ describe("Schema Validation", () => { expect(diagnostics).toHaveLength(1); expect((diagnostics[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a number"); }); + + test("after fixing schema validation errors, it should not return a diagnostic", async () => { + const diagnosticsPromise1 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + + fixtureSchemaUri = await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + } + }`); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": "Alice", + "age" : "not a number" + }`); + await client.openDocument("instance.json"); + + const diagnostics1 = await diagnosticsPromise1; + expect(diagnostics1).toHaveLength(1); + + const diagnosticsPromise2 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + await client.changeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": "Alice", + "age" : 39 + }`); + + const diagnostics2 = await diagnosticsPromise2; + expect(diagnostics2).toHaveLength(0); + }); }); diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts index 1520968..329158a 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -1,45 +1,44 @@ import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import * as jsonc from "jsonc-parser"; import { validate } from "@hyperjump/json-schema-errors"; -import { pointerSegments } from "@hyperjump/json-pointer"; +import { JsonDocument } from "../models/JsonDocument.ts"; import type { ErrorObject } from "@hyperjump/json-schema-errors"; import type { DiagnosticsProvider } from "./Diagnostics.ts"; export class SchemaValidation implements DiagnosticsProvider { - async getDiagnostics(textDocument: TextDocument) { - const text = textDocument.getText(); - const parseErrors: jsonc.ParseError[] = []; - - const tree = jsonc.parseTree(text, parseErrors); + async getDiagnostics(jsonDocument: JsonDocument) { const schemaDiagnostics: Diagnostic[] = []; - const schemaNode = tree ? jsonc.findNodeAtLocation(tree, ["$schema"]) : undefined; + const schemaNode = jsonDocument.findNodeAtPointer("/$schema"); const schemaUri = schemaNode?.value; // skip schema validation if there are syntax errors hence the parseError.length check - if (schemaUri && parseErrors.length === 0) { - let instance = JSON.parse(text); - const result = await validate(schemaUri, instance); - - if (!result.valid) { - const errors = result.errors; - errors.forEach((error) => { - const node = tree ? findNodeByPointer(tree, error.instanceLocation) : undefined; - - if (node) { - schemaDiagnostics.push({ - severity: DiagnosticSeverity.Error, - range: { - start: textDocument.positionAt(node.offset), - end: textDocument.positionAt(node.offset + node.length) - }, - message: formatError(error), - source: "hyperjump-json-language-server" - }); - } - }); + if (schemaUri && jsonDocument.getParseErrors().length === 0) { + let instance = JSON.parse(jsonDocument.getText()); + try { + const result = await validate(schemaUri, instance); + + if (!result.valid) { + const errors = result.errors; + errors.forEach((error) => { + const pointer = decodeURIComponent(error.instanceLocation.slice(1)); + const node = jsonDocument.findNodeAtPointer(pointer); + + if (node) { + schemaDiagnostics.push({ + severity: DiagnosticSeverity.Error, + range: { + start: jsonDocument.positionAt(node.offset), + end: jsonDocument.positionAt(node.offset + node.length) + }, + message: formatError(error), + source: "hyperjump-json-language-server" + }); + } + }); + } + } catch (error: unknown) { + // TODO: Handle invalid or missing schema errors } } @@ -47,20 +46,6 @@ export class SchemaValidation implements DiagnosticsProvider { } } -const findNodeByPointer = (node: jsonc.Node, pointer: string) => { - if (pointer === "#") { - return node; - } - - pointer = decodeURIComponent(pointer.slice(1)); - for (let segment of pointerSegments(pointer)) { - const key = node.type === "array" ? parseInt(segment) : segment; - node = jsonc.findNodeAtLocation(node, [key]) ?? node; - } - - return node; -}; - const formatError = (error: ErrorObject, depth = 0): string => { let message = error.message; if (error.alternatives && error.alternatives.length > 0) { diff --git a/language-server/src/features/SyntaxValidation.test.ts b/language-server/src/features/SyntaxValidation.test.ts index f8c649d..ade4a95 100644 --- a/language-server/src/features/SyntaxValidation.test.ts +++ b/language-server/src/features/SyntaxValidation.test.ts @@ -40,4 +40,29 @@ describe("Syntax Validation", () => { const diagnostics = await diagnosticsPromise; expect(diagnostics).toHaveLength(0); }); + + test("after fixing invalid JSON Syntax, it should not return a diagnostic", async () => { + const diagnosticsPromise1 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + await client.writeDocument("test.json", `{ "name": }`); + await client.openDocument("test.json"); + + const diagnostics1 = await diagnosticsPromise1; + expect(diagnostics1).toHaveLength(1); + + const diagnosticsPromise2 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + await client.changeDocument("test.json", `{ "Name": "Foo" }`); + + const diagnostics2 = await diagnosticsPromise2; + expect(diagnostics2).toHaveLength(0); + }); }); diff --git a/language-server/src/features/SyntaxValidation.ts b/language-server/src/features/SyntaxValidation.ts index c0a8cd4..1dffe8e 100644 --- a/language-server/src/features/SyntaxValidation.ts +++ b/language-server/src/features/SyntaxValidation.ts @@ -1,21 +1,16 @@ import { DiagnosticSeverity } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; import * as jsonc from "jsonc-parser"; import type { DiagnosticsProvider } from "./Diagnostics.ts"; +import { JsonDocument } from "../models/JsonDocument.ts"; export class SyntaxValidation implements DiagnosticsProvider { - async getDiagnostics(textDocument: TextDocument) { - const text = textDocument.getText(); - const parseErrors: jsonc.ParseError[] = []; - - jsonc.parseTree(text, parseErrors); - - return parseErrors.map((error) => ({ + async getDiagnostics(jsonDocument: JsonDocument) { + return jsonDocument.getParseErrors().map((error) => ({ severity: DiagnosticSeverity.Error, range: { - start: textDocument.positionAt(error.offset), - end: textDocument.positionAt(error.offset + error.length) + start: jsonDocument.positionAt(error.offset), + end: jsonDocument.positionAt(error.offset + error.length) }, message: jsonc.printParseErrorCode(error.error), source: "hyperjump-json-language-server" diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts new file mode 100644 index 0000000..070b670 --- /dev/null +++ b/language-server/src/models/JsonDocument.ts @@ -0,0 +1,77 @@ +import { TextDocumentContentChangeEvent } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import * as jsonc from "jsonc-parser"; +import { pointerSegments } from "@hyperjump/json-pointer"; + +import type { Position, Range } from "vscode-languageserver-textdocument"; + +export class JsonDocument implements TextDocument { + private textDocument: TextDocument; + private ast: jsonc.Node | undefined; + private parseErrors: jsonc.ParseError[] = []; + + constructor(textDocument: TextDocument) { + this.textDocument = textDocument; + + this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors); + } + + get uri() { + return this.textDocument.uri; + } + + get languageId() { + return this.textDocument.languageId; + } + + get version() { + return this.textDocument.version; + } + + get lineCount() { + return this.textDocument.lineCount; + } + + getText(range?: Range): string { + return this.textDocument.getText(range); + } + + positionAt(offset: number): Position { + return this.textDocument.positionAt(offset); + } + + offsetAt(position: Position): number { + return this.textDocument.offsetAt(position); + } + + update(changes: TextDocumentContentChangeEvent[], version: number): void { + TextDocument.update(this.textDocument, changes, version); + this.parseErrors = []; + this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors); + } + + getParseErrors() { + return this.parseErrors; + } + + findNodeAtPointer(pointer: string) { + if (!this.ast) { + return; + } + + return findNodeByPointer(this.ast, pointer); + } +} + +const findNodeByPointer = (node: jsonc.Node, pointer: string) => { + if (pointer === "") { + return node; + } + + for (let segment of pointerSegments(pointer)) { + const key = node.type === "array" ? parseInt(segment) : segment; + node = jsonc.findNodeAtLocation(node, [key]) ?? node; + } + + return node; +}; diff --git a/language-server/src/language-server.test.ts b/language-server/src/services/JsonDocuments.test.ts similarity index 83% rename from language-server/src/language-server.test.ts rename to language-server/src/services/JsonDocuments.test.ts index af3975a..f33fcf1 100644 --- a/language-server/src/language-server.test.ts +++ b/language-server/src/services/JsonDocuments.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; import { TextDocumentSyncKind } from "vscode-languageserver"; -import { TestClient } from "./test/test-client.ts"; +import { TestClient } from "../test/test-client.ts"; -describe("JSON Language Server", () => { +describe("JsonDocuments", () => { let client: TestClient; beforeAll(async () => { diff --git a/language-server/src/services/JsonDocuments.ts b/language-server/src/services/JsonDocuments.ts new file mode 100644 index 0000000..aef9d4a --- /dev/null +++ b/language-server/src/services/JsonDocuments.ts @@ -0,0 +1,31 @@ +import { TextDocuments, TextDocumentSyncKind } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { JsonDocument } from "../models/JsonDocument.ts"; +import { Server } from "./server.ts"; + +import type { DocumentUri, ServerCapabilities, TextDocumentContentChangeEvent } from "vscode-languageserver"; + +export class JsonDocuments extends TextDocuments { + constructor(server: Server) { + super({ + create(uri: DocumentUri, languageId: string, version: number, content: string) { + const textDocument = TextDocument.create(uri, languageId, version, content); + return new JsonDocument(textDocument); + }, + update(document: JsonDocument, changes: TextDocumentContentChangeEvent[], version: number) { + document.update(changes, version); + return document; + } + }); + + server.onInitialize(() => { + const serverCapabilities: ServerCapabilities = { + textDocumentSync: TextDocumentSyncKind.Incremental + }; + + return { + capabilities: serverCapabilities + }; + }); + } +} diff --git a/language-server/src/test/test-client.ts b/language-server/src/test/test-client.ts index 58cff9b..d3d5474 100644 --- a/language-server/src/test/test-client.ts +++ b/language-server/src/test/test-client.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { ConfigurationRequest, DidChangeConfigurationNotification, + DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, @@ -18,7 +19,6 @@ import { createConnection } from "vscode-languageserver/node"; import { URI, Utils } from "vscode-uri"; import { merge } from "merge-anything"; import { buildServer, LanguageServerSettings } from "../build-server.js"; -import { wait } from "./test-utils.ts"; import type { Connection, @@ -181,7 +181,7 @@ export class TestClient { await this.client.sendNotification(InitializedNotification.type, {}); // Wait for dynamic registrations to be completed - await wait(100); + // await wait(100); await this.changeConfiguration(); } @@ -213,14 +213,14 @@ export class TestClient { const fullUri = Utils.resolvePath(URI.parse(await this.workspaceFolder), uri); await writeFile(fullUri.fsPath, text, "utf-8"); - return fullUri; + return fullUri.toString(); } async deleteDocument(uri: string) { const fullUri = Utils.resolvePath(URI.parse(await this.workspaceFolder), uri); await rm(fileURLToPath(fullUri.fsPath)); - return fullUri; + return fullUri.toString(); } async openDocument(uri: string) { @@ -240,6 +240,19 @@ export class TestClient { return documentUri; } + async changeDocument(uri: string, text: string) { + const documentUri = Utils.resolvePath(URI.parse(await this.workspaceFolder), uri); + await writeFile(documentUri.fsPath, text, "utf-8"); + + await this.client.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { + uri: documentUri.toString(), + version: 1 + }, + contentChanges: [{ text }] + }); + } + async closeDocument(uri: string) { this.openDocuments.delete(uri);