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..cc262926 100644 --- a/language-server/src/features/validate-workspace.js +++ b/language-server/src/features/validate-workspace.js @@ -7,6 +7,8 @@ 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"; + * @import { FileEvent } from "vscode-languageserver" */ @@ -15,18 +17,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) => { @@ -71,8 +76,10 @@ export class ValidateWorkspaceFeature { await this.#schemas.getOpen(schemaUri, true); } - // Re/validate all schemas - for await (const schemaDocument of this.#schemas.all()) { + const affectedSchemas = await this.#dependencies.sync(changes); + + // Re/validate affected schemas + for await (const schemaDocument of affectedSchemas) { await this.#validateSchema.validateSchema(schemaDocument); } 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..87656f49 --- /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("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("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); + }); +}); diff --git a/language-server/src/services/dependencies.js b/language-server/src/services/dependencies.js new file mode 100644 index 00000000..74d82216 --- /dev/null +++ b/language-server/src/services/dependencies.js @@ -0,0 +1,217 @@ +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 {FileSystemUri} uri + * @property {Set} dependencies + * @property {Set} definitions + */ + +export class Dependencies { + #server; + #schemas; + /** @type {Map} */ + #records; + /** @type {Map>} */ + #dependents; + + /** + * @param {Server} server + * @param {Schemas} schemas + */ + constructor(server, schemas) { + this.#server = server; + this.#schemas = schemas; + this.#records = new Map(); + this.#dependents = new Map(); + } + + /** + * @param {FileEvent[]} changes + * @returns {Promise | Iterable>} + */ + async sync(changes) { + const isInitial = !changes.length; + if (isInitial) { + await this.#init(); + return this.#schemas.all(); + } + /** @type {Set} */ + const affectedUris = new Set(); + // 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.#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); + return this.#resolveSchemas(affectedUris); + } + + async #init() { + this.#records.clear(); + this.#dependents.clear(); + for await (const schemaDocument of this.#schemas.all()) { + 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) { + 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 {FileSystemUri} uri + * @param {Set} dependents + * @returns {Set} + */ + #findDependents(uri, dependents = new Set()) { + const record = this.#records.get(uri); + const handles = record?.definitions ?? new Set(); + + 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; + } + + /** + * @param {DependencyRecord} dependent + * @param {SchemaUri} dependencyUri + */ + #addDependency(dependent, dependencyUri) { + dependent.dependencies.add(dependencyUri); + let dependents = this.#dependents.get(dependencyUri); + if (!dependents) { + dependents = new Set(); + this.#dependents.set(dependencyUri, dependents); + } + dependents.add(dependent.uri); + } + + /** + * @param {FileSystemUri} uri + * @returns {DependencyRecord} + */ + #createRecord(uri) { + const record = { + uri, + dependencies: new Set(), + definitions: new Set([uri]) + }; + this.#records.set(uri, record); + return record; + } + + /** + * @param {FileEvent[]} changes + * @param {Set} affectedUris + * @returns {Set} + */ + #findAffectedUris(changes, affectedUris = new Set()) { + for (const change of changes) { + if (change.type !== FileChangeType.Deleted) { + // 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; + } + + /** + * @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); + } +} 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", () => {