From 6cf183aeaf4cd0d4be82ad73bbb9fe453901b170 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 11 Jun 2026 05:29:00 +0530 Subject: [PATCH 1/7] cache compiled schema validators and restructure schemaValidation test --- language-server/src/features/SchemaValidation.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 0bdfb80..89127ac 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, beforeAll, afterAll, afterEach, beforeEach } from "vitest"; import { TestClient } from "../test/test-client.ts"; import { unregisterSchema } from "@hyperjump/json-schema"; @@ -13,6 +13,10 @@ describe("Schema Validation", () => { await client.start(); }); + beforeEach(() => { + fixtureSchemaUri = `https://example.com/person`; + }); + afterAll(async () => { await client.stop(); }); From 9f904df347c3556928df8282651183a5aa192808 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 11 Jun 2026 21:24:11 +0530 Subject: [PATCH 2/7] replace type any with EvaluateInstance for validatorCache --- language-server/src/features/SchemaValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts index 899f068..8d728fa 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -2,7 +2,7 @@ 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"; +import type { ErrorObject, EvaluateInstance } from "@hyperjump/json-schema-errors"; import type { DiagnosticsProvider } from "./Diagnostics.ts"; export class SchemaValidation implements DiagnosticsProvider { From ca7d0375cbcd12271b57b3b4a8f945ca9a5e827e Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 11 Jun 2026 05:29:00 +0530 Subject: [PATCH 3/7] cache compiled schema validators and restructure schemaValidation test use json-document model --- language-server/src/features/SchemaValidation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 89127ac..4669d1b 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -326,4 +326,4 @@ describe("Schema Validation", () => { const diagnostics2 = await diagnosticsPromise2; expect(diagnostics2).toHaveLength(0); }); -}); +}); \ No newline at end of file From a115926002e664bf647da2a7e3ee6ccacf85b8a0 Mon Sep 17 00:00:00 2001 From: Diya Date: Mon, 15 Jun 2026 06:14:19 +0530 Subject: [PATCH 4/7] refactor caching strategy and implement cache invalidation --- language-server/src/features/Diagnostics.ts | 10 +- .../src/features/SchemaValidation.test.ts | 2 +- .../src/features/SchemaValidation.ts | 53 ++++--- .../src/services/schemaValidatorCache.test.ts | 137 ++++++++++++++++++ .../src/services/schemaValidatorCache.ts | 65 +++++++++ 5 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 language-server/src/services/schemaValidatorCache.test.ts create mode 100644 language-server/src/services/schemaValidatorCache.ts diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 54cbaeb..71c72e2 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -1,20 +1,24 @@ import { Server } from "../services/server.ts"; -import { JsonDocuments } from "../services/JsonDocuments.ts"; import { JsonDocument } from "../models/JsonDocument.ts"; -import type { Diagnostic } from "vscode-languageserver"; +import type { Diagnostic, TextDocuments } from "vscode-languageserver"; export type DiagnosticsProvider = { getDiagnostics(jsonDocument: JsonDocument): Promise; + clearCache?(document: JsonDocument): void; }; export class Diagnostics { private providers: DiagnosticsProvider[]; - constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) { + constructor(server: Server, documents: TextDocuments, providers: DiagnosticsProvider[]) { this.providers = providers; documents.onDidChangeContent(async (change) => { + for (const provider of this.providers) { + provider.clearCache?.(change.document); + } + const diagnostics = []; for (const provider of this.providers) { diagnostics.push(...await provider.getDiagnostics(change.document)); diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 4669d1b..89127ac 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -326,4 +326,4 @@ describe("Schema Validation", () => { const diagnostics2 = await diagnosticsPromise2; expect(diagnostics2).toHaveLength(0); }); -}); \ No newline at end of file +}); diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts index 8d728fa..34d971e 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -1,41 +1,50 @@ import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver"; -import { validate } from "@hyperjump/json-schema-errors"; import { JsonDocument } from "../models/JsonDocument.ts"; +import { SchemaValidatorCache } from "../services/schemaValidatorCache.ts"; import type { ErrorObject, EvaluateInstance } from "@hyperjump/json-schema-errors"; import type { DiagnosticsProvider } from "./Diagnostics.ts"; export class SchemaValidation implements DiagnosticsProvider { + private validatorCache = new SchemaValidatorCache(); + + clearCache(document: JsonDocument) { + this.validatorCache.clear(document); + } + 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 compiledValidator = await this.validatorCache.getValidator(schemaUri); + + if (compiledValidator) { + const result = compiledValidator(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 diff --git a/language-server/src/services/schemaValidatorCache.test.ts b/language-server/src/services/schemaValidatorCache.test.ts new file mode 100644 index 0000000..52db609 --- /dev/null +++ b/language-server/src/services/schemaValidatorCache.test.ts @@ -0,0 +1,137 @@ +import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { TestClient } from "../test/test-client.ts"; +import { unregisterSchema } from "@hyperjump/json-schema"; + +import type { Diagnostic, PublishDiagnosticsParams } from "vscode-languageserver"; + +describe("SchemaValidatorCache", () => { + let client: TestClient; + let fixtureSchemaUri: string; + + beforeAll(async () => { + client = new TestClient(); + await client.start(); + }); + + afterAll(async () => { + await client.stop(); + }); + + afterEach(() => { + if (fixtureSchemaUri) { + unregisterSchema(fixtureSchemaUri); + } + }); + + test("Same schema used twice should only compile once", async () => { + fixtureSchemaUri = await client.writeDocument(`first.schema.json`, `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`); + + const instance1Uri = await client.writeDocument("instance1.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": 123 + }`); + + const instance2Uri = await client.writeDocument("instance2.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": true + }`); + + const diagnosticsPromise1 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + await client.openDocument("instance1.json"); + const diagnostics1 = await diagnosticsPromise1; + + const diagnosticsPromise2 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + await client.openDocument("instance2.json"); + const diagnostics2 = await diagnosticsPromise2; + + expect(diagnostics1).toHaveLength(1); + expect((diagnostics1[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a string"); + + expect(diagnostics2).toHaveLength(1); + expect((diagnostics2[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a string"); + + await client.closeDocument(instance1Uri); + await client.closeDocument(instance2Uri); + }); + + test("Schema changes on disk so cache should invalidate", async () => { + fixtureSchemaUri = await client.writeDocument("second.schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + } + }`); + + const instanceUri = await client.writeDocument("instance-scenario2.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": 123 + }`); + + const diagnosticsPromise1 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + await client.openDocument("instance-scenario2.json"); + const diagnostics1 = await diagnosticsPromise1; + expect(diagnostics1).toHaveLength(1); + expect((diagnostics1[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a string"); + + await client.writeDocument("second.schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "number" } + } + }`); + + const diagnosticsPromise2 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + await client.changeDocument("instance-scenario2.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": 123 + }`); + + const diagnostics2 = await diagnosticsPromise2; + expect(diagnostics2).toHaveLength(0); + + await client.closeDocument(instanceUri); + }); + + test("Invalid schema URI should not crash", async () => { + const instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "file:///non-existent-schema.schema.json", + "name": 123 + }`); + + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + await client.openDocument("instance.json"); + const diagnostics = await diagnosticsPromise; + + expect(diagnostics).toHaveLength(0); + + await client.closeDocument(instanceUri); + }); +}); diff --git a/language-server/src/services/schemaValidatorCache.ts b/language-server/src/services/schemaValidatorCache.ts new file mode 100644 index 0000000..f6b0ab4 --- /dev/null +++ b/language-server/src/services/schemaValidatorCache.ts @@ -0,0 +1,65 @@ +import { fileURLToPath } from "node:url"; +import { readFile } from "node:fs/promises"; +import { validate } from "@hyperjump/json-schema-errors"; +import { unregisterSchema } from "@hyperjump/json-schema"; +import { JsonDocument } from "../models/JsonDocument.ts"; +import type { EvaluateInstance } from "@hyperjump/json-schema-errors"; + +type CacheEntry = { + compiledValidator: EvaluateInstance | null; + content?: string; +}; + +export class SchemaValidatorCache { + private cache = new Map(); + + async getValidator(schemaUri: string): Promise { + const currentContent = await getFileContent(schemaUri); + let cacheEntry = this.cache.get(schemaUri); + + const hasChanged = cacheEntry && ( + currentContent !== undefined && cacheEntry.content !== currentContent + ); + + if (hasChanged) { + unregisterSchema(schemaUri); + this.cache.delete(schemaUri); + cacheEntry = undefined; + } + + if (cacheEntry === undefined) { + try { + const compiledValidator = await validate(schemaUri); + cacheEntry = { compiledValidator, content: currentContent }; + this.cache.set(schemaUri, cacheEntry); + } catch (error) { + cacheEntry = { compiledValidator: null, content: currentContent }; + this.cache.set(schemaUri, cacheEntry); + } + } + + return cacheEntry.compiledValidator; + } + + clear(document: JsonDocument) { + unregisterSchema(document.uri); + this.cache.delete(document.uri); + const idNode = document.findNodeAtPointer("/$id") ?? document.findNodeAtPointer("/id"); + if (idNode && typeof idNode.value === "string") { + unregisterSchema(idNode.value); + this.cache.delete(idNode.value); + } + } +} + +const getFileContent = async (uri: string): Promise => { + if (uri.startsWith("file://")) { + try { + const filePath = fileURLToPath(uri); + return await readFile(filePath, "utf-8"); + } catch { + return undefined; + } + } + return undefined; +}; From 58f499e33c1aa2b2e8dd67c6edcc12d6308ceca0 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 18 Jun 2026 02:23:54 +0530 Subject: [PATCH 5/7] remove SchemaValidatorCache and wire cleanup --- language-server/src/build-server.ts | 4 +- .../src/features/SchemaValidation.test.ts | 57 +++++++- .../src/features/SchemaValidation.ts | 62 +++----- .../src/features/SyntaxValidation.test.ts | 6 +- language-server/src/models/JsonDocument.ts | 35 ++++- language-server/src/services/JsonDocuments.ts | 5 +- language-server/src/services/SchemaStore.ts | 36 +++++ .../src/services/schemaValidatorCache.test.ts | 137 ------------------ .../src/services/schemaValidatorCache.ts | 65 --------- language-server/src/test/test-client.ts | 4 +- 10 files changed, 150 insertions(+), 261 deletions(-) create mode 100644 language-server/src/services/SchemaStore.ts delete mode 100644 language-server/src/services/schemaValidatorCache.test.ts delete mode 100644 language-server/src/services/schemaValidatorCache.ts diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 3225607..63a3d0e 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,8 +18,9 @@ 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, [ diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 89127ac..6bb7b8a 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, beforeEach } from "vitest"; +import { describe, test, expect, afterEach, beforeEach } from "vitest"; import { TestClient } from "../test/test-client.ts"; import { unregisterSchema } from "@hyperjump/json-schema"; @@ -8,16 +8,12 @@ describe("Schema Validation", () => { let client: TestClient; let fixtureSchemaUri: string; - beforeAll(async () => { + beforeEach(async () => { client = new TestClient(); await client.start(); }); - beforeEach(() => { - fixtureSchemaUri = `https://example.com/person`; - }); - - afterAll(async () => { + afterEach(async () => { await client.stop(); }); @@ -326,4 +322,51 @@ describe("Schema Validation", () => { const diagnostics2 = await diagnosticsPromise2; expect(diagnostics2).toHaveLength(0); }); + + test.skip("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); + } + }); + }); + + fixtureSchemaUri = await client.changeDocument("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 34d971e..d386a62 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -1,56 +1,34 @@ import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver"; import { JsonDocument } from "../models/JsonDocument.ts"; -import { SchemaValidatorCache } from "../services/schemaValidatorCache.ts"; -import type { ErrorObject, EvaluateInstance } from "@hyperjump/json-schema-errors"; +import type { ErrorObject } from "@hyperjump/json-schema-errors"; import type { DiagnosticsProvider } from "./Diagnostics.ts"; export class SchemaValidation implements DiagnosticsProvider { - private validatorCache = new SchemaValidatorCache(); - - clearCache(document: JsonDocument) { - this.validatorCache.clear(document); - } - async getDiagnostics(jsonDocument: JsonDocument) { const schemaDiagnostics: Diagnostic[] = []; - const schemaNode = jsonDocument.findNodeAtPointer("/$schema"); - const schemaUri = schemaNode?.value; - - if (schemaUri && jsonDocument.getParseErrors().length === 0) { - let instance = JSON.parse(jsonDocument.getText()); - try { - const compiledValidator = await this.validatorCache.getValidator(schemaUri); - - if (compiledValidator) { - const result = compiledValidator(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..2e16ffb 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -2,18 +2,44 @@ 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; + } + + this.schemaStore.clear(schemaUri); + + let instance = JSON.parse(this.getText()); + this.schemaErrors = this.schemaStore.validate(schemaUri, instance); } get uri() { @@ -46,14 +72,17 @@ 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(); } getParseErrors() { return this.parseErrors; } + getSchemaErrors() { + return this.schemaErrors; + } + 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/services/schemaValidatorCache.test.ts b/language-server/src/services/schemaValidatorCache.test.ts deleted file mode 100644 index 52db609..0000000 --- a/language-server/src/services/schemaValidatorCache.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { TestClient } from "../test/test-client.ts"; -import { unregisterSchema } from "@hyperjump/json-schema"; - -import type { Diagnostic, PublishDiagnosticsParams } from "vscode-languageserver"; - -describe("SchemaValidatorCache", () => { - let client: TestClient; - let fixtureSchemaUri: string; - - beforeAll(async () => { - client = new TestClient(); - await client.start(); - }); - - afterAll(async () => { - await client.stop(); - }); - - afterEach(() => { - if (fixtureSchemaUri) { - unregisterSchema(fixtureSchemaUri); - } - }); - - test("Same schema used twice should only compile once", async () => { - fixtureSchemaUri = await client.writeDocument(`first.schema.json`, `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { "type": "string" } - } - }`); - - const instance1Uri = await client.writeDocument("instance1.json", `{ - "$schema": "${fixtureSchemaUri}", - "name": 123 - }`); - - const instance2Uri = await client.writeDocument("instance2.json", `{ - "$schema": "${fixtureSchemaUri}", - "name": true - }`); - - const diagnosticsPromise1 = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - resolve(params.diagnostics); - }); - }); - await client.openDocument("instance1.json"); - const diagnostics1 = await diagnosticsPromise1; - - const diagnosticsPromise2 = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - resolve(params.diagnostics); - }); - }); - await client.openDocument("instance2.json"); - const diagnostics2 = await diagnosticsPromise2; - - expect(diagnostics1).toHaveLength(1); - expect((diagnostics1[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a string"); - - expect(diagnostics2).toHaveLength(1); - expect((diagnostics2[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a string"); - - await client.closeDocument(instance1Uri); - await client.closeDocument(instance2Uri); - }); - - test("Schema changes on disk so cache should invalidate", async () => { - fixtureSchemaUri = await client.writeDocument("second.schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { "type": "string" } - } - }`); - - const instanceUri = await client.writeDocument("instance-scenario2.json", `{ - "$schema": "${fixtureSchemaUri}", - "name": 123 - }`); - - const diagnosticsPromise1 = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - resolve(params.diagnostics); - }); - }); - await client.openDocument("instance-scenario2.json"); - const diagnostics1 = await diagnosticsPromise1; - expect(diagnostics1).toHaveLength(1); - expect((diagnostics1[0].message as string).replace(/[\u2068\u2069]/g, "")).toBe("Expected a string"); - - await client.writeDocument("second.schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { "type": "number" } - } - }`); - - const diagnosticsPromise2 = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - resolve(params.diagnostics); - }); - }); - await client.changeDocument("instance-scenario2.json", `{ - "$schema": "${fixtureSchemaUri}", - "name": 123 - }`); - - const diagnostics2 = await diagnosticsPromise2; - expect(diagnostics2).toHaveLength(0); - - await client.closeDocument(instanceUri); - }); - - test("Invalid schema URI should not crash", async () => { - const instanceUri = await client.writeDocument("instance.json", `{ - "$schema": "file:///non-existent-schema.schema.json", - "name": 123 - }`); - - const diagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - resolve(params.diagnostics); - }); - }); - await client.openDocument("instance.json"); - const diagnostics = await diagnosticsPromise; - - expect(diagnostics).toHaveLength(0); - - await client.closeDocument(instanceUri); - }); -}); diff --git a/language-server/src/services/schemaValidatorCache.ts b/language-server/src/services/schemaValidatorCache.ts deleted file mode 100644 index f6b0ab4..0000000 --- a/language-server/src/services/schemaValidatorCache.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { readFile } from "node:fs/promises"; -import { validate } from "@hyperjump/json-schema-errors"; -import { unregisterSchema } from "@hyperjump/json-schema"; -import { JsonDocument } from "../models/JsonDocument.ts"; -import type { EvaluateInstance } from "@hyperjump/json-schema-errors"; - -type CacheEntry = { - compiledValidator: EvaluateInstance | null; - content?: string; -}; - -export class SchemaValidatorCache { - private cache = new Map(); - - async getValidator(schemaUri: string): Promise { - const currentContent = await getFileContent(schemaUri); - let cacheEntry = this.cache.get(schemaUri); - - const hasChanged = cacheEntry && ( - currentContent !== undefined && cacheEntry.content !== currentContent - ); - - if (hasChanged) { - unregisterSchema(schemaUri); - this.cache.delete(schemaUri); - cacheEntry = undefined; - } - - if (cacheEntry === undefined) { - try { - const compiledValidator = await validate(schemaUri); - cacheEntry = { compiledValidator, content: currentContent }; - this.cache.set(schemaUri, cacheEntry); - } catch (error) { - cacheEntry = { compiledValidator: null, content: currentContent }; - this.cache.set(schemaUri, cacheEntry); - } - } - - return cacheEntry.compiledValidator; - } - - clear(document: JsonDocument) { - unregisterSchema(document.uri); - this.cache.delete(document.uri); - const idNode = document.findNodeAtPointer("/$id") ?? document.findNodeAtPointer("/id"); - if (idNode && typeof idNode.value === "string") { - unregisterSchema(idNode.value); - this.cache.delete(idNode.value); - } - } -} - -const getFileContent = async (uri: string): Promise => { - if (uri.startsWith("file://")) { - try { - const filePath = fileURLToPath(uri); - return await readFile(filePath, "utf-8"); - } catch { - return undefined; - } - } - return undefined; -}; 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) { From cf2e482bafffef6c6c235ba41d5e2184e9cbb9e0 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 18 Jun 2026 02:42:46 +0530 Subject: [PATCH 6/7] align Diagnostics with upstream: use JsonDocuments type, remove clearCache --- language-server/src/features/Diagnostics.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 71c72e2..54cbaeb 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -1,24 +1,20 @@ import { Server } from "../services/server.ts"; +import { JsonDocuments } from "../services/JsonDocuments.ts"; import { JsonDocument } from "../models/JsonDocument.ts"; -import type { Diagnostic, TextDocuments } from "vscode-languageserver"; +import type { Diagnostic } from "vscode-languageserver"; export type DiagnosticsProvider = { getDiagnostics(jsonDocument: JsonDocument): Promise; - clearCache?(document: JsonDocument): void; }; export class Diagnostics { private providers: DiagnosticsProvider[]; - constructor(server: Server, documents: TextDocuments, providers: DiagnosticsProvider[]) { + constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) { this.providers = providers; documents.onDidChangeContent(async (change) => { - for (const provider of this.providers) { - provider.clearCache?.(change.document); - } - const diagnostics = []; for (const provider of this.providers) { diagnostics.push(...await provider.getDiagnostics(change.document)); From 3b0a9d25c2eb390a1d6dfcffc5546142b9f04c87 Mon Sep 17 00:00:00 2001 From: Diya Date: Fri, 19 Jun 2026 01:57:52 +0530 Subject: [PATCH 7/7] fix: cache compiled schemas and invalidate on schema changes --- language-server/src/build-server.ts | 25 ++++++++++++++++++- language-server/src/features/Diagnostics.ts | 22 ++++++++++------ .../src/features/SchemaValidation.test.ts | 6 ++--- language-server/src/models/JsonDocument.ts | 11 ++++++-- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 63a3d0e..d5950fe 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -23,10 +23,33 @@ export const buildServer = (connection: Connection): Connection => { 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 6bb7b8a..b76b982 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -323,7 +323,7 @@ describe("Schema Validation", () => { expect(diagnostics2).toHaveLength(0); }); - test.skip("changing the schema should invalidate the cache", async () => { + test("changing the schema should invalidate the cache", async () => { const diagnosticsPromise1 = new Promise((resolve) => { client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { resolve(params.diagnostics); @@ -349,7 +349,7 @@ describe("Schema Validation", () => { const diagnostics1 = await diagnosticsPromise1; expect(diagnostics1).toHaveLength(1); - const diagnosticsPromise2 = new Promise((resolve) => { + const diagnosticsPromise2 = new Promise((resolve) => { client.onNotification("textDocument/publishDiagnostics", (params) => { if (params.uri === instanceUri) { resolve(params.diagnostics); @@ -357,7 +357,7 @@ describe("Schema Validation", () => { }); }); - fixtureSchemaUri = await client.changeDocument("schema.json", `{ + await client.writeDocument("schema.json", `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 2e16ffb..863634d 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -36,8 +36,6 @@ export class JsonDocument implements TextDocument { return; } - this.schemaStore.clear(schemaUri); - let instance = JSON.parse(this.getText()); this.schemaErrors = this.schemaStore.validate(schemaUri, instance); } @@ -75,6 +73,10 @@ export class JsonDocument implements TextDocument { this.validate(); } + revalidate() { + this.validate(); + } + getParseErrors() { return this.parseErrors; } @@ -83,6 +85,11 @@ export class JsonDocument implements TextDocument { return this.schemaErrors; } + getSchemaUri() { + const schemaNode = this.findNodeAtPointer("/$schema"); + return schemaNode?.value as string | undefined; + } + findNodeAtPointer(pointer: string) { if (!this.ast) { return;