From 85e88717eb476e599c3330a06e7c3276c4cdc6aa Mon Sep 17 00:00:00 2001 From: Suyog Habbu Date: Thu, 2 Jul 2026 19:24:00 +0530 Subject: [PATCH 1/6] feat: support self-identifying workspace schemas - Implement startup scanning, registration under , and watcher lifecycle management. - Update and cache clearing and dependency mapping with URI normalization. - Correct and stabilize test assertions. --- language-server/src/build-server.ts | 13 +- .../src/features/SchemaValidation.test.ts | 15 ++ .../features/SelfIdentifyingSchemas.test.ts | 158 ++++++++++++++++++ .../src/features/SelfIdentifyingSchemas.ts | 147 ++++++++++++++++ language-server/src/models/JsonDocument.ts | 15 +- language-server/src/services/SchemaStore.ts | 26 ++- language-server/src/test/TestClient.ts | 12 +- 7 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 language-server/src/features/SelfIdentifyingSchemas.test.ts create mode 100644 language-server/src/features/SelfIdentifyingSchemas.ts diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index ec2b327..3ffeeba 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -6,6 +6,7 @@ import { Diagnostics } from "./features/Diagnostics.ts"; import { SyntaxValidation } from "./features/SyntaxValidation.ts"; import { SchemaValidation } from "./features/SchemaValidation.ts"; import { Formatting } from "./features/Formatting.ts"; +import { SelfIdentifyingSchemas } from "./features/SelfIdentifyingSchemas.ts"; import { addMediaTypePlugin, removeUriSchemePlugin } from "@hyperjump/browser"; import { buildSchemaDocument } from "@hyperjump/json-schema/experimental"; import { Hover } from "./features/Hover.ts"; @@ -18,14 +19,13 @@ import "@hyperjump/json-schema/draft-04"; import type { Connection } from "vscode-languageserver"; -export type LanguageServerSettings = { -}; +export type LanguageServerSettings = {}; addMediaTypePlugin("application/json", { parse: async (response) => { return buildSchemaDocument(await response.json(), response.url); }, - fileMatcher: async (path) => path.endsWith(".json") + fileMatcher: async (path) => path.endsWith(".json"), }); removeUriSchemePlugin("http"); @@ -40,12 +40,15 @@ export const buildServer = (connection: Connection): Connection => { const documents = new JsonDocuments(server, schemaStore); documents.listen(server); + new Formatting(server, documents); + + new SelfIdentifyingSchemas(server, schemaStore); + new Diagnostics(server, documents, workspace, [ new SyntaxValidation(), - new SchemaValidation() + new SchemaValidation(), ]); - new Formatting(server, documents); new Hover(server, documents); return server; diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index db423c2..b7341c4 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -305,6 +305,9 @@ describe("Schema Validation", () => { await expect(initialValidation).resolves.toHaveLength(1); + // Clear socket queue from duplicate open/watch notifications + await new Promise((resolve) => setTimeout(resolve, 100)); + const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { resolve(params.diagnostics); @@ -345,6 +348,9 @@ describe("Schema Validation", () => { await expect(initialValidation).resolves.toHaveLength(1); + // Clear socket queue from duplicate open/watch notifications + await new Promise((resolve) => setTimeout(resolve, 100)); + const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { if (params.uri === instanceUri) { @@ -393,6 +399,9 @@ describe("Schema Validation", () => { await expect(initialValidation).resolves.toHaveLength(1); + // Clear socket queue from duplicate open/watch notifications + await new Promise((resolve) => setTimeout(resolve, 100)); + const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { if (params.uri === instanceUri) { @@ -526,6 +535,9 @@ describe("Schema Validation", () => { await client.openDocument("instance.json"); await expect(initialValidation).resolves.to.toHaveLength(1); + // Clear socket queue from duplicate open/watch notifications + await new Promise((resolve) => setTimeout(resolve, 100)); + // Remove $schema const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { @@ -565,6 +577,9 @@ describe("Schema Validation", () => { await client.openDocument("instance.json"); await expect(initialValidation).resolves.to.toHaveLength(1); + // Clear socket queue from duplicate open/watch notifications + await new Promise((resolve) => setTimeout(resolve, 100)); + // Introducing a schema error should reset schema errors on dependent instances const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { diff --git a/language-server/src/features/SelfIdentifyingSchemas.test.ts b/language-server/src/features/SelfIdentifyingSchemas.test.ts new file mode 100644 index 0000000..88378a2 --- /dev/null +++ b/language-server/src/features/SelfIdentifyingSchemas.test.ts @@ -0,0 +1,158 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { TestClient } from "../test/TestClient.ts"; +import { unregisterSchema } from "@hyperjump/json-schema"; + +import type { Diagnostic, PublishDiagnosticsParams } from "vscode-languageserver"; + +describe("Self-Identifying Schemas", () => { + let client: TestClient; + const schemaId = "https://example.com/my-workspace-schema"; + + beforeEach(async () => { + client = new TestClient(); + await client.start(); + }); + + afterEach(async () => { + await client.stop(); + try { + unregisterSchema(schemaId); + } catch { + // Ignore if not registered + } + }); + + test("should register self-identifying schema and validate document using its $id", async () => { + // 1. Create a self-identifying schema file in the workspace + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "string" } + } + }`); + + // Wait a brief moment for the watched file event to process + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 2. Create and open an instance file that references the local schema by its $id + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri.endsWith("instance.json")) { + resolve(params.diagnostics); + } + }); + }); + + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.openDocument("instance.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain("Expected a ⁨string⁩"); + + // 3. Update the schema to allow a number for "foo" + const updatedDiagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri.endsWith("instance.json")) { + resolve(params.diagnostics); + } + }); + }); + + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "number" } + } + }`); + + const updatedDiagnostics = await updatedDiagnosticsPromise; + expect(updatedDiagnostics).toHaveLength(0); + }); + + test("should discover and register self-identifying schemas on startup", async () => { + // Create a new client specifically for this test so we can write the document before starting the server + const startupClient = new TestClient(); + + await startupClient.writeDocument("startup-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "bar": { "type": "number" } + } + }`); + + // Start server, which triggers the startup scan + await startupClient.start(); + + // Wait a brief moment for the startup scan to finish + await new Promise((resolve) => setTimeout(resolve, 200)); + + const diagnosticsPromise = new Promise((resolve) => { + startupClient.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri.endsWith("instance2.json")) { + resolve(params.diagnostics); + } + }); + }); + + await startupClient.writeDocument("instance2.json", `{ + "$schema": "${schemaId}", + "bar": "not a number" + }`); + await startupClient.openDocument("instance2.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain("Expected a ⁨number⁩"); + + await startupClient.stop(); + }); + + test("should unregister schema when schema file is deleted", async () => { + // 1. Create schema + await client.writeDocument("delete-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "baz": { "type": "boolean" } + } + }`); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 2. Delete the schema file + await client.deleteDocument("delete-schema.json"); + + // Wait for the deletion watched file event to process + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 3. Try to validate an instance against the deleted schema (should fail to load schema) + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri.endsWith("instance3.json")) { + resolve(params.diagnostics); + } + }); + }); + + await client.writeDocument("instance3.json", `{ + "$schema": "${schemaId}", + "baz": "true" + }`); + await client.openDocument("instance3.json"); + + const diagnostics = await diagnosticsPromise; + // Since the schema was unregistered and doesn't exist on the web (fake URL), validation diagnostics won't succeed + expect(diagnostics).toHaveLength(1); + }); +}); diff --git a/language-server/src/features/SelfIdentifyingSchemas.ts b/language-server/src/features/SelfIdentifyingSchemas.ts new file mode 100644 index 0000000..7945da1 --- /dev/null +++ b/language-server/src/features/SelfIdentifyingSchemas.ts @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as jsonc from "jsonc-parser"; +import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; +import { Server } from "../services/Server.ts"; +import { SchemaStore } from "../services/SchemaStore.ts"; + +import type { FileChangeType } from "vscode-languageserver"; + +export class SelfIdentifyingSchemas { + private server: Server; + private schemaStore: SchemaStore; + private registeredSchemas: Map = new Map(); + private workspaceFolders: string[] = []; + + constructor(server: Server, schemaStore: SchemaStore) { + this.server = server; + this.schemaStore = schemaStore; + + server.onInitialize((params) => { + if (params.workspaceFolders) { + this.workspaceFolders = params.workspaceFolders.map((folder) => folder.uri); + } else if (params.rootUri) { + this.workspaceFolders = [params.rootUri]; + } + return { capabilities: {} }; + }); + + server.onInitialized(async () => { + await this.scanWorkspace(); + }); + + server.onDidChangeWatchedFiles(async (params) => { + for (const change of params.changes) { + const fileUri = change.uri; + if (!fileUri.endsWith(".json") && !fileUri.endsWith(".jsonc")) { + continue; + } + + // change.type is: 1 = Created, 2 = Changed, 3 = Deleted + const changeType = change.type as FileChangeType; + + if (changeType === 3) { + this.unregister(fileUri); + } else { + await this.processFile(fileUri); + } + } + }); + } + + private async scanWorkspace() { + this.server.console.log("Scanning workspace for self-identifying schemas..."); + for (const folderUri of this.workspaceFolders) { + if (!folderUri.startsWith("file://")) { + continue; + } + const dirPath = fileURLToPath(folderUri); + await this.scanDirectory(dirPath); + } + this.server.console.log(`Scanning completed. Registered ${this.registeredSchemas.size} local schemas.`); + } + + private async scanDirectory(dir: string) { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") { + continue; + } + await this.scanDirectory(fullPath); + } else if (entry.isFile()) { + if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) { + const fileUri = pathToFileURL(fullPath).toString(); + await this.processFile(fileUri); + } + } + } + } + + private async processFile(fileUri: string) { + this.unregister(fileUri); + + if (!fileUri.startsWith("file://")) { + return; + } + + const filePath = fileURLToPath(fileUri); + try { + const text = await fs.readFile(filePath, "utf-8"); + const ast = jsonc.parseTree(text); + if (!ast || ast.type !== "object") { + return; + } + + const schemaNode = jsonc.findNodeAtLocation(ast, ["$schema"]); + const idNode = jsonc.findNodeAtLocation(ast, ["$id"]); + + if ( + schemaNode && schemaNode.type === "string" && idNode && idNode.type === "string" + ) { + const schemaObject = jsonc.parse(text); + const id = idNode.value; + + try { + unregisterSchema(id); + } catch { + // Ignore if not registered + } + + registerSchema(schemaObject, id); + this.registeredSchemas.set(fileUri, { id, fileUri }); + this.schemaStore.registerWorkspaceSchema(fileUri, id); + + this.server.console.log(`Registered local schema: ${id} (from ${fileUri})`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.server.console.error(`Failed to process local schema at ${fileUri}: ${message}`); + } + } + + private unregister(fileUri: string) { + const registered = this.registeredSchemas.get(fileUri); + if (registered) { + try { + unregisterSchema(registered.id); + } catch { + // Ignore if not registered + } + this.registeredSchemas.delete(fileUri); + this.schemaStore.unregisterWorkspaceSchema(fileUri); + + this.schemaStore.clear(registered.id); + + this.server.console.log(`Unregistered local schema: ${registered.id}`); + } + } +} diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index dbda34c..59e855d 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 * as JsonPointer from "@hyperjump/json-pointer"; -import { resolveIri } from "@hyperjump/uri"; +import { resolveIri, normalizeIri } from "@hyperjump/uri"; import { SchemaStore } from "../services/SchemaStore.ts"; import { Server } from "../services/Server.ts"; import { MatchingSchemaCollector } from "../services/MatchingSchemaCollector.ts"; @@ -76,9 +76,18 @@ export class JsonDocument implements TextDocument { return false; } - const dependentSchemaUris = await this.schemaStore.getDependentSchemaUris(schemaUri); + const normalizedSchemaUri = normalizeIri(schemaUri); + const normalizedChangedUri = normalizeIri(changedUri); - return dependentSchemaUris === undefined || dependentSchemaUris.has(changedUri); + const dependentSchemaUris = await this.schemaStore.getDependentSchemaUris(normalizedSchemaUri); + + if (dependentSchemaUris === undefined) { + return true; + } + + const changedSchemaId = this.schemaStore.getWorkspaceSchemaId(normalizedChangedUri); + + return dependentSchemaUris.has(normalizedChangedUri) || (changedSchemaId !== undefined && (normalizedSchemaUri === changedSchemaId || dependentSchemaUris.has(changedSchemaId))); } get uri() { diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index 4ec7c70..dbc4c4b 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,6 +1,7 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { compile, getSchema } from "@hyperjump/json-schema/experimental"; +import { unregisterSchema } from "@hyperjump/json-schema"; import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; import { addUriSchemePlugin, httpSchemePlugin } from "@hyperjump/browser"; import { normalizeIri } from "@hyperjump/uri"; @@ -127,10 +128,17 @@ export class SchemaStore { } async clear(schemaUri: string) { + const normalizedUri = normalizeIri(schemaUri); + try { + unregisterSchema(normalizedUri); + } catch { + // Ignore if not registered + } for (const [cachedSchemaUri, compiledSchema] of this.compiledSchemaCache) { try { + const normalizedCachedUri = normalizeIri(cachedSchemaUri); const dependentSchemas = this.getDependenencies(await compiledSchema); - if (!dependentSchemas.has(schemaUri)) { + if (normalizedCachedUri !== normalizedUri && !dependentSchemas.has(normalizedUri)) { continue; } } catch { @@ -145,9 +153,23 @@ export class SchemaStore { const dependentSchemas = new Set(); for (const key of Object.keys(compiledSchema.ast)) { if (key !== "metaData" && key !== "plugins") { - dependentSchemas.add(key.split("#")[0]); + dependentSchemas.add(normalizeIri(key.split("#")[0])); } } return dependentSchemas; } + + private workspaceSchemaUris: Map = new Map(); + + registerWorkspaceSchema(fileUri: string, id: string) { + this.workspaceSchemaUris.set(normalizeIri(fileUri), normalizeIri(id)); + } + + unregisterWorkspaceSchema(fileUri: string) { + this.workspaceSchemaUris.delete(normalizeIri(fileUri)); + } + + getWorkspaceSchemaId(fileUri: string) { + return this.workspaceSchemaUris.get(normalizeIri(fileUri)); + } } diff --git a/language-server/src/test/TestClient.ts b/language-server/src/test/TestClient.ts index 54fcf65..86363b5 100644 --- a/language-server/src/test/TestClient.ts +++ b/language-server/src/test/TestClient.ts @@ -245,8 +245,16 @@ export class TestClient { async deleteDocument(uri: string) { const fullUri = resolveIri(uri, await this.workspaceFolder); - const fullPath = fileURLToPath(fullUri); - await rm(fileURLToPath(fullPath)); + await rm(fileURLToPath(fullUri)); + + if (this.watchEnabled) { + await this.client.sendNotification(DidChangeWatchedFilesNotification.type, { + changes: [{ + type: 3, // FileChangeType.Deleted + uri: fullUri + }] + }); + } return fullUri; } From ef0d9758c136a988786cd35b3bf27025a6ea672e Mon Sep 17 00:00:00 2001 From: Suyog Habbu Date: Thu, 2 Jul 2026 19:39:28 +0530 Subject: [PATCH 2/6] test: resolve race conditions in schema tests --- language-server/src/features/SelfIdentifyingSchemas.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/language-server/src/features/SelfIdentifyingSchemas.test.ts b/language-server/src/features/SelfIdentifyingSchemas.test.ts index 88378a2..7b4a56c 100644 --- a/language-server/src/features/SelfIdentifyingSchemas.test.ts +++ b/language-server/src/features/SelfIdentifyingSchemas.test.ts @@ -55,6 +55,9 @@ describe("Self-Identifying Schemas", () => { expect(diagnostics).toHaveLength(1); expect(diagnostics[0].message).toContain("Expected a ⁨string⁩"); + // Clear socket queue from duplicate open/watch notifications + await new Promise((resolve) => setTimeout(resolve, 100)); + // 3. Update the schema to allow a number for "foo" const updatedDiagnosticsPromise = new Promise((resolve) => { client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { @@ -73,6 +76,9 @@ describe("Self-Identifying Schemas", () => { } }`); + // Wait a brief moment for the watched file event and write to settle on Windows + await new Promise((resolve) => setTimeout(resolve, 100)); + const updatedDiagnostics = await updatedDiagnosticsPromise; expect(updatedDiagnostics).toHaveLength(0); }); From c31226f0a0a52f9ccb596e8f598ef0964a872fc6 Mon Sep 17 00:00:00 2001 From: Suyog Habbu Date: Sun, 5 Jul 2026 08:29:25 +0530 Subject: [PATCH 3/6] fix: resolve file watch listener collision and async cache clearing races --- language-server/src/build-server.ts | 6 +++--- .../src/features/SelfIdentifyingSchemas.ts | 15 +++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 3ffeeba..3e43281 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -25,7 +25,7 @@ addMediaTypePlugin("application/json", { parse: async (response) => { return buildSchemaDocument(await response.json(), response.url); }, - fileMatcher: async (path) => path.endsWith(".json"), + fileMatcher: async (path) => path.endsWith(".json") }); removeUriSchemePlugin("http"); @@ -42,11 +42,11 @@ export const buildServer = (connection: Connection): Connection => { new Formatting(server, documents); - new SelfIdentifyingSchemas(server, schemaStore); + new SelfIdentifyingSchemas(server, workspace, schemaStore); new Diagnostics(server, documents, workspace, [ new SyntaxValidation(), - new SchemaValidation(), + new SchemaValidation() ]); new Hover(server, documents); diff --git a/language-server/src/features/SelfIdentifyingSchemas.ts b/language-server/src/features/SelfIdentifyingSchemas.ts index 7945da1..257b483 100644 --- a/language-server/src/features/SelfIdentifyingSchemas.ts +++ b/language-server/src/features/SelfIdentifyingSchemas.ts @@ -5,17 +5,20 @@ import * as jsonc from "jsonc-parser"; import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; import { Server } from "../services/Server.ts"; import { SchemaStore } from "../services/SchemaStore.ts"; +import { Workspace } from "../services/Workspace.ts"; import type { FileChangeType } from "vscode-languageserver"; export class SelfIdentifyingSchemas { private server: Server; + private workspace: Workspace; private schemaStore: SchemaStore; private registeredSchemas: Map = new Map(); private workspaceFolders: string[] = []; - constructor(server: Server, schemaStore: SchemaStore) { + constructor(server: Server, workspace: Workspace, schemaStore: SchemaStore) { this.server = server; + this.workspace = workspace; this.schemaStore = schemaStore; server.onInitialize((params) => { @@ -31,7 +34,7 @@ export class SelfIdentifyingSchemas { await this.scanWorkspace(); }); - server.onDidChangeWatchedFiles(async (params) => { + workspace.onDidChangeWatchedFiles(async (params) => { for (const change of params.changes) { const fileUri = change.uri; if (!fileUri.endsWith(".json") && !fileUri.endsWith(".jsonc")) { @@ -42,7 +45,7 @@ export class SelfIdentifyingSchemas { const changeType = change.type as FileChangeType; if (changeType === 3) { - this.unregister(fileUri); + await this.unregister(fileUri); } else { await this.processFile(fileUri); } @@ -87,7 +90,7 @@ export class SelfIdentifyingSchemas { } private async processFile(fileUri: string) { - this.unregister(fileUri); + await this.unregister(fileUri); if (!fileUri.startsWith("file://")) { return; @@ -128,7 +131,7 @@ export class SelfIdentifyingSchemas { } } - private unregister(fileUri: string) { + private async unregister(fileUri: string) { const registered = this.registeredSchemas.get(fileUri); if (registered) { try { @@ -139,7 +142,7 @@ export class SelfIdentifyingSchemas { this.registeredSchemas.delete(fileUri); this.schemaStore.unregisterWorkspaceSchema(fileUri); - this.schemaStore.clear(registered.id); + await this.schemaStore.clear(registered.id); this.server.console.log(`Unregistered local schema: ${registered.id}`); } From 6f5e702ddc787f0f455cb453a9208bd2d9829ea6 Mon Sep 17 00:00:00 2001 From: Suyog Habbu Date: Tue, 7 Jul 2026 08:38:40 +0530 Subject: [PATCH 4/6] refactor: move self-identifying schema logic into SchemaStore --- language-server/src/build-server.ts | 9 +- .../src/features/SchemaValidation.test.ts | 208 +++++++++++++++++- .../features/SelfIdentifyingSchemas.test.ts | 164 -------------- .../src/features/SelfIdentifyingSchemas.ts | 150 ------------- language-server/src/services/SchemaStore.ts | 113 +++++++++- language-server/src/test/TestClient.ts | 2 +- 6 files changed, 312 insertions(+), 334 deletions(-) delete mode 100644 language-server/src/features/SelfIdentifyingSchemas.test.ts delete mode 100644 language-server/src/features/SelfIdentifyingSchemas.ts diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 3e43281..ec2b327 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -6,7 +6,6 @@ import { Diagnostics } from "./features/Diagnostics.ts"; import { SyntaxValidation } from "./features/SyntaxValidation.ts"; import { SchemaValidation } from "./features/SchemaValidation.ts"; import { Formatting } from "./features/Formatting.ts"; -import { SelfIdentifyingSchemas } from "./features/SelfIdentifyingSchemas.ts"; import { addMediaTypePlugin, removeUriSchemePlugin } from "@hyperjump/browser"; import { buildSchemaDocument } from "@hyperjump/json-schema/experimental"; import { Hover } from "./features/Hover.ts"; @@ -19,7 +18,8 @@ import "@hyperjump/json-schema/draft-04"; import type { Connection } from "vscode-languageserver"; -export type LanguageServerSettings = {}; +export type LanguageServerSettings = { +}; addMediaTypePlugin("application/json", { parse: async (response) => { @@ -40,15 +40,12 @@ export const buildServer = (connection: Connection): Connection => { const documents = new JsonDocuments(server, schemaStore); documents.listen(server); - new Formatting(server, documents); - - new SelfIdentifyingSchemas(server, workspace, schemaStore); - new Diagnostics(server, documents, workspace, [ new SyntaxValidation(), new SchemaValidation() ]); + new Formatting(server, documents); new Hover(server, documents); return server; diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index b7341c4..3ed0433 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -3,7 +3,7 @@ import { PublishDiagnosticsNotification } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; import { unregisterSchema } from "@hyperjump/json-schema"; -import type { Diagnostic } from "vscode-languageserver"; +import type { Diagnostic, PublishDiagnosticsParams, LogMessageParams } from "vscode-languageserver"; describe("Schema Validation", () => { let client: TestClient; @@ -11,7 +11,15 @@ describe("Schema Validation", () => { beforeEach(async () => { client = new TestClient(); + const scanCompletedPromise = new Promise((resolve) => { + client.onNotification("window/logMessage", (params: LogMessageParams) => { + if (params.message.startsWith("Scanning completed")) { + resolve(); + } + }); + }); await client.start(); + await scanCompletedPromise; }); afterEach(async () => { @@ -296,7 +304,7 @@ describe("Schema Validation", () => { } }`); - await client.writeDocument("instance.json", `{ + const instanceUri = await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", "name": "Alice", "age" : "not a number" @@ -310,7 +318,9 @@ describe("Schema Validation", () => { const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { - resolve(params.diagnostics); + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } }); }); @@ -535,9 +545,6 @@ describe("Schema Validation", () => { await client.openDocument("instance.json"); await expect(initialValidation).resolves.to.toHaveLength(1); - // Clear socket queue from duplicate open/watch notifications - await new Promise((resolve) => setTimeout(resolve, 100)); - // Remove $schema const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { @@ -577,9 +584,6 @@ describe("Schema Validation", () => { await client.openDocument("instance.json"); await expect(initialValidation).resolves.to.toHaveLength(1); - // Clear socket queue from duplicate open/watch notifications - await new Promise((resolve) => setTimeout(resolve, 100)); - // Introducing a schema error should reset schema errors on dependent instances const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { @@ -730,4 +734,190 @@ describe("Schema Validation", () => { } ]); }); + + describe("Self-Identifying Schemas", () => { + const schemaId = "https://example.com/my-workspace-schema"; + + test("should register self-identifying schema and validate document using its $id", async () => { + // 1. Create a self-identifying schema file in the workspace + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "string" } + } + }`); + + // 2. Create and open an instance file that references the local schema by its $id + let instanceUri: string; + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); + }); + + instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.openDocument("instance.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toEqual([ + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + }); + + test("should update registered schema and re-validate dependent documents", async () => { + // 1. Create initial schema + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "string" } + } + }`); + + // 2. Create instance and resolve initial validation + let instanceUri: string; + const initialValidation = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); + }); + + instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.openDocument("instance.json"); + await expect(initialValidation).resolves.toHaveLength(1); + + // 3. Update the schema to allow a number for "foo" + const updatedDiagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); + }); + + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "number" } + } + }`); + + const updatedDiagnostics = await updatedDiagnosticsPromise; + expect(updatedDiagnostics).toHaveLength(0); + }); + + test("should discover and register self-identifying schemas on startup", async () => { + const startupClient = new TestClient(); + try { + await startupClient.writeDocument("startup-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "bar": { "type": "number" } + } + }`); + + const scanCompletedPromise = new Promise((resolve) => { + startupClient.onNotification("window/logMessage", (params: LogMessageParams) => { + if (params.message.startsWith("Scanning completed")) { + resolve(); + } + }); + }); + + await startupClient.start(); + await scanCompletedPromise; + + let instanceUri: string; + const diagnosticsPromise = new Promise((resolve) => { + startupClient.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); + }); + + instanceUri = await startupClient.writeDocument("instance2.json", `{ + "$schema": "${schemaId}", + "bar": "not a number" + }`); + await startupClient.openDocument("instance2.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toEqual([ + expect.objectContaining({ message: "Expected a ⁨number⁩" }) + ]); + } finally { + await startupClient.stop(); + } + }); + + test("should unregister schema when schema file is deleted", async () => { + // 1. Create schema and wait for it to be registered on the server + const registerPromise = new Promise((resolve) => { + client.onNotification("window/logMessage", (params: LogMessageParams) => { + if (params.message.startsWith("Registered local schema")) { + resolve(); + } + }); + }); + + await client.writeDocument("delete-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "baz": { "type": "boolean" } + } + }`); + await registerPromise; + + // 2. Delete the schema file and wait for unregistration to complete on the server + const unregisterPromise = new Promise((resolve) => { + client.onNotification("window/logMessage", (params: LogMessageParams) => { + if (params.message.startsWith("Unregistered local schema")) { + resolve(); + } + }); + }); + + await client.deleteDocument("delete-schema.json"); + await unregisterPromise; + + // 3. Try to validate an instance against the deleted schema (should fail to load schema) + let instanceUri: string; + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); + }); + + instanceUri = await client.writeDocument("instance3.json", `{ + "$schema": "${schemaId}", + "baz": "true" + }`); + await client.openDocument("instance3.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toHaveLength(1); + }); + }); }); diff --git a/language-server/src/features/SelfIdentifyingSchemas.test.ts b/language-server/src/features/SelfIdentifyingSchemas.test.ts deleted file mode 100644 index 7b4a56c..0000000 --- a/language-server/src/features/SelfIdentifyingSchemas.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { TestClient } from "../test/TestClient.ts"; -import { unregisterSchema } from "@hyperjump/json-schema"; - -import type { Diagnostic, PublishDiagnosticsParams } from "vscode-languageserver"; - -describe("Self-Identifying Schemas", () => { - let client: TestClient; - const schemaId = "https://example.com/my-workspace-schema"; - - beforeEach(async () => { - client = new TestClient(); - await client.start(); - }); - - afterEach(async () => { - await client.stop(); - try { - unregisterSchema(schemaId); - } catch { - // Ignore if not registered - } - }); - - test("should register self-identifying schema and validate document using its $id", async () => { - // 1. Create a self-identifying schema file in the workspace - await client.writeDocument("my-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "foo": { "type": "string" } - } - }`); - - // Wait a brief moment for the watched file event to process - await new Promise((resolve) => setTimeout(resolve, 100)); - - // 2. Create and open an instance file that references the local schema by its $id - const diagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri.endsWith("instance.json")) { - resolve(params.diagnostics); - } - }); - }); - - await client.writeDocument("instance.json", `{ - "$schema": "${schemaId}", - "foo": 42 - }`); - await client.openDocument("instance.json"); - - const diagnostics = await diagnosticsPromise; - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("Expected a ⁨string⁩"); - - // Clear socket queue from duplicate open/watch notifications - await new Promise((resolve) => setTimeout(resolve, 100)); - - // 3. Update the schema to allow a number for "foo" - const updatedDiagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri.endsWith("instance.json")) { - resolve(params.diagnostics); - } - }); - }); - - await client.writeDocument("my-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "foo": { "type": "number" } - } - }`); - - // Wait a brief moment for the watched file event and write to settle on Windows - await new Promise((resolve) => setTimeout(resolve, 100)); - - const updatedDiagnostics = await updatedDiagnosticsPromise; - expect(updatedDiagnostics).toHaveLength(0); - }); - - test("should discover and register self-identifying schemas on startup", async () => { - // Create a new client specifically for this test so we can write the document before starting the server - const startupClient = new TestClient(); - - await startupClient.writeDocument("startup-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "bar": { "type": "number" } - } - }`); - - // Start server, which triggers the startup scan - await startupClient.start(); - - // Wait a brief moment for the startup scan to finish - await new Promise((resolve) => setTimeout(resolve, 200)); - - const diagnosticsPromise = new Promise((resolve) => { - startupClient.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri.endsWith("instance2.json")) { - resolve(params.diagnostics); - } - }); - }); - - await startupClient.writeDocument("instance2.json", `{ - "$schema": "${schemaId}", - "bar": "not a number" - }`); - await startupClient.openDocument("instance2.json"); - - const diagnostics = await diagnosticsPromise; - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("Expected a ⁨number⁩"); - - await startupClient.stop(); - }); - - test("should unregister schema when schema file is deleted", async () => { - // 1. Create schema - await client.writeDocument("delete-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "baz": { "type": "boolean" } - } - }`); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - // 2. Delete the schema file - await client.deleteDocument("delete-schema.json"); - - // Wait for the deletion watched file event to process - await new Promise((resolve) => setTimeout(resolve, 100)); - - // 3. Try to validate an instance against the deleted schema (should fail to load schema) - const diagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri.endsWith("instance3.json")) { - resolve(params.diagnostics); - } - }); - }); - - await client.writeDocument("instance3.json", `{ - "$schema": "${schemaId}", - "baz": "true" - }`); - await client.openDocument("instance3.json"); - - const diagnostics = await diagnosticsPromise; - // Since the schema was unregistered and doesn't exist on the web (fake URL), validation diagnostics won't succeed - expect(diagnostics).toHaveLength(1); - }); -}); diff --git a/language-server/src/features/SelfIdentifyingSchemas.ts b/language-server/src/features/SelfIdentifyingSchemas.ts deleted file mode 100644 index 257b483..0000000 --- a/language-server/src/features/SelfIdentifyingSchemas.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { promises as fs } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import * as jsonc from "jsonc-parser"; -import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; -import { Server } from "../services/Server.ts"; -import { SchemaStore } from "../services/SchemaStore.ts"; -import { Workspace } from "../services/Workspace.ts"; - -import type { FileChangeType } from "vscode-languageserver"; - -export class SelfIdentifyingSchemas { - private server: Server; - private workspace: Workspace; - private schemaStore: SchemaStore; - private registeredSchemas: Map = new Map(); - private workspaceFolders: string[] = []; - - constructor(server: Server, workspace: Workspace, schemaStore: SchemaStore) { - this.server = server; - this.workspace = workspace; - this.schemaStore = schemaStore; - - server.onInitialize((params) => { - if (params.workspaceFolders) { - this.workspaceFolders = params.workspaceFolders.map((folder) => folder.uri); - } else if (params.rootUri) { - this.workspaceFolders = [params.rootUri]; - } - return { capabilities: {} }; - }); - - server.onInitialized(async () => { - await this.scanWorkspace(); - }); - - workspace.onDidChangeWatchedFiles(async (params) => { - for (const change of params.changes) { - const fileUri = change.uri; - if (!fileUri.endsWith(".json") && !fileUri.endsWith(".jsonc")) { - continue; - } - - // change.type is: 1 = Created, 2 = Changed, 3 = Deleted - const changeType = change.type as FileChangeType; - - if (changeType === 3) { - await this.unregister(fileUri); - } else { - await this.processFile(fileUri); - } - } - }); - } - - private async scanWorkspace() { - this.server.console.log("Scanning workspace for self-identifying schemas..."); - for (const folderUri of this.workspaceFolders) { - if (!folderUri.startsWith("file://")) { - continue; - } - const dirPath = fileURLToPath(folderUri); - await this.scanDirectory(dirPath); - } - this.server.console.log(`Scanning completed. Registered ${this.registeredSchemas.size} local schemas.`); - } - - private async scanDirectory(dir: string) { - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === ".git") { - continue; - } - await this.scanDirectory(fullPath); - } else if (entry.isFile()) { - if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) { - const fileUri = pathToFileURL(fullPath).toString(); - await this.processFile(fileUri); - } - } - } - } - - private async processFile(fileUri: string) { - await this.unregister(fileUri); - - if (!fileUri.startsWith("file://")) { - return; - } - - const filePath = fileURLToPath(fileUri); - try { - const text = await fs.readFile(filePath, "utf-8"); - const ast = jsonc.parseTree(text); - if (!ast || ast.type !== "object") { - return; - } - - const schemaNode = jsonc.findNodeAtLocation(ast, ["$schema"]); - const idNode = jsonc.findNodeAtLocation(ast, ["$id"]); - - if ( - schemaNode && schemaNode.type === "string" && idNode && idNode.type === "string" - ) { - const schemaObject = jsonc.parse(text); - const id = idNode.value; - - try { - unregisterSchema(id); - } catch { - // Ignore if not registered - } - - registerSchema(schemaObject, id); - this.registeredSchemas.set(fileUri, { id, fileUri }); - this.schemaStore.registerWorkspaceSchema(fileUri, id); - - this.server.console.log(`Registered local schema: ${id} (from ${fileUri})`); - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.server.console.error(`Failed to process local schema at ${fileUri}: ${message}`); - } - } - - private async unregister(fileUri: string) { - const registered = this.registeredSchemas.get(fileUri); - if (registered) { - try { - unregisterSchema(registered.id); - } catch { - // Ignore if not registered - } - this.registeredSchemas.delete(fileUri); - this.schemaStore.unregisterWorkspaceSchema(fileUri); - - await this.schemaStore.clear(registered.id); - - this.server.console.log(`Unregistered local schema: ${registered.id}`); - } - } -} diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index dbc4c4b..de986ae 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,12 +1,15 @@ +import { promises as fs } from "node:fs"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { compile, getSchema } from "@hyperjump/json-schema/experimental"; -import { unregisterSchema } from "@hyperjump/json-schema"; +import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; import { addUriSchemePlugin, httpSchemePlugin } from "@hyperjump/browser"; import { normalizeIri } from "@hyperjump/uri"; +import * as jsonc from "jsonc-parser"; import * as Pact from "@hyperjump/pact"; import ignore from "ignore"; +import { FileChangeType } from "vscode-languageserver"; import { abbreviateUri } from "../util/utils.ts"; import type { CompiledSchema, EvaluationPlugin } from "@hyperjump/json-schema/experimental"; @@ -68,10 +71,23 @@ export class SchemaStore { addUriSchemePlugin("http", uriSchemePlugin); addUriSchemePlugin("https", uriSchemePlugin); + server.onInitialized(async () => { + await this.scanWorkspace(); + }); + + server.onShutdown(() => { + this.clearAll(); + }); + workspace.onDidChangeWatchedFiles(async (params) => { for (const change of params.changes) { - const changedSchemaUri = normalizeIri(change.uri); - await this.clear(changedSchemaUri); + const fileUri = normalizeIri(change.uri); + if (change.type === FileChangeType.Deleted) { + await this.unregisterWorkspaceSchemaFile(fileUri); + } else { + await this.processWorkspaceSchemaFile(fileUri); + } + await this.clear(fileUri); } }); } @@ -172,4 +188,93 @@ export class SchemaStore { getWorkspaceSchemaId(fileUri: string) { return this.workspaceSchemaUris.get(normalizeIri(fileUri)); } + + private async scanWorkspace() { + this.server.console.log("Scanning workspace for self-identifying schemas..."); + for (const folderUri of this.workspace.workspaceFolders) { + if (!folderUri.startsWith("file://")) { + continue; + } + const dirPath = fileURLToPath(folderUri); + await this.scanDirectory(dirPath); + } + this.server.console.log(`Scanning completed. Registered ${this.workspaceSchemaUris.size} local schemas.`); + } + + private async scanDirectory(dir: string) { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") { + continue; + } + await this.scanDirectory(fullPath); + } else if (entry.isFile()) { + if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) { + const fileUri = normalizeIri(pathToFileURL(fullPath).toString()); + await this.processWorkspaceSchemaFile(fileUri); + } + } + } + } + + private async processWorkspaceSchemaFile(fileUri: string) { + await this.unregisterWorkspaceSchemaFile(fileUri); + + if (!fileUri.startsWith("file://")) { + return; + } + + const filePath = fileURLToPath(fileUri); + try { + const text = await fs.readFile(filePath, "utf-8"); + const ast = jsonc.parseTree(text); + if (ast?.type !== "object") { + return; + } + + const schemaNode = jsonc.findNodeAtLocation(ast, ["$schema"]); + const idNode = jsonc.findNodeAtLocation(ast, ["$id"]); + + if ( + schemaNode?.type === "string" && idNode?.type === "string" + ) { + const schemaObject = jsonc.parse(text); + const id = normalizeIri(idNode.value); + + unregisterSchema(id); + registerSchema(schemaObject, id); + this.registerWorkspaceSchema(fileUri, id); + + this.server.console.log(`Registered local schema: ${id} (from ${fileUri})`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.server.console.error(`Failed to process local schema at ${fileUri}: ${message}`); + } + } + + private async unregisterWorkspaceSchemaFile(fileUri: string) { + const id = this.getWorkspaceSchemaId(fileUri); + if (id) { + unregisterSchema(id); + this.unregisterWorkspaceSchema(fileUri); + await this.clear(id); + this.server.console.log(`Unregistered local schema: ${id}`); + } + } + + private clearAll() { + for (const id of this.workspaceSchemaUris.values()) { + unregisterSchema(id); + } + this.workspaceSchemaUris.clear(); + } } diff --git a/language-server/src/test/TestClient.ts b/language-server/src/test/TestClient.ts index 86363b5..24726d4 100644 --- a/language-server/src/test/TestClient.ts +++ b/language-server/src/test/TestClient.ts @@ -250,7 +250,7 @@ export class TestClient { if (this.watchEnabled) { await this.client.sendNotification(DidChangeWatchedFilesNotification.type, { changes: [{ - type: 3, // FileChangeType.Deleted + type: FileChangeType.Deleted, uri: fullUri }] }); From 3d517ad9449f9c25301b7c55f69d6d6fc24d91b2 Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Fri, 10 Jul 2026 22:17:23 -0700 Subject: [PATCH 5/6] Cleanup --- language-server/src/features/Hover.test.ts | 4 +- .../src/features/SchemaValidation.test.ts | 392 +++++++++--------- language-server/src/models/JsonDocument.ts | 15 +- language-server/src/services/SchemaStore.ts | 106 ++--- 4 files changed, 230 insertions(+), 287 deletions(-) diff --git a/language-server/src/features/Hover.test.ts b/language-server/src/features/Hover.test.ts index 4e1e4bb..733021e 100644 --- a/language-server/src/features/Hover.test.ts +++ b/language-server/src/features/Hover.test.ts @@ -426,8 +426,8 @@ _hyperjump-json-language-server_` }); test("hover with an invalid schema", async () => { - const diagnostics = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", () => { + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, () => { resolve(); }); }); diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 3ed0433..239015a 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -1,9 +1,8 @@ import { describe, test, expect, afterEach, beforeEach } from "vitest"; import { PublishDiagnosticsNotification } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; -import { unregisterSchema } from "@hyperjump/json-schema"; -import type { Diagnostic, PublishDiagnosticsParams, LogMessageParams } from "vscode-languageserver"; +import type { Diagnostic } from "vscode-languageserver"; describe("Schema Validation", () => { let client: TestClient; @@ -11,25 +10,13 @@ describe("Schema Validation", () => { beforeEach(async () => { client = new TestClient(); - const scanCompletedPromise = new Promise((resolve) => { - client.onNotification("window/logMessage", (params: LogMessageParams) => { - if (params.message.startsWith("Scanning completed")) { - resolve(); - } - }); - }); await client.start(); - await scanCompletedPromise; }); afterEach(async () => { await client.stop(); }); - afterEach(() => { - unregisterSchema(fixtureSchemaUri); - }); - test("JSON Validation using Hyperjump - Valid Case", async () => { const diagnostics: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { @@ -313,9 +300,6 @@ describe("Schema Validation", () => { await expect(initialValidation).resolves.toHaveLength(1); - // Clear socket queue from duplicate open/watch notifications - await new Promise((resolve) => setTimeout(resolve, 100)); - const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { if (params.uri === instanceUri) { @@ -358,9 +342,6 @@ describe("Schema Validation", () => { await expect(initialValidation).resolves.toHaveLength(1); - // Clear socket queue from duplicate open/watch notifications - await new Promise((resolve) => setTimeout(resolve, 100)); - const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { if (params.uri === instanceUri) { @@ -409,9 +390,6 @@ describe("Schema Validation", () => { await expect(initialValidation).resolves.toHaveLength(1); - // Clear socket queue from duplicate open/watch notifications - await new Promise((resolve) => setTimeout(resolve, 100)); - const secondValidation: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, (params) => { if (params.uri === instanceUri) { @@ -735,189 +713,215 @@ describe("Schema Validation", () => { ]); }); - describe("Self-Identifying Schemas", () => { + test("should register self-identifying schema and validate document using its $id", async () => { const schemaId = "https://example.com/my-workspace-schema"; - test("should register self-identifying schema and validate document using its $id", async () => { - // 1. Create a self-identifying schema file in the workspace - await client.writeDocument("my-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "foo": { "type": "string" } - } - }`); - - // 2. Create and open an instance file that references the local schema by its $id - let instanceUri: string; - const diagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri === instanceUri) { - resolve(params.diagnostics); - } - }); - }); - - instanceUri = await client.writeDocument("instance.json", `{ - "$schema": "${schemaId}", - "foo": 42 - }`); - await client.openDocument("instance.json"); - - const diagnostics = await diagnosticsPromise; - expect(diagnostics).toEqual([ - expect.objectContaining({ message: "Expected a ⁨string⁩" }) - ]); - }); - - test("should update registered schema and re-validate dependent documents", async () => { - // 1. Create initial schema - await client.writeDocument("my-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "foo": { "type": "string" } + // 1. Create a self-identifying schema file in the workspace + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "string" } + } + }`); + + // 2. Create and open an instance file that references the local schema by its $id + let instanceUri: string; + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); } - }`); - - // 2. Create instance and resolve initial validation - let instanceUri: string; - const initialValidation = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri === instanceUri) { - resolve(params.diagnostics); - } - }); - }); - - instanceUri = await client.writeDocument("instance.json", `{ - "$schema": "${schemaId}", - "foo": 42 - }`); - await client.openDocument("instance.json"); - await expect(initialValidation).resolves.toHaveLength(1); - - // 3. Update the schema to allow a number for "foo" - const updatedDiagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri === instanceUri) { - resolve(params.diagnostics); - } - }); - }); - - await client.writeDocument("my-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "foo": { "type": "number" } + }); + }); + + instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.openDocument("instance.json"); + + await expect(diagnostics).resolves.toEqual([ + { + message: "Expected a ⁨string⁩", + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 15 } + }, + severity: 1, + source: "hyperjump-json-language-server" + } + ]); + }); + + test("should update registered schema and re-validate dependent documents", async () => { + const schemaId = "https://example.com/my-workspace-schema"; + + // 1. Create initial schema + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "string" } + } + }`); + + // 2. Create instance and resolve initial validation + const instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + const initialValidation: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); } - }`); - - const updatedDiagnostics = await updatedDiagnosticsPromise; - expect(updatedDiagnostics).toHaveLength(0); - }); - - test("should discover and register self-identifying schemas on startup", async () => { - const startupClient = new TestClient(); - try { - await startupClient.writeDocument("startup-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "bar": { "type": "number" } - } - }`); - - const scanCompletedPromise = new Promise((resolve) => { - startupClient.onNotification("window/logMessage", (params: LogMessageParams) => { - if (params.message.startsWith("Scanning completed")) { - resolve(); - } - }); - }); - - await startupClient.start(); - await scanCompletedPromise; - - let instanceUri: string; - const diagnosticsPromise = new Promise((resolve) => { - startupClient.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri === instanceUri) { - resolve(params.diagnostics); - } - }); - }); - - instanceUri = await startupClient.writeDocument("instance2.json", `{ - "$schema": "${schemaId}", - "bar": "not a number" - }`); - await startupClient.openDocument("instance2.json"); - - const diagnostics = await diagnosticsPromise; - expect(diagnostics).toEqual([ - expect.objectContaining({ message: "Expected a ⁨number⁩" }) - ]); - } finally { - await startupClient.stop(); - } - }); - - test("should unregister schema when schema file is deleted", async () => { - // 1. Create schema and wait for it to be registered on the server - const registerPromise = new Promise((resolve) => { - client.onNotification("window/logMessage", (params: LogMessageParams) => { - if (params.message.startsWith("Registered local schema")) { - resolve(); - } - }); - }); - - await client.writeDocument("delete-schema.json", `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "${schemaId}", - "type": "object", - "properties": { - "baz": { "type": "boolean" } + }); + }); + await client.openDocument("instance.json"); + + await expect(initialValidation).resolves.toEqual([ + { + message: "Expected a ⁨string⁩", + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 15 } + }, + severity: 1, + source: "hyperjump-json-language-server" + } + ]); + + // 3. Update the schema to allow a number for "foo" + const updatedDiagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); } - }`); - await registerPromise; + }); + }); + + await client.writeDocument("my-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "foo": { "type": "number" } + } + }`); + + await expect(updatedDiagnostics).resolves.toEqual([]); + }); + + test("should unregister schema when schema file is deleted", async () => { + const schemaId = "https://example.com/my-workspace-schema"; - // 2. Delete the schema file and wait for unregistration to complete on the server - const unregisterPromise = new Promise((resolve) => { - client.onNotification("window/logMessage", (params: LogMessageParams) => { - if (params.message.startsWith("Unregistered local schema")) { - resolve(); - } - }); + // 1. Create schema and wait for it to be registered on the server + await client.writeDocument("delete-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "baz": { "type": "boolean" } + } + }`); + + const instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "baz": "true" + }`); + const diagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } }); + }); + await client.openDocument("instance.json"); - await client.deleteDocument("delete-schema.json"); - await unregisterPromise; + await expect(diagnostics).resolves.toEqual([ + { + message: "Expected a ⁨boolean⁩", + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 19 } + }, + severity: 1, + source: "hyperjump-json-language-server" + } + ]); - // 3. Try to validate an instance against the deleted schema (should fail to load schema) - let instanceUri: string; - const diagnosticsPromise = new Promise((resolve) => { - client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => { - if (params.uri === instanceUri) { - resolve(params.diagnostics); - } - }); + // 2. Delete the schema file and wait for unregistration to complete on the server + const updatedDiagnostics: Promise = new Promise((resolve) => { + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } }); + }); + await client.deleteDocument("delete-schema.json"); + + // 3. Try to validate an instance against the deleted schema (should fail to load schema) + await expect(updatedDiagnostics).resolves.toEqual([ + { + message: `Unable to load resource '${schemaId}'.`, + range: { + start: { line: 1, character: 17 }, + end: { line: 1, character: 58 } + }, + severity: 1, + source: "hyperjump-json-language-server" + } + ]); + }); +}); + +describe("Workspace scan", async () => { + let client: TestClient | undefined; - instanceUri = await client.writeDocument("instance3.json", `{ - "$schema": "${schemaId}", - "baz": "true" - }`); - await client.openDocument("instance3.json"); + afterEach(async () => { + await client?.stop(); + }); - const diagnostics = await diagnosticsPromise; - expect(diagnostics).toHaveLength(1); + test("should discover and register self-identifying schemas on startup", async () => { + const schemaId = "https://example.com/my-workspace-schema"; + + client = new TestClient(); + await client.writeDocument("startup-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "type": "object", + "properties": { + "bar": { "type": "number" } + } + }`); + await client.start(); + + const instanceUri = await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "bar": "not a number" + }`); + const diagnostics: Promise = new Promise((resolve) => { + client?.onNotification(PublishDiagnosticsNotification.type, (params) => { + if (params.uri === instanceUri) { + resolve(params.diagnostics); + } + }); }); + await client.openDocument("instance.json"); + + await expect(diagnostics).resolves.toEqual([ + { + message: "Expected a ⁨number⁩", + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 27 } + }, + severity: 1, + source: "hyperjump-json-language-server" + } + ]); }); }); diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index 59e855d..dbda34c 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 * as JsonPointer from "@hyperjump/json-pointer"; -import { resolveIri, normalizeIri } from "@hyperjump/uri"; +import { resolveIri } from "@hyperjump/uri"; import { SchemaStore } from "../services/SchemaStore.ts"; import { Server } from "../services/Server.ts"; import { MatchingSchemaCollector } from "../services/MatchingSchemaCollector.ts"; @@ -76,18 +76,9 @@ export class JsonDocument implements TextDocument { return false; } - const normalizedSchemaUri = normalizeIri(schemaUri); - const normalizedChangedUri = normalizeIri(changedUri); + const dependentSchemaUris = await this.schemaStore.getDependentSchemaUris(schemaUri); - const dependentSchemaUris = await this.schemaStore.getDependentSchemaUris(normalizedSchemaUri); - - if (dependentSchemaUris === undefined) { - return true; - } - - const changedSchemaId = this.schemaStore.getWorkspaceSchemaId(normalizedChangedUri); - - return dependentSchemaUris.has(normalizedChangedUri) || (changedSchemaId !== undefined && (normalizedSchemaUri === changedSchemaId || dependentSchemaUris.has(changedSchemaId))); + return dependentSchemaUris === undefined || dependentSchemaUris.has(changedUri); } get uri() { diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index de986ae..db49184 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -31,6 +31,8 @@ export class SchemaStore { private workspace: Workspace; private compiledSchemaCache: Map> = new Map(); private catalog: Promise; + private workspaceSchemaUris: Map = new Map(); + private scanCompleted: Promise; constructor(server: Server, workspace: Workspace) { this.server = server; @@ -50,6 +52,13 @@ export class SchemaStore { }); }); + this.scanCompleted = new Promise((resolve) => { + server.onInitialized(async () => { + await this.scanWorkspace(); + resolve(); + }); + }); + const schemaAllowList = this.catalog.then((catalog) => { return Pact.pipe( catalog, @@ -71,23 +80,13 @@ export class SchemaStore { addUriSchemePlugin("http", uriSchemePlugin); addUriSchemePlugin("https", uriSchemePlugin); - server.onInitialized(async () => { - await this.scanWorkspace(); - }); - - server.onShutdown(() => { - this.clearAll(); - }); - workspace.onDidChangeWatchedFiles(async (params) => { for (const change of params.changes) { - const fileUri = normalizeIri(change.uri); - if (change.type === FileChangeType.Deleted) { - await this.unregisterWorkspaceSchemaFile(fileUri); - } else { - await this.processWorkspaceSchemaFile(fileUri); + const changedSchemaUri = normalizeIri(change.uri); + await this.clear(changedSchemaUri); + if (change.type !== FileChangeType.Deleted) { + await this.processWorkspaceSchemaFile(changedSchemaUri); } - await this.clear(fileUri); } }); } @@ -117,6 +116,8 @@ export class SchemaStore { } async validate(schemaUri: string, instance: Json, instanceUri: string, plugins: EvaluationPlugin[] = []) { + await this.scanCompleted; + if (!this.compiledSchemaCache.has(schemaUri)) { this.compiledSchemaCache.set(schemaUri, (async function (server) { const startTime = performance.now(); @@ -144,17 +145,11 @@ export class SchemaStore { } async clear(schemaUri: string) { - const normalizedUri = normalizeIri(schemaUri); - try { - unregisterSchema(normalizedUri); - } catch { - // Ignore if not registered - } for (const [cachedSchemaUri, compiledSchema] of this.compiledSchemaCache) { try { - const normalizedCachedUri = normalizeIri(cachedSchemaUri); const dependentSchemas = this.getDependenencies(await compiledSchema); - if (normalizedCachedUri !== normalizedUri && !dependentSchemas.has(normalizedUri)) { + const actualSchemaUri = this.workspaceSchemaUris.get(schemaUri) ?? schemaUri; + if (!dependentSchemas.has(actualSchemaUri)) { continue; } } catch { @@ -162,6 +157,8 @@ export class SchemaStore { this.server.console.log(`clear schema cache for ${abbreviateUri(cachedSchemaUri)}`); this.compiledSchemaCache.delete(cachedSchemaUri); + unregisterSchema(cachedSchemaUri); + this.workspaceSchemaUris.delete(cachedSchemaUri); } } @@ -169,47 +166,23 @@ export class SchemaStore { const dependentSchemas = new Set(); for (const key of Object.keys(compiledSchema.ast)) { if (key !== "metaData" && key !== "plugins") { - dependentSchemas.add(normalizeIri(key.split("#")[0])); + dependentSchemas.add(key.split("#")[0]); } } return dependentSchemas; } - private workspaceSchemaUris: Map = new Map(); - - registerWorkspaceSchema(fileUri: string, id: string) { - this.workspaceSchemaUris.set(normalizeIri(fileUri), normalizeIri(id)); - } - - unregisterWorkspaceSchema(fileUri: string) { - this.workspaceSchemaUris.delete(normalizeIri(fileUri)); - } - - getWorkspaceSchemaId(fileUri: string) { - return this.workspaceSchemaUris.get(normalizeIri(fileUri)); - } - private async scanWorkspace() { this.server.console.log("Scanning workspace for self-identifying schemas..."); for (const folderUri of this.workspace.workspaceFolders) { - if (!folderUri.startsWith("file://")) { - continue; - } const dirPath = fileURLToPath(folderUri); await this.scanDirectory(dirPath); } - this.server.console.log(`Scanning completed. Registered ${this.workspaceSchemaUris.size} local schemas.`); + this.server.console.log("Scanning completed"); } private async scanDirectory(dir: string) { - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".git") { @@ -218,7 +191,7 @@ export class SchemaStore { await this.scanDirectory(fullPath); } else if (entry.isFile()) { if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) { - const fileUri = normalizeIri(pathToFileURL(fullPath).toString()); + const fileUri = pathToFileURL(fullPath).toString(); await this.processWorkspaceSchemaFile(fileUri); } } @@ -226,12 +199,6 @@ export class SchemaStore { } private async processWorkspaceSchemaFile(fileUri: string) { - await this.unregisterWorkspaceSchemaFile(fileUri); - - if (!fileUri.startsWith("file://")) { - return; - } - const filePath = fileURLToPath(fileUri); try { const text = await fs.readFile(filePath, "utf-8"); @@ -243,15 +210,13 @@ export class SchemaStore { const schemaNode = jsonc.findNodeAtLocation(ast, ["$schema"]); const idNode = jsonc.findNodeAtLocation(ast, ["$id"]); - if ( - schemaNode?.type === "string" && idNode?.type === "string" - ) { + if (schemaNode?.type === "string" && idNode?.type === "string") { const schemaObject = jsonc.parse(text); - const id = normalizeIri(idNode.value); + const id = idNode.value; unregisterSchema(id); - registerSchema(schemaObject, id); - this.registerWorkspaceSchema(fileUri, id); + registerSchema(schemaObject); + this.workspaceSchemaUris.set(fileUri, id); this.server.console.log(`Registered local schema: ${id} (from ${fileUri})`); } @@ -260,21 +225,4 @@ export class SchemaStore { this.server.console.error(`Failed to process local schema at ${fileUri}: ${message}`); } } - - private async unregisterWorkspaceSchemaFile(fileUri: string) { - const id = this.getWorkspaceSchemaId(fileUri); - if (id) { - unregisterSchema(id); - this.unregisterWorkspaceSchema(fileUri); - await this.clear(id); - this.server.console.log(`Unregistered local schema: ${id}`); - } - } - - private clearAll() { - for (const id of this.workspaceSchemaUris.values()) { - unregisterSchema(id); - } - this.workspaceSchemaUris.clear(); - } } From 394b701526493afb585c0d9033a1a834eb5cf324 Mon Sep 17 00:00:00 2001 From: Suyog Habbu Date: Sun, 12 Jul 2026 10:57:32 +0530 Subject: [PATCH 6/6] chore: address review feedback on workspace scanning and schema parsing - Added gitignore filtering and native globbing to workspace scanning. - Resolved schema ID keywords dynamically based on the dialect. - Optimized parsing to parse the document only once. - Added test coverage for scanning errors and cleaned up test types. --- .../src/features/SchemaValidation.test.ts | 24 ++++++ language-server/src/services/SchemaStore.ts | 83 +++++++++++++------ language-server/src/test/TestClient.ts | 8 +- 3 files changed, 86 insertions(+), 29 deletions(-) diff --git a/language-server/src/features/SchemaValidation.test.ts b/language-server/src/features/SchemaValidation.test.ts index 239015a..08939ca 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -2,6 +2,10 @@ import { describe, test, expect, afterEach, beforeEach } from "vitest"; import { PublishDiagnosticsNotification } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + import type { Diagnostic } from "vscode-languageserver"; describe("Schema Validation", () => { @@ -924,4 +928,24 @@ describe("Workspace scan", async () => { } ]); }); + + test("should handle and log error when processing invalid local schema on startup", async () => { + client = new TestClient(); + + const workspacePath = fileURLToPath(await client.workspaceFolder); + await fs.mkdir(join(workspacePath, "startup-broken-schema.json")); + + const errorLoggedPromise = new Promise((resolve) => { + client?.onNotification("window/logMessage", (params: any) => { + if (params.message.includes("Failed to process local schema at")) { + resolve(params.message); + } + }); + }); + + await client.start(); + + const loggedMessage = await errorLoggedPromise; + expect(loggedMessage).toContain("Failed to process local schema at"); + }); }); diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index db49184..3d6fbba 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { compile, getSchema } from "@hyperjump/json-schema/experimental"; +import { compile, getSchema, getKeywordName } from "@hyperjump/json-schema/experimental"; import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; import { addUriSchemePlugin, httpSchemePlugin } from "@hyperjump/browser"; @@ -176,49 +176,78 @@ export class SchemaStore { this.server.console.log("Scanning workspace for self-identifying schemas..."); for (const folderUri of this.workspace.workspaceFolders) { const dirPath = fileURLToPath(folderUri); - await this.scanDirectory(dirPath); - } - this.server.console.log("Scanning completed"); - } - private async scanDirectory(dir: string) { - for (const entry of await fs.readdir(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === ".git") { - continue; - } - await this.scanDirectory(fullPath); - } else if (entry.isFile()) { - if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) { + const ig = ignore().add([".git", "node_modules"]); + try { + const gitignorePath = path.join(dirPath, ".gitignore"); + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); + ig.add(gitignoreContent); + } catch { + // Ignore if .gitignore does not exist + } + + const globFn = (fs as any).glob; + if (globFn) { + const globOptions = { + cwd: dirPath, + exclude: (entry: any) => entry.name === "node_modules" || entry.name === ".git" + }; + for await (const entry of globFn("**/*.{json,jsonc}", globOptions)) { + if (ig.ignores(entry)) { + continue; + } + const fullPath = path.join(dirPath, entry); const fileUri = pathToFileURL(fullPath).toString(); await this.processWorkspaceSchemaFile(fileUri); } + } else { + const scan = async (dir: string) => { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(dirPath, fullPath); + if (ig.ignores(relativePath)) { + continue; + } + if (entry.isDirectory()) { + await scan(fullPath); + } else if (entry.isFile()) { + if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) { + const fileUri = pathToFileURL(fullPath).toString(); + await this.processWorkspaceSchemaFile(fileUri); + } + } + } + }; + await scan(dirPath); } } + this.server.console.log("Scanning completed"); } private async processWorkspaceSchemaFile(fileUri: string) { const filePath = fileURLToPath(fileUri); try { const text = await fs.readFile(filePath, "utf-8"); - const ast = jsonc.parseTree(text); - if (ast?.type !== "object") { + const schemaObject = jsonc.parse(text); + if (typeof schemaObject !== "object" || schemaObject === null || Array.isArray(schemaObject)) { return; } - const schemaNode = jsonc.findNodeAtLocation(ast, ["$schema"]); - const idNode = jsonc.findNodeAtLocation(ast, ["$id"]); - - if (schemaNode?.type === "string" && idNode?.type === "string") { - const schemaObject = jsonc.parse(text); - const id = idNode.value; + const dialectId = schemaObject["$schema"]; + if (typeof dialectId === "string") { + const idKeyword = getKeywordName(dialectId, "https://json-schema.org/keyword/id") + || getKeywordName(dialectId, "https://json-schema.org/keyword/draft-04/id"); - unregisterSchema(id); - registerSchema(schemaObject); - this.workspaceSchemaUris.set(fileUri, id); + if (idKeyword) { + const id = schemaObject[idKeyword]; + if (typeof id === "string") { + unregisterSchema(id); + registerSchema(schemaObject); + this.workspaceSchemaUris.set(fileUri, id); - this.server.console.log(`Registered local schema: ${id} (from ${fileUri})`); + this.server.console.log(`Registered local schema: ${id} (from ${fileUri})`); + } + } } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/language-server/src/test/TestClient.ts b/language-server/src/test/TestClient.ts index 24726d4..24b62a6 100644 --- a/language-server/src/test/TestClient.ts +++ b/language-server/src/test/TestClient.ts @@ -38,7 +38,7 @@ export class TestClient { private languageServerSettings: Partial | undefined; private configurationChangeNotificationOptions: DidChangeConfigurationRegistrationOptions | null | undefined; private openDocuments: Set; - private workspaceFolder: Promise; + private _workspaceFolder: Promise; onRequest: Connection["onRequest"]; sendRequest: Connection["sendRequest"]; @@ -52,7 +52,7 @@ export class TestClient { this.serverName = serverName; this.watchEnabled = false; this.openDocuments = new Set(); - this.workspaceFolder = mkdtemp(join(tmpdir(), "test-workspace-")) + this._workspaceFolder = mkdtemp(join(tmpdir(), "test-workspace-")) .then((path) => pathToFileURL(path) + "/"); this.mockAgent = new MockAgent(); @@ -107,6 +107,10 @@ export class TestClient { return structuredClone(this._serverCapabilities); } + public get workspaceFolder(): Promise { + return this._workspaceFolder; + } + async start(params: Partial = {}) { const defaultInitParams: InitializeParams = { processId: null,