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 db423c2..08939ca 100644 --- a/language-server/src/features/SchemaValidation.test.ts +++ b/language-server/src/features/SchemaValidation.test.ts @@ -1,7 +1,10 @@ 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 { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Diagnostic } from "vscode-languageserver"; @@ -18,10 +21,6 @@ describe("Schema Validation", () => { 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) => { @@ -296,7 +295,7 @@ describe("Schema Validation", () => { } }`); - await client.writeDocument("instance.json", `{ + const instanceUri = await client.writeDocument("instance.json", `{ "$schema": "${fixtureSchemaUri}", "name": "Alice", "age" : "not a number" @@ -307,7 +306,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); + } }); }); @@ -715,4 +716,236 @@ describe("Schema Validation", () => { } ]); }); + + test("should register self-identifying schema and validate document using its $id", async () => { + const schemaId = "https://example.com/my-workspace-schema"; + + // 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); + } + }); + }); + + 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); + } + }); + }); + 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 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"; + + // 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 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" + } + ]); + + // 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; + + afterEach(async () => { + await client?.stop(); + }); + + 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" + } + ]); + }); + + 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 4ec7c70..3d6fbba 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,11 +1,15 @@ +import { promises as fs } from "node:fs"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { compile, getSchema } from "@hyperjump/json-schema/experimental"; +import { fileURLToPath, pathToFileURL } from "node:url"; +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"; 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"; @@ -27,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; @@ -46,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,6 +84,9 @@ export class SchemaStore { for (const change of params.changes) { const changedSchemaUri = normalizeIri(change.uri); await this.clear(changedSchemaUri); + if (change.type !== FileChangeType.Deleted) { + await this.processWorkspaceSchemaFile(changedSchemaUri); + } } }); } @@ -100,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(); @@ -130,7 +148,8 @@ export class SchemaStore { for (const [cachedSchemaUri, compiledSchema] of this.compiledSchemaCache) { try { const dependentSchemas = this.getDependenencies(await compiledSchema); - if (!dependentSchemas.has(schemaUri)) { + const actualSchemaUri = this.workspaceSchemaUris.get(schemaUri) ?? schemaUri; + if (!dependentSchemas.has(actualSchemaUri)) { continue; } } catch { @@ -138,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); } } @@ -150,4 +171,87 @@ export class SchemaStore { } return dependentSchemas; } + + private async scanWorkspace() { + this.server.console.log("Scanning workspace for self-identifying schemas..."); + for (const folderUri of this.workspace.workspaceFolders) { + const dirPath = fileURLToPath(folderUri); + + 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 schemaObject = jsonc.parse(text); + if (typeof schemaObject !== "object" || schemaObject === null || Array.isArray(schemaObject)) { + return; + } + + 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"); + + 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})`); + } + } + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.server.console.error(`Failed to process local schema at ${fileUri}: ${message}`); + } + } } diff --git a/language-server/src/test/TestClient.ts b/language-server/src/test/TestClient.ts index 54fcf65..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, @@ -245,8 +249,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: FileChangeType.Deleted, + uri: fullUri + }] + }); + } return fullUri; }