From 6cf183aeaf4cd0d4be82ad73bbb9fe453901b170 Mon Sep 17 00:00:00 2001 From: Diya Date: Thu, 11 Jun 2026 05:29:00 +0530 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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; From fd14e57039d1c123650f61aec0f5abe0bb7481fa Mon Sep 17 00:00:00 2001 From: Diya Date: Sat, 20 Jun 2026 09:33:18 +0530 Subject: [PATCH 08/11] implement transitive schema cache using compiledschema ast --- language-server/src/build-server.ts | 25 +------ language-server/src/features/Diagnostics.ts | 21 +++++- .../src/features/SchemaValidation.test.ts | 74 ++++++++++++++++++- language-server/src/services/SchemaStore.ts | 55 +++++++++++--- 4 files changed, 139 insertions(+), 36 deletions(-) diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index d5950fe..cfffbfa 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -23,33 +23,10 @@ export const buildServer = (connection: Connection): Connection => { const documents = new JsonDocuments(server, schemaStore); documents.listen(server); - const diagnostics = new Diagnostics(server, documents, [ + new Diagnostics(server, documents, schemaStore, [ 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 e5e16e8..21c42e2 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -3,6 +3,7 @@ import { JsonDocuments } from "../services/JsonDocuments.ts"; import { JsonDocument } from "../models/JsonDocument.ts"; import type { Diagnostic } from "vscode-languageserver"; +import type { SchemaStore } from "../services/SchemaStore.ts"; export type DiagnosticsProvider = { getDiagnostics(jsonDocument: JsonDocument): Promise; @@ -12,13 +13,31 @@ export class Diagnostics { private server: Server; private providers: DiagnosticsProvider[]; - constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) { + constructor(server: Server, documents: JsonDocuments, schemaStore: SchemaStore, providers: DiagnosticsProvider[]) { this.server = server; this.providers = providers; documents.onDidChangeContent(async (change) => { await this.sendDiagnostics(change.document); }); + + server.onDidChangeWatchedFiles(async (params) => { + const changedUris = new Set(params.changes.map((change) => decodeURIComponent(change.uri))); + + for (const document of documents.all()) { + const schemaUri = document.getSchemaUri(); + + if (schemaUri === undefined) { + continue; + } + const dependentSchemaUris = schemaStore.getDependentSchemaUris(schemaUri); + + if (dependentSchemaUris === undefined || [...changedUris].some((uri) => dependentSchemaUris.has(uri))) { + document.revalidate(); + await this.sendDiagnostics(document); + } + } + }); } async sendDiagnostics(document: JsonDocument) { diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index b76b982..9c088ac 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, 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"; @@ -369,4 +369,76 @@ describe("Schema Validation", () => { const diagnostics2 = await diagnosticsPromise2; expect(diagnostics2).toHaveLength(0); }); + + test("changing a referenced schema revalidates dependents", async () => { + const diagnosticsPromise1 = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + + const referencedSchema = await client.writeDocument("B.schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "number" + }`); + + fixtureSchemaUri = await client.writeDocument("A.schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "age": { "$ref": "${referencedSchema}" } + } + }`); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "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("B.schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string" + }`); + + const diagnostics2 = await diagnosticsPromise2; + expect(diagnostics2).toHaveLength(0); + }); + + test("changing a watched file should not revalidate documents with no $schema", async () => { + const DiagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + resolve(params.diagnostics); + }); + }); + + await client.writeDocument("plain.json", `{ "foo": "bar" }`); + const plainUri = await client.openDocument("plain.json"); + await DiagnosticsPromise; + + let revalidated = false; + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri === plainUri) { + revalidated = true; + } + }); + + fixtureSchemaUri = await client.writeDocument("unrelated.schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + }`); + + expect(revalidated).toBe(false); + }); }); diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index 951932d..d6ac67d 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,36 +1,71 @@ -import { validate } from "@hyperjump/json-schema-errors"; +import { compile, getSchema } from "@hyperjump/json-schema/experimental"; +import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; import { unregisterSchema } from "@hyperjump/json-schema"; -import type { EvaluateInstance, Json } from "@hyperjump/json-schema-errors"; +import type { CompiledSchema } from "@hyperjump/json-schema/experimental"; +import type { Json, ValidationResult } from "@hyperjump/json-schema-errors"; import type { Server } from "../services/server.ts"; export class SchemaStore { - private validatorCache: Map = new Map(); + private compiledSchemaCache: Map = new Map(); constructor(server: Server) { server.onExit(() => { this.clearAll(); }); + + server.onDidChangeWatchedFiles((params) => { + const changedUris = new Set(params.changes.map((change) => decodeURIComponent(change.uri))); + + const toClear = [...this.compiledSchemaCache.keys()].filter((schemaUri) => { + const dependentSchemas = this.getDepsFromCompiledSchema(this.compiledSchemaCache.get(schemaUri)!); + + return [...changedUris].some((uri) => dependentSchemas.has(uri)); + }); + + for (const schemaUri of toClear) { + this.clear(schemaUri); + } + }); } - async validate(schemaUri: string, instance: Json) { - if (!this.validatorCache.has(schemaUri)) { - this.validatorCache.set(schemaUri, await validate(schemaUri)); + async validate(schemaUri: string, instance: Json): Promise { + if (!this.compiledSchemaCache.has(schemaUri)) { + const schema = await getSchema(schemaUri); + this.compiledSchemaCache.set(schemaUri, await compile(schema)); } - const validator = this.validatorCache.get(schemaUri)!; + const compiledSchema = this.compiledSchemaCache.get(schemaUri)!; - return validator(instance); + return evaluateCompiledSchema(compiledSchema, instance); + } + + getDependentSchemaUris(schemaUri: string): Set | undefined { + const compiledSchema = this.compiledSchemaCache.get(schemaUri); + if (compiledSchema === undefined) { + return undefined; + } + return this.getDepsFromCompiledSchema(compiledSchema); } clearAll() { - for (const schemaUri of this.validatorCache.keys()) { + for (const schemaUri of this.compiledSchemaCache.keys()) { this.clear(schemaUri); } } clear(schemaUri: string) { unregisterSchema(schemaUri); - this.validatorCache.delete(schemaUri); + this.compiledSchemaCache.delete(schemaUri); // TODO: Unregister schemas under $id and id } + + private getDepsFromCompiledSchema(compiledSchema: CompiledSchema): Set { + return new Set( + Object.keys(compiledSchema.ast) + .filter((key) => key !== "metaData") + .filter((key) => key !== "plugins") + .map((key) => key.split("#")[0]) + .filter((uri) => uri !== "") + ); + } } From d1e165597563ee0f5fb304919cb952e23e3df09a Mon Sep 17 00:00:00 2001 From: Diya Date: Mon, 22 Jun 2026 03:13:32 +0530 Subject: [PATCH 09/11] code refactor --- language-server/src/build-server.ts | 2 +- language-server/src/features/Diagnostics.ts | 21 +++----- .../src/features/SchemaValidation.test.ts | 28 +++++++++- .../src/features/SchemaValidation.ts | 39 +++++++------- language-server/src/models/JsonDocument.ts | 52 +++++++++++++++---- language-server/src/services/SchemaStore.ts | 36 +++++++------ 6 files changed, 119 insertions(+), 59 deletions(-) diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index cfffbfa..63a3d0e 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -23,7 +23,7 @@ export const buildServer = (connection: Connection): Connection => { const documents = new JsonDocuments(server, schemaStore); documents.listen(server); - new Diagnostics(server, documents, schemaStore, [ + new Diagnostics(server, documents, [ new SyntaxValidation(), new SchemaValidation() ]); diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 21c42e2..029a275 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -3,7 +3,6 @@ import { JsonDocuments } from "../services/JsonDocuments.ts"; import { JsonDocument } from "../models/JsonDocument.ts"; import type { Diagnostic } from "vscode-languageserver"; -import type { SchemaStore } from "../services/SchemaStore.ts"; export type DiagnosticsProvider = { getDiagnostics(jsonDocument: JsonDocument): Promise; @@ -13,7 +12,7 @@ export class Diagnostics { private server: Server; private providers: DiagnosticsProvider[]; - constructor(server: Server, documents: JsonDocuments, schemaStore: SchemaStore, providers: DiagnosticsProvider[]) { + constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) { this.server = server; this.providers = providers; @@ -22,25 +21,21 @@ export class Diagnostics { }); server.onDidChangeWatchedFiles(async (params) => { - const changedUris = new Set(params.changes.map((change) => decodeURIComponent(change.uri))); + const changedUris = new Set(); + for (const change of params.changes) { + changedUris.add(decodeURIComponent(change.uri)); + } for (const document of documents.all()) { - const schemaUri = document.getSchemaUri(); - - if (schemaUri === undefined) { - continue; - } - const dependentSchemaUris = schemaStore.getDependentSchemaUris(schemaUri); - - if (dependentSchemaUris === undefined || [...changedUris].some((uri) => dependentSchemaUris.has(uri))) { - document.revalidate(); + if (document.dependsOn(changedUris)) { + document.validateSchema(); await this.sendDiagnostics(document); } } }); } - async sendDiagnostics(document: JsonDocument) { + private async sendDiagnostics(document: JsonDocument) { const diagnostics = []; for (const provider of this.providers) { diagnostics.push(...await provider.getDiagnostics(document)); diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 9c088ac..2ce3863 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, 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"; @@ -416,6 +416,32 @@ describe("Schema Validation", () => { expect(diagnostics2).toHaveLength(0); }); + test("JSON Validation using Hyperjump - Relative $schema case", 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" } + } + }`); + + await client.writeDocument("instance.json", `{ + "$schema": "schema.json", + "name": 1234 + }`); + await client.openDocument("instance.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toMatch(/Expected a.*string/); + }); + test("changing a watched file should not revalidate documents with no $schema", async () => { const DiagnosticsPromise = new Promise((resolve) => { client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts index d386a62..f4cff93 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -9,25 +9,28 @@ export class SchemaValidation implements DiagnosticsProvider { const schemaDiagnostics: Diagnostic[] = []; const result = await jsonDocument.getSchemaErrors(); + try { + if (result?.valid === false) { + const errors = result.errors; + errors.forEach((error) => { + const pointer = decodeURIComponent(error.instanceLocation.slice(1)); + const node = jsonDocument.findNodeAtPointer(pointer); - 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" - }); - } - }); + 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/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 863634d..fded451 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -13,6 +13,7 @@ export class JsonDocument implements TextDocument { private ast: jsonc.Node | undefined; private parseErrors: jsonc.ParseError[] = []; private schemaErrors: Promise | undefined; + private schemaUri: string | undefined; constructor(textDocument: TextDocument, schemaStore: SchemaStore) { this.textDocument = textDocument; @@ -29,15 +30,18 @@ export class JsonDocument implements TextDocument { return; } - const schemaNode = this.findNodeAtPointer("/$schema"); - const schemaUri = schemaNode?.value; - - if (schemaUri === undefined) { - return; + const rawSchemaUri = this.findNodeAtPointer("/$schema")?.value as string | undefined; + if (rawSchemaUri !== undefined) { + try { + this.schemaUri = new URL(rawSchemaUri, this.uri).toString(); + } catch { + this.schemaUri = rawSchemaUri; + } + } else { + this.schemaUri = undefined; } - let instance = JSON.parse(this.getText()); - this.schemaErrors = this.schemaStore.validate(schemaUri, instance); + this.validateSchema(); } get uri() { @@ -73,8 +77,15 @@ export class JsonDocument implements TextDocument { this.validate(); } - revalidate() { - this.validate(); + validateSchema() { + this.schemaErrors = undefined; + + if (this.schemaUri === undefined) { + return; + } + + const instance = JSON.parse(this.getText()); + this.schemaErrors = this.schemaStore.validate(this.schemaUri, instance); } getParseErrors() { @@ -86,8 +97,27 @@ export class JsonDocument implements TextDocument { } getSchemaUri() { - const schemaNode = this.findNodeAtPointer("/$schema"); - return schemaNode?.value as string | undefined; + return this.schemaUri; + } + + dependsOn(changedUris: Set): boolean { + if (this.schemaUri === undefined) { + return false; + } + + const dependentSchemaUris = this.schemaStore.getDependentSchemaUris(this.schemaUri); + + if (dependentSchemaUris === undefined) { + return true; + } + + for (const uri of changedUris) { + if (dependentSchemaUris.has(uri)) { + return true; + } + } + + return false; } findNodeAtPointer(pointer: string) { diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index d6ac67d..777e64a 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,6 +1,5 @@ import { compile, getSchema } from "@hyperjump/json-schema/experimental"; import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; -import { unregisterSchema } from "@hyperjump/json-schema"; import type { CompiledSchema } from "@hyperjump/json-schema/experimental"; import type { Json, ValidationResult } from "@hyperjump/json-schema-errors"; @@ -15,13 +14,21 @@ export class SchemaStore { }); server.onDidChangeWatchedFiles((params) => { - const changedUris = new Set(params.changes.map((change) => decodeURIComponent(change.uri))); - - const toClear = [...this.compiledSchemaCache.keys()].filter((schemaUri) => { - const dependentSchemas = this.getDepsFromCompiledSchema(this.compiledSchemaCache.get(schemaUri)!); + const changedUris = new Set(); + for (const change of params.changes) { + changedUris.add(decodeURIComponent(change.uri)); + } - return [...changedUris].some((uri) => dependentSchemas.has(uri)); - }); + const toClear: string[] = []; + for (const [schemaUri, compiledSchema] of this.compiledSchemaCache.entries()) { + const dependentSchemas = this.getDepsFromCompiledSchema(compiledSchema); + for (const uri of changedUris) { + if (dependentSchemas.has(uri)) { + toClear.push(schemaUri); + break; + } + } + } for (const schemaUri of toClear) { this.clear(schemaUri); @@ -54,18 +61,17 @@ export class SchemaStore { } clear(schemaUri: string) { - unregisterSchema(schemaUri); this.compiledSchemaCache.delete(schemaUri); // TODO: Unregister schemas under $id and id } private getDepsFromCompiledSchema(compiledSchema: CompiledSchema): Set { - return new Set( - Object.keys(compiledSchema.ast) - .filter((key) => key !== "metaData") - .filter((key) => key !== "plugins") - .map((key) => key.split("#")[0]) - .filter((uri) => uri !== "") - ); + const deps = new Set(); + for (const key of Object.keys(compiledSchema.ast)) { + if (key !== "metaData" && key !== "plugins") { + deps.add(key.split("#")[0]); + } + } + return deps; } } From 0285f85d65b43de20b536cc1fc9165e61359106a Mon Sep 17 00:00:00 2001 From: Diya Date: Mon, 22 Jun 2026 05:40:37 +0530 Subject: [PATCH 10/11] use normalizeIri to normalize the URIs correctly --- language-server/src/features/Diagnostics.ts | 3 ++- language-server/src/models/JsonDocument.ts | 5 +++-- language-server/src/services/SchemaStore.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 029a275..ecbe4a1 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -1,6 +1,7 @@ import { Server } from "../services/server.ts"; import { JsonDocuments } from "../services/JsonDocuments.ts"; import { JsonDocument } from "../models/JsonDocument.ts"; +import { normalizeIri } from "@hyperjump/uri"; import type { Diagnostic } from "vscode-languageserver"; @@ -23,7 +24,7 @@ export class Diagnostics { server.onDidChangeWatchedFiles(async (params) => { const changedUris = new Set(); for (const change of params.changes) { - changedUris.add(decodeURIComponent(change.uri)); + changedUris.add(normalizeIri(change.uri)); } for (const document of documents.all()) { diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index fded451..463862f 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -2,6 +2,7 @@ import { TextDocumentContentChangeEvent } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import * as jsonc from "jsonc-parser"; import { pointerSegments } from "@hyperjump/json-pointer"; +import { normalizeIri } from "@hyperjump/uri"; import { SchemaStore } from "../services/SchemaStore.ts"; import type { Position, Range } from "vscode-languageserver-textdocument"; @@ -33,9 +34,9 @@ export class JsonDocument implements TextDocument { const rawSchemaUri = this.findNodeAtPointer("/$schema")?.value as string | undefined; if (rawSchemaUri !== undefined) { try { - this.schemaUri = new URL(rawSchemaUri, this.uri).toString(); + this.schemaUri = normalizeIri(new URL(rawSchemaUri, this.uri).toString()); } catch { - this.schemaUri = rawSchemaUri; + this.schemaUri = normalizeIri(rawSchemaUri); } } else { this.schemaUri = undefined; diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index 777e64a..d6de32d 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,5 +1,6 @@ import { compile, getSchema } from "@hyperjump/json-schema/experimental"; import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; +import { normalizeIri } from "@hyperjump/uri"; import type { CompiledSchema } from "@hyperjump/json-schema/experimental"; import type { Json, ValidationResult } from "@hyperjump/json-schema-errors"; @@ -16,7 +17,7 @@ export class SchemaStore { server.onDidChangeWatchedFiles((params) => { const changedUris = new Set(); for (const change of params.changes) { - changedUris.add(decodeURIComponent(change.uri)); + changedUris.add(normalizeIri(change.uri)); } const toClear: string[] = []; From e7a85f6de066237b4a7cf52b188f742ed18d92ae Mon Sep 17 00:00:00 2001 From: Diya Date: Mon, 22 Jun 2026 20:15:32 +0530 Subject: [PATCH 11/11] remove URL class and use resolveIri instead --- language-server/src/features/SchemaValidation.ts | 3 ++- language-server/src/models/JsonDocument.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts index f4cff93..7df8041 100644 --- a/language-server/src/features/SchemaValidation.ts +++ b/language-server/src/features/SchemaValidation.ts @@ -8,8 +8,9 @@ export class SchemaValidation implements DiagnosticsProvider { async getDiagnostics(jsonDocument: JsonDocument) { const schemaDiagnostics: Diagnostic[] = []; - const result = await jsonDocument.getSchemaErrors(); try { + const result = await jsonDocument.getSchemaErrors(); + if (result?.valid === false) { const errors = result.errors; errors.forEach((error) => { diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 463862f..817720a 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -2,7 +2,7 @@ import { TextDocumentContentChangeEvent } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import * as jsonc from "jsonc-parser"; import { pointerSegments } from "@hyperjump/json-pointer"; -import { normalizeIri } from "@hyperjump/uri"; +import { resolveIri } from "@hyperjump/uri"; import { SchemaStore } from "../services/SchemaStore.ts"; import type { Position, Range } from "vscode-languageserver-textdocument"; @@ -34,9 +34,9 @@ export class JsonDocument implements TextDocument { const rawSchemaUri = this.findNodeAtPointer("/$schema")?.value as string | undefined; if (rawSchemaUri !== undefined) { try { - this.schemaUri = normalizeIri(new URL(rawSchemaUri, this.uri).toString()); + this.schemaUri = resolveIri(rawSchemaUri, this.uri); } catch { - this.schemaUri = normalizeIri(rawSchemaUri); + this.schemaUri = rawSchemaUri; } } else { this.schemaUri = undefined;