diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 3225607..d5950fe 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -1,5 +1,6 @@ import { Server } from "./services/server.ts"; import { JsonDocuments } from "./services/JsonDocuments.ts"; +import { SchemaStore } from "./services/SchemaStore.ts"; import { Diagnostics } from "./features/Diagnostics.ts"; import { SyntaxValidation } from "./features/SyntaxValidation.ts"; import { SchemaValidation } from "./features/SchemaValidation.ts"; @@ -17,14 +18,38 @@ export type LanguageServerSettings = { export const buildServer = (connection: Connection): Connection => { const server = new Server(connection); + const schemaStore = new SchemaStore(server); - const documents = new JsonDocuments(server); + const documents = new JsonDocuments(server, schemaStore); documents.listen(server); - new Diagnostics(server, documents, [ + const diagnostics = new Diagnostics(server, documents, [ new SyntaxValidation(), new SchemaValidation() ]); + server.onDidChangeWatchedFiles(async (params) => { + for (const change of params.changes) { + schemaStore.clear(change.uri); + } + + for (const document of documents.all()) { + document.revalidate(); + await diagnostics.sendDiagnostics(document); + } + }); + + documents.onDidChangeContent(async (change) => { + const changedUri = change.document.uri; + schemaStore.clear(changedUri); + + for (const document of documents.all()) { + if (document.uri !== changedUri && document.getSchemaUri() === changedUri) { + document.revalidate(); + await diagnostics.sendDiagnostics(document); + } + } + }); + return server; }; diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 54cbaeb..e5e16e8 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -9,21 +9,27 @@ export type DiagnosticsProvider = { }; export class Diagnostics { + private server: Server; private providers: DiagnosticsProvider[]; constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) { + this.server = server; this.providers = providers; documents.onDidChangeContent(async (change) => { - const diagnostics = []; - for (const provider of this.providers) { - diagnostics.push(...await provider.getDiagnostics(change.document)); - } + await this.sendDiagnostics(change.document); + }); + } + + async sendDiagnostics(document: JsonDocument) { + const diagnostics = []; + for (const provider of this.providers) { + diagnostics.push(...await provider.getDiagnostics(document)); + } - await server.sendDiagnostics({ - uri: change.document.uri, - diagnostics: diagnostics - }); + await this.server.sendDiagnostics({ + uri: document.uri, + diagnostics: diagnostics }); } } diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 0bdfb80..b76b982 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { describe, test, expect, afterEach, beforeEach } from "vitest"; import { TestClient } from "../test/test-client.ts"; import { unregisterSchema } from "@hyperjump/json-schema"; @@ -8,12 +8,12 @@ describe("Schema Validation", () => { let client: TestClient; let fixtureSchemaUri: string; - beforeAll(async () => { + beforeEach(async () => { client = new TestClient(); await client.start(); }); - afterAll(async () => { + afterEach(async () => { await client.stop(); }); @@ -322,4 +322,51 @@ describe("Schema Validation", () => { const diagnostics2 = await diagnosticsPromise2; expect(diagnostics2).toHaveLength(0); }); + + test("changing the schema should invalidate the cache", 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" + }`); + const instanceUri = await client.openDocument("instance.json"); + + const diagnostics1 = await diagnosticsPromise1; + expect(diagnostics1).toHaveLength(1); + + const diagnosticsPromise2 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); + }); + + await client.writeDocument("schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "string" } + } + }`); + + 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 899f068..d386a62 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -1,5 +1,4 @@ import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver"; -import { validate } from "@hyperjump/json-schema-errors"; import { JsonDocument } from "../models/JsonDocument.ts"; import type { ErrorObject } from "@hyperjump/json-schema-errors"; @@ -9,39 +8,27 @@ export class SchemaValidation implements DiagnosticsProvider { async getDiagnostics(jsonDocument: JsonDocument) { const schemaDiagnostics: Diagnostic[] = []; - const schemaNode = jsonDocument.findNodeAtPointer("/$schema"); - const schemaUri = schemaNode?.value; - - // skip schema validation if there are syntax errors hence the parseError.length check - 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" - }); - } + const result = await jsonDocument.getSchemaErrors(); + + if (result?.valid === false) { + 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 - } + }); } - return schemaDiagnostics; } } diff --git a/language-server/src/features/SyntaxValidation.test.ts b/language-server/src/features/SyntaxValidation.test.ts index ade4a95..78ebb03 100644 --- a/language-server/src/features/SyntaxValidation.test.ts +++ b/language-server/src/features/SyntaxValidation.test.ts @@ -1,15 +1,15 @@ -import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { TestClient } from "../test/test-client.ts"; describe("Syntax Validation", () => { let client: TestClient; - beforeAll(async () => { + beforeEach(async () => { client = new TestClient(); await client.start(); }); - afterAll(async () => { + afterEach(async () => { await client.stop(); }); diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 070b670..863634d 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -2,18 +2,42 @@ import { TextDocumentContentChangeEvent } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import * as jsonc from "jsonc-parser"; import { pointerSegments } from "@hyperjump/json-pointer"; +import { SchemaStore } from "../services/SchemaStore.ts"; import type { Position, Range } from "vscode-languageserver-textdocument"; +import type { ValidationResult } from "@hyperjump/json-schema-errors"; export class JsonDocument implements TextDocument { private textDocument: TextDocument; + private schemaStore: SchemaStore; private ast: jsonc.Node | undefined; private parseErrors: jsonc.ParseError[] = []; + private schemaErrors: Promise | undefined; - constructor(textDocument: TextDocument) { + constructor(textDocument: TextDocument, schemaStore: SchemaStore) { this.textDocument = textDocument; + this.schemaStore = schemaStore; + this.validate(); + } + + private validate() { + this.parseErrors = []; this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors); + + if (this.parseErrors.length > 0) { + return; + } + + const schemaNode = this.findNodeAtPointer("/$schema"); + const schemaUri = schemaNode?.value; + + if (schemaUri === undefined) { + return; + } + + let instance = JSON.parse(this.getText()); + this.schemaErrors = this.schemaStore.validate(schemaUri, instance); } get uri() { @@ -46,14 +70,26 @@ export class JsonDocument implements TextDocument { update(changes: TextDocumentContentChangeEvent[], version: number): void { TextDocument.update(this.textDocument, changes, version); - this.parseErrors = []; - this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors); + this.validate(); + } + + revalidate() { + this.validate(); } getParseErrors() { return this.parseErrors; } + getSchemaErrors() { + return this.schemaErrors; + } + + getSchemaUri() { + const schemaNode = this.findNodeAtPointer("/$schema"); + return schemaNode?.value as string | undefined; + } + findNodeAtPointer(pointer: string) { if (!this.ast) { return; diff --git a/language-server/src/services/JsonDocuments.ts b/language-server/src/services/JsonDocuments.ts index ce5da40..7add26c 100644 --- a/language-server/src/services/JsonDocuments.ts +++ b/language-server/src/services/JsonDocuments.ts @@ -4,16 +4,17 @@ import { JsonDocument } from "../models/JsonDocument.ts"; import { Server } from "./server.ts"; import type { DocumentUri, ServerCapabilities, TextDocumentContentChangeEvent } from "vscode-languageserver"; +import type { SchemaStore } from "./SchemaStore.ts"; export class JsonDocuments extends TextDocuments { private server: Server; private hasWorkspaceWatchCapability: boolean = false; - constructor(server: Server) { + constructor(server: Server, schemaStore: SchemaStore) { super({ create(uri: DocumentUri, languageId: string, version: number, content: string) { const textDocument = TextDocument.create(uri, languageId, version, content); - return new JsonDocument(textDocument); + return new JsonDocument(textDocument, schemaStore); }, update(document: JsonDocument, changes: TextDocumentContentChangeEvent[], version: number) { document.update(changes, version); diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts new file mode 100644 index 0000000..951932d --- /dev/null +++ b/language-server/src/services/SchemaStore.ts @@ -0,0 +1,36 @@ +import { validate } from "@hyperjump/json-schema-errors"; +import { unregisterSchema } from "@hyperjump/json-schema"; + +import type { EvaluateInstance, Json } from "@hyperjump/json-schema-errors"; +import type { Server } from "../services/server.ts"; + +export class SchemaStore { + private validatorCache: Map = new Map(); + + constructor(server: Server) { + server.onExit(() => { + this.clearAll(); + }); + } + + async validate(schemaUri: string, instance: Json) { + if (!this.validatorCache.has(schemaUri)) { + this.validatorCache.set(schemaUri, await validate(schemaUri)); + } + const validator = this.validatorCache.get(schemaUri)!; + + return validator(instance); + } + + clearAll() { + for (const schemaUri of this.validatorCache.keys()) { + this.clear(schemaUri); + } + } + + clear(schemaUri: string) { + unregisterSchema(schemaUri); + this.validatorCache.delete(schemaUri); + // TODO: Unregister schemas under $id and id + } +} diff --git a/language-server/src/test/test-client.ts b/language-server/src/test/test-client.ts index 6505bb8..43757a3 100644 --- a/language-server/src/test/test-client.ts +++ b/language-server/src/test/test-client.ts @@ -256,7 +256,7 @@ export class TestClient { this.openDocuments.add(documentUri.toString()); - return documentUri; + return documentUri.toString(); } async changeDocument(uri: string, text: string) { @@ -269,6 +269,8 @@ export class TestClient { }, contentChanges: [{ text }] }); + + return documentUri.toString(); } async closeDocument(uri: string) {