From 8f7554da60fc33dd98c5a6feab2e0da9b9cd5a69 Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Tue, 21 Apr 2026 17:17:30 +0300 Subject: [PATCH 1/7] feat(dependencies): extract workspace dependencies (#17) --- language-server/src/build-server.js | 4 +- .../src/features/validate-workspace.js | 9 +- language-server/src/services/dependencies.js | 98 +++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 language-server/src/services/dependencies.js diff --git a/language-server/src/build-server.js b/language-server/src/build-server.js index 288c07d9..b7e863d0 100644 --- a/language-server/src/build-server.js +++ b/language-server/src/build-server.js @@ -2,6 +2,7 @@ import { Server } from "./services/server.js"; import { Configuration } from "./services/configuration.js"; import { Schemas } from "./services/schemas.js"; +import { Dependencies } from "./services/dependencies.js"; // Features import { CompletionFeature } from "./features/completion/completion.js"; @@ -61,7 +62,8 @@ export const buildServer = (connection) => { // TODO: It's awkward that validateSchema needs a variable const validateSchema = new ValidateSchemaFeature(server, schemas, diagnostics); - new ValidateWorkspaceFeature(server, schemas, configuration, validateSchema); + const dependencies = new Dependencies(server, schemas); + new ValidateWorkspaceFeature(server, schemas, configuration, validateSchema, dependencies); new CompletionFeature(server, schemas, [ new SchemaCompletionProvider(), diff --git a/language-server/src/features/validate-workspace.js b/language-server/src/features/validate-workspace.js index 5f95d434..03101708 100644 --- a/language-server/src/features/validate-workspace.js +++ b/language-server/src/features/validate-workspace.js @@ -7,6 +7,7 @@ import { hasDialect } from "@hyperjump/json-schema/experimental"; * @import { Schemas } from "../services/schemas.js"; * @import { Configuration } from "../services/configuration.js"; * @import { ValidateSchemaFeature } from "./validate-schema.js"; + * @import { Dependencies } from "../services/dependencies.js"; */ @@ -15,18 +16,21 @@ export class ValidateWorkspaceFeature { #schemas; #configuration; #validateSchema; + #dependencies; /** * @param {Server} server * @param {Schemas} schemas * @param {Configuration} configuration * @param {ValidateSchemaFeature} validateSchema + * @param {Dependencies} dependencies */ - constructor(server, schemas, configuration, validateSchema) { + constructor(server, schemas, configuration, validateSchema, dependencies) { this.#server = server; this.#schemas = schemas; this.#configuration = configuration; this.#validateSchema = validateSchema; + this.#dependencies = dependencies; // eslint-disable-next-line @typescript-eslint/no-misused-promises this.#schemas.onDidChangeWatchedFiles(async (params) => { @@ -76,6 +80,9 @@ export class ValidateWorkspaceFeature { await this.#validateSchema.validateSchema(schemaDocument); } + await this.#dependencies.build(); + this.#dependencies.print(); + await this.#server.sendRequest(SemanticTokensRefreshRequest.type); reporter.done(); diff --git a/language-server/src/services/dependencies.js b/language-server/src/services/dependencies.js new file mode 100644 index 00000000..a5876092 --- /dev/null +++ b/language-server/src/services/dependencies.js @@ -0,0 +1,98 @@ +import { resolveIri, toAbsoluteUri } from "../util/util.js"; +import { value } from "../model/schema-node.js"; + +/** + * @import { Server } from "./server.js"; + * @import { Schemas } from "./schemas.js"; + */ + +/** + * @typedef {Object} DependencyRecord + * @property {string} uri + * @property {Set} dependencies + * @property {Set} dependents + */ + +export class Dependencies { + #server; + #schemas; + /** @type {Map} */ + #records; + + /** + * @param {Server} server + * @param {Schemas} schemas + */ + constructor(server, schemas) { + this.#server = server; + this.#schemas = schemas; + this.#records = new Map(); + } + + async build() { + this.#server.console.log("Extracting Dependencies"); + this.#records.clear(); + + for await (const schemaDocument of this.#schemas.all()) { + const uri = schemaDocument.textDocument.uri; + const dependent = this.#getOrCreateRecord(uri); + for (const schemaResource of schemaDocument.schemaResources) { + if (schemaResource.dialectUri) { + this.#addDependencyIfLocal(dependent, schemaResource.dialectUri); + } + for (const reference of this.#schemas.references(schemaResource)) { + /** @type {string} */ + const referenceValue = value(reference); + const referencedUri = toAbsoluteUri(resolveIri(referenceValue, schemaResource.baseUri)); + this.#addDependencyIfLocal(dependent, referencedUri); + } + } + } + } + + /** + * @param {DependencyRecord} dependent + * @param {string} dependencyUri + */ + #addDependencyIfLocal(dependent, dependencyUri) { + // NOTE: Tracks only local filesystem schemas. + // If a schema URI (e.g https://...) does not have a corresponding document, + // we can't resolve its path, we can't watch it for changes, so tracking it adds no value. + const dependencyDocument = this.#schemas.getBySchemaUri(dependencyUri); + if (!dependencyDocument) { + return; + } + const localDependencyUri = dependencyDocument.textDocument.uri; + const dependency = this.#getOrCreateRecord(localDependencyUri); + dependent.dependencies.add(localDependencyUri); + dependency.dependents.add(dependent.uri); + } + + /** + * @param {string} uri + * @returns {DependencyRecord} + */ + #getOrCreateRecord(uri) { + let dependencyRecord = this.#records.get(uri); + + if (!dependencyRecord) { + dependencyRecord = { + uri, + dependencies: new Set(), + dependents: new Set() + }; + this.#records.set(uri, dependencyRecord); + } + + return dependencyRecord; + } + + print() { + for (const [key, value] of this.#records) { + const dependencies = value.dependencies; + for (const dependency of dependencies) { + this.#server.console.log(`${key} -> ${dependency}`); + } + } + } +} From 8dc0aaeffd8d51f96e9d8b171f7414ec0e4f3907 Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Thu, 23 Apr 2026 14:14:18 +0300 Subject: [PATCH 2/7] feat(validate-workspace): implement incremental validation (#17) --- .../src/features/validate-workspace.js | 47 ++++++++++++++++++- language-server/src/services/dependencies.js | 26 ++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/language-server/src/features/validate-workspace.js b/language-server/src/features/validate-workspace.js index 03101708..ca55c6a5 100644 --- a/language-server/src/features/validate-workspace.js +++ b/language-server/src/features/validate-workspace.js @@ -8,6 +8,8 @@ import { hasDialect } from "@hyperjump/json-schema/experimental"; * @import { Configuration } from "../services/configuration.js"; * @import { ValidateSchemaFeature } from "./validate-schema.js"; * @import { Dependencies } from "../services/dependencies.js"; + * @import { FileEvent } from "vscode-languageserver" + * @import { SchemaDocument } from "../model/schema-document.js" */ @@ -75,8 +77,15 @@ export class ValidateWorkspaceFeature { await this.#schemas.getOpen(schemaUri, true); } - // Re/validate all schemas - for await (const schemaDocument of this.#schemas.all()) { + // NOTE: When the workspace is first loaded, we need to validate all schemas + const shouldValidateWorkspace = changes.length === 0; + // NOTE: We find the affected schemas before rebuilding the dependencies. + // e.g. If A depends on B, and B is deleted, and we build the dependencies first, + // we would have already removed B from the workspace, so the graph would have no dependents. + const affectedSchemas = shouldValidateWorkspace ? this.#schemas.all() : this.#findAffectedSchemas(changes); + + // Re/validate affected schemas + for await (const schemaDocument of affectedSchemas) { await this.#validateSchema.validateSchema(schemaDocument); } @@ -87,4 +96,38 @@ export class ValidateWorkspaceFeature { reporter.done(); } + + /** + * @param {FileEvent[]} changes + * @returns {AsyncGenerator} + */ + async* #findAffectedSchemas(changes) { + const affectedUris = this.#findAffectedUris(changes); + for (const uri of affectedUris) { + const schemaDocument = await this.#schemas.get(uri); + if (schemaDocument) { + yield schemaDocument; + } + } + } + + /** + * @param {FileEvent[]} changes + * @returns {Set} + */ + #findAffectedUris(changes) { + /** @type {Set} */ + const affectedUris = new Set(); + for (const change of changes) { + if (change.type !== FileChangeType.Deleted) { + // NOTE: When a file is deleted, we don't need to revalidate it, as it will be removed from the workspace + affectedUris.add(change.uri); + } + const dependents = this.#dependencies.findDependents(change.uri); + for (const dependent of dependents) { + affectedUris.add(dependent); + } + } + return affectedUris; + } } diff --git a/language-server/src/services/dependencies.js b/language-server/src/services/dependencies.js index a5876092..e50e1b73 100644 --- a/language-server/src/services/dependencies.js +++ b/language-server/src/services/dependencies.js @@ -50,6 +50,32 @@ export class Dependencies { } } + /** + * @param {string} uri + * @returns {DependencyRecord | undefined} + */ + get(uri) { + return this.#records.get(uri); + } + + /** + * @param {string} uri + * @param {Set} dependents + * @returns {Set} + */ + findDependents(uri, dependents = new Set()) { + const dependency = this.get(uri); + if (!dependency) return dependents; + + for (const dependent of dependency.dependents) { + if (dependents.has(dependent)) continue; + dependents.add(dependent); + this.findDependents(dependent, dependents); + } + + return dependents; + } + /** * @param {DependencyRecord} dependent * @param {string} dependencyUri From 3fd9cb21843f0a15c5a72b8e7499584856f0d900 Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Thu, 23 Apr 2026 14:14:38 +0300 Subject: [PATCH 3/7] test(schemas): fix test expectations after incremental validation (#17) --- language-server/src/services/schemas-neovim.test.ts | 7 +++---- language-server/src/services/schemas.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/language-server/src/services/schemas-neovim.test.ts b/language-server/src/services/schemas-neovim.test.ts index 2e0c6b58..46065ea3 100644 --- a/language-server/src/services/schemas-neovim.test.ts +++ b/language-server/src/services/schemas-neovim.test.ts @@ -5,7 +5,6 @@ import { PublishDiagnosticsNotification } from "vscode-languageserver"; describe("Feature - workspace (neovim)", () => { let client: TestClient; - let documentUriA: string; let documentUriB: string; beforeAll(async () => { @@ -20,7 +19,7 @@ describe("Feature - workspace (neovim)", () => { } }); - documentUriA = await client.writeDocument("./subjectA.schema.json", `{ "$schema": "https://json-schema.org/draft/2020-12/schema" }`); + await client.writeDocument("./subjectA.schema.json", `{ "$schema": "https://json-schema.org/draft/2020-12/schema" }`); documentUriB = await client.writeDocument("./subjectB.schema.json", `{ "$schema": "https://json-schema.org/draft/2020-12/schema" }`); }); @@ -37,7 +36,7 @@ describe("Feature - workspace (neovim)", () => { }); }); - test("a change to a watched file should validate the workspace", { retry: 3 }, async () => { + test("a change to a watched file should validate the file", { retry: 3 }, async () => { const schemaUris: string[] = []; client.onNotification(PublishDiagnosticsNotification.type, (params) => { @@ -46,7 +45,7 @@ describe("Feature - workspace (neovim)", () => { await client.writeDocument("./subjectB.schema.json", `{ "$schema": "https://json-schema.org/draft/2020-12/schema" }`); - expect(schemaUris).to.eql([documentUriA, documentUriB]); + expect(schemaUris).to.eql([documentUriB]); }); test.todo("changing the workspace folders should validate the workspace", () => { diff --git a/language-server/src/services/schemas.test.ts b/language-server/src/services/schemas.test.ts index 81fc91b5..b33c2ae0 100644 --- a/language-server/src/services/schemas.test.ts +++ b/language-server/src/services/schemas.test.ts @@ -45,7 +45,7 @@ describe("JSON Schema Language Server", () => { expect(await diagnostics).to.equal(documentUriA); }); - test("a change to a watched file should validate the workspace", async () => { + test("a change to a watched file should validate the file", async () => { const schemaUris: string[] = []; client.onNotification(PublishDiagnosticsNotification.type, (params) => { @@ -54,7 +54,7 @@ describe("JSON Schema Language Server", () => { await client.writeDocument("./subjectB.schema.json", `{ "$schema": "https://json-schema.org/draft/2020-12/cshema" }`); - expect(schemaUris).to.eql([documentUriA, documentUriB]); + expect(schemaUris).to.eql([documentUriB]); }); test.todo("changing the workspace folders should validate the workspace", () => { From 090fa4f0b9baedc83d983d7c364bbe1ad434a5a7 Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Thu, 23 Apr 2026 14:15:32 +0300 Subject: [PATCH 4/7] test(validate-workspace): add integration tests for incremental validation (#17) --- .../src/features/validate-workspace.test.ts | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 language-server/src/features/validate-workspace.test.ts diff --git a/language-server/src/features/validate-workspace.test.ts b/language-server/src/features/validate-workspace.test.ts new file mode 100644 index 00000000..ab2dc841 --- /dev/null +++ b/language-server/src/features/validate-workspace.test.ts @@ -0,0 +1,297 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { PublishDiagnosticsNotification } from "vscode-languageserver"; +import { TestClient } from "../test/test-client.ts"; + +const employeeSchema = `{ + "$id": "https://test.com/schemas/employee", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "role": { "type": "string" } + } +}`; + +const policySchema = `{ + "$id": "https://test.com/schemas/policy", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" } + ], + "properties": { + "security_level": { "type": "string", "enum": ["low", "high"] } + }, + "required": ["security_level"] +}`; + +const projectSchema = `{ + "$id": "https://test.com/schemas/project", + "$schema": "https://test.com/schemas/policy", + "security_level": "high", + "type": "object", + "properties": { + "project_name": { "type": "string" }, + "lead": { "$ref": "https://test.com/schemas/employee" } + } +}`; + +const reportSchema = `{ + "$id": "https://test.com/schemas/report", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "main_project": { "$ref": "https://test.com/schemas/project" } + } +}`; + +describe("Incremental Workspace Validation", () => { + let client: TestClient; + let employeeUri: string; + let policyUri: string; + let projectUri: string; + let reportUri: string; + const schemaUris: string[] = []; + + beforeAll(async () => { + client = new TestClient(); + await client.start(); + + client.onNotification(PublishDiagnosticsNotification.type, (params) => { + schemaUris.push(params.uri); + }); + + employeeUri = await client.writeDocument("./employee.schema.json", employeeSchema); + policyUri = await client.writeDocument("./policy.schema.json", policySchema); + projectUri = await client.writeDocument("./project.schema.json", projectSchema); + reportUri = await client.writeDocument("./report.schema.json", reportSchema); + }); + + beforeEach(() => { + schemaUris.length = 0; + }); + + afterAll(async () => { + await client.stop(); + }); + + test("editing a schema with no dependents should revalidate only itself", async () => { + await client.writeDocument("./report.schema.json", reportSchema); + + expect(schemaUris).to.include(reportUri); + expect(schemaUris.length).to.equal(1); + }); + + test("editing a schema with direct dependents should only revalidate itself and its direct dependents", async () => { + await client.writeDocument("./project.schema.json", projectSchema); + + expect(schemaUris).to.include(projectUri); + expect(schemaUris).to.include(reportUri); + expect(schemaUris.length).to.equal(2); + }); + + test("editing a schema with indirect dependents should only revalidate itself and the whole dependent chain", async () => { + await client.writeDocument("./employee.schema.json", employeeSchema); + + expect(schemaUris).to.include(employeeUri); + expect(schemaUris).to.include(projectUri); + expect(schemaUris).to.include(reportUri); + expect(schemaUris).to.not.include(policyUri); + expect(schemaUris.length).to.equal(3); + }); + + test("editing a meta schema should revalidate itself and schemas using it as a dialect", async () => { + const metaSchema = `{ + "$id": "https://test.com/schemas/meta-test", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }`; + const dependentSchema = `{ + "$id": "https://test.com/schemas/meta-dependent", + "$schema": "https://test.com/schemas/meta-test" + }`; + const metaUri = await client.writeDocument("./meta-test.schema.json", metaSchema); + const dependentUri = await client.writeDocument("./meta-dependent.schema.json", dependentSchema); + + schemaUris.length = 0; + + await client.writeDocument("./meta-test.schema.json", metaSchema); + + expect(schemaUris).to.include(metaUri); + expect(schemaUris).to.include(dependentUri); + expect(schemaUris.length).to.equal(2); + }); + + test("editing a schema with circular dependencies should not cause infinite loops", async () => { + const circularASchema = `{ + "$id": "https://test.com/schemas/circularA", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "https://test.com/schemas/circularB" + }`; + const circularBSchema = `{ + "$id": "https://test.com/schemas/circularB", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "https://test.com/schemas/circularA" + }`; + const circularAUri = await client.writeDocument("./circularA.schema.json", circularASchema); + const circularBUri = await client.writeDocument("./circularB.schema.json", circularBSchema); + + schemaUris.length = 0; + + await client.writeDocument("./circularA.schema.json", circularASchema); + + expect(schemaUris).to.include(circularAUri); + expect(schemaUris).to.include(circularBUri); + expect(schemaUris.length).to.equal(2); + + schemaUris.length = 0; + + await client.writeDocument("./circularB.schema.json", circularBSchema); + + expect(schemaUris).to.include(circularAUri); + expect(schemaUris).to.include(circularBUri); + expect(schemaUris.length).to.equal(2); + }); + + test("deleting a schema should revalidate the dependent chain and clear itself", async () => { + await client.deleteDocument(employeeUri); + + expect(schemaUris).to.include(employeeUri); + expect(schemaUris).to.include(projectUri); + expect(schemaUris).to.include(reportUri); + expect(schemaUris.length).to.equal(3); + }); + + test("removing a reference should not trigger revalidation of the dependent chain", async () => { + const projectSchemaNoRef = `{ + "$id": "https://test.com/schemas/project", + "$schema": "https://test.com/schemas/policy", + "security_level": "high", + "properties": { "project_name": { "type": "string" } } + }`; + await client.writeDocument("./project.schema.json", projectSchemaNoRef); + + schemaUris.length = 0; + + await client.writeDocument("./employee.schema.json", employeeSchema); + + expect(schemaUris).to.include(employeeUri); + expect(schemaUris.length).to.equal(1); + }); + + test("adding a reference should trigger revalidation of the dependent chain", async () => { + await client.writeDocument("./project.schema.json", projectSchema); + + schemaUris.length = 0; + + await client.writeDocument("./employee.schema.json", employeeSchema); + + expect(schemaUris).to.include(employeeUri); + expect(schemaUris).to.include(projectUri); + expect(schemaUris).to.include(reportUri); + expect(schemaUris.length).to.equal(3); + }); + + test.todo("resolution of missing schemas should trigger revalidation when the schema becomes available", async () => { + const aSchema = `{ + "$id": "https://test.com/schemas/a", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }`; + const bSchema = `{ + "$id": "https://test.com/schemas/b", + "$schema": "https://test.com/schemas/a" + }`; + const bUri = await client.writeDocument("./b.schema.json", bSchema); + + schemaUris.length = 0; + + const aUri = await client.writeDocument("./a.schema.json", aSchema); + + expect(schemaUris).to.include(bUri); + expect(schemaUris).to.include(aUri); + expect(schemaUris.length).to.equal(2); + }); + + test("changing $id should trigger revalidation of its dependents", async () => { + const aSchema = `{ + "$id": "https://test.com/schemas/a-v1", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }`; + const bSchema = `{ + "$id": "https://test.com/schemas/b", + "$schema": "https://test.com/schemas/a-v1" + }`; + const aUri = await client.writeDocument("./a.schema.json", aSchema); + const bUri = await client.writeDocument("./b.schema.json", bSchema); + + schemaUris.length = 0; + + await client.writeDocument("./a.schema.json", aSchema.replace("/a-v1", "/a-v2")); + + expect(schemaUris).to.include(aUri); + expect(schemaUris).to.include(bUri); + expect(schemaUris.length).to.equal(2); + }); + + test("editing a schema with fragment references should revalidate its dependents", async () => { + const aSchema = `{ + "$id": "https://test.com/schemas/a", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { "foo": { "type": "string" } } + }`; + const bSchema = `{ + "$id": "https://test.com/schemas/b", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "https://test.com/schemas/a#/$defs/foo" + }`; + const aUri = await client.writeDocument("./a.schema.json", aSchema); + const bUri = await client.writeDocument("./b.schema.json", bSchema); + + schemaUris.length = 0; + + await client.writeDocument("./a.schema.json", aSchema); + + expect(schemaUris).to.include(aUri); + expect(schemaUris).to.include(bUri); + expect(schemaUris.length).to.equal(2); + }); + + test.todo("multiple schemas with the same $id should revalidate their dependents when either is edited", async () => { + const aSchema = `{ + "$id": "https://test.com/schemas/shared", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }`; + const bSchema = `{ + "$id": "https://test.com/schemas/b", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "https://test.com/schemas/shared" + }`; + const cSchema = `{ + "$id": "https://test.com/schemas/shared", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }`; + const aUri = await client.writeDocument("./a.schema.json", aSchema); + const bUri = await client.writeDocument("./b.schema.json", bSchema); + const cUri = await client.writeDocument("./c.schema.json", cSchema); + + schemaUris.length = 0; + + await client.writeDocument("./a.schema.json", aSchema); + expect(schemaUris).to.include(aUri); + expect(schemaUris).to.include(bUri); + expect(schemaUris.length).toBe(2); + + schemaUris.length = 0; + + await client.writeDocument("./c.schema.json", cSchema); + expect(schemaUris).to.include(cUri); + expect(schemaUris).to.include(bUri); + expect(schemaUris.length).to.equal(2); + }); +}); From 6a7b23ee77a059cf153a3e4742c3ee00bf4969a1 Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Sat, 25 Apr 2026 13:04:15 +0300 Subject: [PATCH 5/7] fix(dependencies): do not ignore unresolved dependencies (#17) Previous implementation was built on the assumption that we only cared to track dependencies that resolved to actual files in the workspace. This proved wrong because we need to know the intent of a schema A to reference a schema B so the dependencies are complete in case B becomes available. --- .../src/features/validate-workspace.js | 45 +------ .../src/features/validate-workspace.test.ts | 4 +- language-server/src/services/dependencies.js | 126 +++++++++++++----- 3 files changed, 96 insertions(+), 79 deletions(-) diff --git a/language-server/src/features/validate-workspace.js b/language-server/src/features/validate-workspace.js index ca55c6a5..cc262926 100644 --- a/language-server/src/features/validate-workspace.js +++ b/language-server/src/features/validate-workspace.js @@ -9,7 +9,6 @@ import { hasDialect } from "@hyperjump/json-schema/experimental"; * @import { ValidateSchemaFeature } from "./validate-schema.js"; * @import { Dependencies } from "../services/dependencies.js"; * @import { FileEvent } from "vscode-languageserver" - * @import { SchemaDocument } from "../model/schema-document.js" */ @@ -77,57 +76,15 @@ export class ValidateWorkspaceFeature { await this.#schemas.getOpen(schemaUri, true); } - // NOTE: When the workspace is first loaded, we need to validate all schemas - const shouldValidateWorkspace = changes.length === 0; - // NOTE: We find the affected schemas before rebuilding the dependencies. - // e.g. If A depends on B, and B is deleted, and we build the dependencies first, - // we would have already removed B from the workspace, so the graph would have no dependents. - const affectedSchemas = shouldValidateWorkspace ? this.#schemas.all() : this.#findAffectedSchemas(changes); + const affectedSchemas = await this.#dependencies.sync(changes); // Re/validate affected schemas for await (const schemaDocument of affectedSchemas) { await this.#validateSchema.validateSchema(schemaDocument); } - await this.#dependencies.build(); - this.#dependencies.print(); - await this.#server.sendRequest(SemanticTokensRefreshRequest.type); reporter.done(); } - - /** - * @param {FileEvent[]} changes - * @returns {AsyncGenerator} - */ - async* #findAffectedSchemas(changes) { - const affectedUris = this.#findAffectedUris(changes); - for (const uri of affectedUris) { - const schemaDocument = await this.#schemas.get(uri); - if (schemaDocument) { - yield schemaDocument; - } - } - } - - /** - * @param {FileEvent[]} changes - * @returns {Set} - */ - #findAffectedUris(changes) { - /** @type {Set} */ - const affectedUris = new Set(); - for (const change of changes) { - if (change.type !== FileChangeType.Deleted) { - // NOTE: When a file is deleted, we don't need to revalidate it, as it will be removed from the workspace - affectedUris.add(change.uri); - } - const dependents = this.#dependencies.findDependents(change.uri); - for (const dependent of dependents) { - affectedUris.add(dependent); - } - } - return affectedUris; - } } diff --git a/language-server/src/features/validate-workspace.test.ts b/language-server/src/features/validate-workspace.test.ts index ab2dc841..87656f49 100644 --- a/language-server/src/features/validate-workspace.test.ts +++ b/language-server/src/features/validate-workspace.test.ts @@ -198,7 +198,7 @@ describe("Incremental Workspace Validation", () => { expect(schemaUris.length).to.equal(3); }); - test.todo("resolution of missing schemas should trigger revalidation when the schema becomes available", async () => { + test("resolution of missing schemas should trigger revalidation when the schema becomes available", async () => { const aSchema = `{ "$id": "https://test.com/schemas/a", "$schema": "https://json-schema.org/draft/2020-12/schema" @@ -262,7 +262,7 @@ describe("Incremental Workspace Validation", () => { expect(schemaUris.length).to.equal(2); }); - test.todo("multiple schemas with the same $id should revalidate their dependents when either is edited", async () => { + test("multiple schemas with the same $id should revalidate their dependents when either is edited", async () => { const aSchema = `{ "$id": "https://test.com/schemas/shared", "$schema": "https://json-schema.org/draft/2020-12/schema" diff --git a/language-server/src/services/dependencies.js b/language-server/src/services/dependencies.js index e50e1b73..be34dd99 100644 --- a/language-server/src/services/dependencies.js +++ b/language-server/src/services/dependencies.js @@ -1,23 +1,38 @@ import { resolveIri, toAbsoluteUri } from "../util/util.js"; import { value } from "../model/schema-node.js"; +import { FileChangeType } from "vscode-languageserver"; /** * @import { Server } from "./server.js"; * @import { Schemas } from "./schemas.js"; + * @import { FileEvent } from "vscode-languageserver"; + * @import { SchemaDocument } from "../model/schema-document.js" + */ + +/** + * @typedef {string} FileSystemUri + * A URI that can be resolved to a file path on the filesystem + */ + +/** + * @typedef {string} SchemaUri + * A URI that identifies a schema it could be a file system URI or an id */ /** * @typedef {Object} DependencyRecord - * @property {string} uri - * @property {Set} dependencies - * @property {Set} dependents + * @property {FileSystemUri} uri + * @property {Set} dependencies + * @property {Set} definitions */ export class Dependencies { #server; #schemas; - /** @type {Map} */ + /** @type {Map} */ #records; + /** @type {Map>} */ + #dependents; /** * @param {Server} server @@ -27,50 +42,80 @@ export class Dependencies { this.#server = server; this.#schemas = schemas; this.#records = new Map(); + this.#dependents = new Map(); } async build() { this.#server.console.log("Extracting Dependencies"); this.#records.clear(); + this.#dependents.clear(); for await (const schemaDocument of this.#schemas.all()) { const uri = schemaDocument.textDocument.uri; const dependent = this.#getOrCreateRecord(uri); for (const schemaResource of schemaDocument.schemaResources) { + dependent.definitions.add(schemaResource.baseUri); if (schemaResource.dialectUri) { - this.#addDependencyIfLocal(dependent, schemaResource.dialectUri); + this.#addDependency(dependent, schemaResource.dialectUri); } for (const reference of this.#schemas.references(schemaResource)) { /** @type {string} */ const referenceValue = value(reference); const referencedUri = toAbsoluteUri(resolveIri(referenceValue, schemaResource.baseUri)); - this.#addDependencyIfLocal(dependent, referencedUri); + this.#addDependency(dependent, referencedUri); } } } } /** - * @param {string} uri - * @returns {DependencyRecord | undefined} + * @param {FileEvent[]} changes + * @returns {Promise | Iterable>} */ - get(uri) { - return this.#records.get(uri); + async sync(changes) { + const shouldValidateAllSchemas = !changes.length; + if (shouldValidateAllSchemas) { + await this.build(); + return this.#schemas.all(); + } + /** @type {Set} */ + const affectedUris = new Set(); + const affectedSchemas = []; + // We are calling findAffectedUris before updating the dependencies + // because if a file is deleted, it will be removed from the dependencies + // and we won't be able to find its dependents after the update. + // This also happens if a file defined an id that now is deleted. + this.#findAffectedUris(changes, affectedUris); + await this.build(); + // We are also calling findAffectedUris after updating the dependencies + // because if a file is added, we need to find its dependents. + // This also happens if a file now defines an id that it didn't before. + this.#findAffectedUris(changes, affectedUris); + for (const uri of affectedUris) { + const schemaDocument = await this.#schemas.get(uri); + if (schemaDocument) { + affectedSchemas.push(schemaDocument); + } + } + return affectedSchemas; } /** - * @param {string} uri - * @param {Set} dependents - * @returns {Set} + * @param {FileSystemUri} uri + * @param {Set} dependents + * @returns {Set} */ findDependents(uri, dependents = new Set()) { - const dependency = this.get(uri); - if (!dependency) return dependents; + const record = this.#records.get(uri); + const handles = record?.definitions ?? new Set(); - for (const dependent of dependency.dependents) { - if (dependents.has(dependent)) continue; - dependents.add(dependent); - this.findDependents(dependent, dependents); + for (const handle of handles) { + const directDependents = this.#dependents.get(handle) ?? new Set(); + for (const directDependent of directDependents) { + if (dependents.has(directDependent)) continue; + dependents.add(directDependent); + this.findDependents(directDependent, dependents); + } } return dependents; @@ -78,24 +123,20 @@ export class Dependencies { /** * @param {DependencyRecord} dependent - * @param {string} dependencyUri + * @param {SchemaUri} dependencyUri */ - #addDependencyIfLocal(dependent, dependencyUri) { - // NOTE: Tracks only local filesystem schemas. - // If a schema URI (e.g https://...) does not have a corresponding document, - // we can't resolve its path, we can't watch it for changes, so tracking it adds no value. - const dependencyDocument = this.#schemas.getBySchemaUri(dependencyUri); - if (!dependencyDocument) { - return; + #addDependency(dependent, dependencyUri) { + dependent.dependencies.add(dependencyUri); + let dependents = this.#dependents.get(dependencyUri); + if (!dependents) { + dependents = new Set(); + this.#dependents.set(dependencyUri, dependents); } - const localDependencyUri = dependencyDocument.textDocument.uri; - const dependency = this.#getOrCreateRecord(localDependencyUri); - dependent.dependencies.add(localDependencyUri); - dependency.dependents.add(dependent.uri); + dependents.add(dependent.uri); } /** - * @param {string} uri + * @param {FileSystemUri} uri * @returns {DependencyRecord} */ #getOrCreateRecord(uri) { @@ -105,7 +146,7 @@ export class Dependencies { dependencyRecord = { uri, dependencies: new Set(), - dependents: new Set() + definitions: new Set([uri]) }; this.#records.set(uri, dependencyRecord); } @@ -113,6 +154,25 @@ export class Dependencies { return dependencyRecord; } + /** + * @param {FileEvent[]} changes + * @param {Set} affectedUris + * @returns {Set} + */ + #findAffectedUris(changes, affectedUris = new Set()) { + for (const change of changes) { + if (change.type !== FileChangeType.Deleted) { + // NOTE: When a file is deleted, we don't need to revalidate it, as it will be removed from the workspace + affectedUris.add(change.uri); + } + const dependents = this.findDependents(change.uri); + for (const dependent of dependents) { + affectedUris.add(dependent); + } + } + return affectedUris; + } + print() { for (const [key, value] of this.#records) { const dependencies = value.dependencies; From d60d26e020b1cfef00a7a052e11ef8aaf31301fb Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Sat, 25 Apr 2026 14:40:59 +0300 Subject: [PATCH 6/7] feat(dependencies): track dependencies incrementally (#17) --- language-server/src/services/dependencies.js | 112 ++++++++++++------- 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/language-server/src/services/dependencies.js b/language-server/src/services/dependencies.js index be34dd99..7324be3d 100644 --- a/language-server/src/services/dependencies.js +++ b/language-server/src/services/dependencies.js @@ -45,29 +45,6 @@ export class Dependencies { this.#dependents = new Map(); } - async build() { - this.#server.console.log("Extracting Dependencies"); - this.#records.clear(); - this.#dependents.clear(); - - for await (const schemaDocument of this.#schemas.all()) { - const uri = schemaDocument.textDocument.uri; - const dependent = this.#getOrCreateRecord(uri); - for (const schemaResource of schemaDocument.schemaResources) { - dependent.definitions.add(schemaResource.baseUri); - if (schemaResource.dialectUri) { - this.#addDependency(dependent, schemaResource.dialectUri); - } - for (const reference of this.#schemas.references(schemaResource)) { - /** @type {string} */ - const referenceValue = value(reference); - const referencedUri = toAbsoluteUri(resolveIri(referenceValue, schemaResource.baseUri)); - this.#addDependency(dependent, referencedUri); - } - } - } - } - /** * @param {FileEvent[]} changes * @returns {Promise | Iterable>} @@ -75,7 +52,7 @@ export class Dependencies { async sync(changes) { const shouldValidateAllSchemas = !changes.length; if (shouldValidateAllSchemas) { - await this.build(); + await this.addAllSchemas(); return this.#schemas.all(); } /** @type {Set} */ @@ -86,7 +63,7 @@ export class Dependencies { // and we won't be able to find its dependents after the update. // This also happens if a file defined an id that now is deleted. this.#findAffectedUris(changes, affectedUris); - await this.build(); + await this.updateSchemas(changes); // We are also calling findAffectedUris after updating the dependencies // because if a file is added, we need to find its dependents. // This also happens if a file now defines an id that it didn't before. @@ -100,6 +77,70 @@ export class Dependencies { return affectedSchemas; } + async addAllSchemas() { + this.#records.clear(); + this.#dependents.clear(); + for await (const schemaDocument of this.#schemas.all()) { + this.addSchema(schemaDocument); + } + } + + /** + * @param {SchemaDocument} schemaDocument + */ + addSchema(schemaDocument) { + const uri = schemaDocument.textDocument.uri; + this.removeSchema(uri); + const dependent = this.#createRecord(uri); + for (const schemaResource of schemaDocument.schemaResources) { + dependent.definitions.add(schemaResource.baseUri); + if (schemaResource.dialectUri) { + this.#addDependency(dependent, schemaResource.dialectUri); + } + for (const reference of this.#schemas.references(schemaResource)) { + /** @type {string} */ + const referenceValue = value(reference); + const referencedUri = toAbsoluteUri(resolveIri(referenceValue, schemaResource.baseUri)); + this.#addDependency(dependent, referencedUri); + } + } + } + + /** + * @param {FileSystemUri} uri + */ + removeSchema(uri) { + const record = this.#records.get(uri); + if (!record) { + return; + } + for (const dependency of record.dependencies) { + const dependents = this.#dependents.get(dependency); + dependents?.delete(uri); + } + this.#records.delete(uri); + } + + /** + * @param {FileEvent[]} changes + */ + async updateSchemas(changes) { + for (const change of changes) { + switch (change.type) { + case FileChangeType.Created: + case FileChangeType.Changed: { + const document = await this.#schemas.get(change.uri); + this.addSchema(document); + break; + } + case FileChangeType.Deleted: { + this.removeSchema(change.uri); + break; + } + } + } + } + /** * @param {FileSystemUri} uri * @param {Set} dependents @@ -139,19 +180,14 @@ export class Dependencies { * @param {FileSystemUri} uri * @returns {DependencyRecord} */ - #getOrCreateRecord(uri) { - let dependencyRecord = this.#records.get(uri); - - if (!dependencyRecord) { - dependencyRecord = { - uri, - dependencies: new Set(), - definitions: new Set([uri]) - }; - this.#records.set(uri, dependencyRecord); - } - - return dependencyRecord; + #createRecord(uri) { + const record = { + uri, + dependencies: new Set(), + definitions: new Set([uri]) + }; + this.#records.set(uri, record); + return record; } /** From dbdf28fcb29eb0b207541360935c98103f675176 Mon Sep 17 00:00:00 2001 From: Marios Ntoulas Date: Sat, 25 Apr 2026 15:11:54 +0300 Subject: [PATCH 7/7] refactor(dependencies): clean up implementation (#17) --- language-server/src/services/dependencies.js | 93 ++++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/language-server/src/services/dependencies.js b/language-server/src/services/dependencies.js index 7324be3d..74d82216 100644 --- a/language-server/src/services/dependencies.js +++ b/language-server/src/services/dependencies.js @@ -50,47 +50,60 @@ export class Dependencies { * @returns {Promise | Iterable>} */ async sync(changes) { - const shouldValidateAllSchemas = !changes.length; - if (shouldValidateAllSchemas) { - await this.addAllSchemas(); + const isInitial = !changes.length; + if (isInitial) { + await this.#init(); return this.#schemas.all(); } /** @type {Set} */ const affectedUris = new Set(); - const affectedSchemas = []; // We are calling findAffectedUris before updating the dependencies // because if a file is deleted, it will be removed from the dependencies // and we won't be able to find its dependents after the update. // This also happens if a file defined an id that now is deleted. this.#findAffectedUris(changes, affectedUris); - await this.updateSchemas(changes); + await this.#applyChanges(changes); // We are also calling findAffectedUris after updating the dependencies // because if a file is added, we need to find its dependents. // This also happens if a file now defines an id that it didn't before. this.#findAffectedUris(changes, affectedUris); - for (const uri of affectedUris) { - const schemaDocument = await this.#schemas.get(uri); - if (schemaDocument) { - affectedSchemas.push(schemaDocument); - } - } - return affectedSchemas; + return this.#resolveSchemas(affectedUris); } - async addAllSchemas() { + async #init() { this.#records.clear(); this.#dependents.clear(); for await (const schemaDocument of this.#schemas.all()) { - this.addSchema(schemaDocument); + this.#addSchema(schemaDocument); + } + } + + /** + * @param {FileEvent[]} changes + */ + async #applyChanges(changes) { + for (const change of changes) { + switch (change.type) { + case FileChangeType.Created: + case FileChangeType.Changed: { + const document = await this.#schemas.get(change.uri); + this.#addSchema(document); + break; + } + case FileChangeType.Deleted: { + this.#removeSchema(change.uri); + break; + } + } } } /** * @param {SchemaDocument} schemaDocument */ - addSchema(schemaDocument) { + #addSchema(schemaDocument) { const uri = schemaDocument.textDocument.uri; - this.removeSchema(uri); + this.#removeSchema(uri); const dependent = this.#createRecord(uri); for (const schemaResource of schemaDocument.schemaResources) { dependent.definitions.add(schemaResource.baseUri); @@ -109,7 +122,7 @@ export class Dependencies { /** * @param {FileSystemUri} uri */ - removeSchema(uri) { + #removeSchema(uri) { const record = this.#records.get(uri); if (!record) { return; @@ -121,32 +134,12 @@ export class Dependencies { this.#records.delete(uri); } - /** - * @param {FileEvent[]} changes - */ - async updateSchemas(changes) { - for (const change of changes) { - switch (change.type) { - case FileChangeType.Created: - case FileChangeType.Changed: { - const document = await this.#schemas.get(change.uri); - this.addSchema(document); - break; - } - case FileChangeType.Deleted: { - this.removeSchema(change.uri); - break; - } - } - } - } - /** * @param {FileSystemUri} uri * @param {Set} dependents * @returns {Set} */ - findDependents(uri, dependents = new Set()) { + #findDependents(uri, dependents = new Set()) { const record = this.#records.get(uri); const handles = record?.definitions ?? new Set(); @@ -155,7 +148,7 @@ export class Dependencies { for (const directDependent of directDependents) { if (dependents.has(directDependent)) continue; dependents.add(directDependent); - this.findDependents(directDependent, dependents); + this.#findDependents(directDependent, dependents); } } @@ -198,10 +191,10 @@ export class Dependencies { #findAffectedUris(changes, affectedUris = new Set()) { for (const change of changes) { if (change.type !== FileChangeType.Deleted) { - // NOTE: When a file is deleted, we don't need to revalidate it, as it will be removed from the workspace + // When a file is deleted, we don't need to revalidate it, as it will be removed from the workspace affectedUris.add(change.uri); } - const dependents = this.findDependents(change.uri); + const dependents = this.#findDependents(change.uri); for (const dependent of dependents) { affectedUris.add(dependent); } @@ -209,12 +202,16 @@ export class Dependencies { return affectedUris; } - print() { - for (const [key, value] of this.#records) { - const dependencies = value.dependencies; - for (const dependency of dependencies) { - this.#server.console.log(`${key} -> ${dependency}`); - } - } + /** + * @param {Set} uris + * @returns {Promise} + */ + async #resolveSchemas(uris) { + const documents = await Promise.all( + Array.from(uris).map((uri) => + this.#schemas.get(uri) + ) + ); + return documents.filter(Boolean); } }