diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index dd7ee6a..04aafe4 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -6,6 +6,7 @@ import { Workspace } from "./services/Workspace.ts"; import { Diagnostics } from "./features/diagnostics/Diagnostics.ts"; import { SyntaxValidationDiagnosticsProvider } from "./features/diagnostics/SyntaxValidationDiagnosticsProvider.ts"; import { SchemaValidationDiagnosticsProvider } from "./features/diagnostics/SchemaValidationDiagnosticsProvider.ts"; +import { SchemaRegistrationDiagnosticsProvider } from "./features/diagnostics/SchemaRegistrationDiagnosticsProvider.ts"; import { Formatting } from "./features/Formatting.ts"; import { Hover } from "./features/Hover.ts"; import { Completions } from "./features/completions/Completions.ts"; @@ -34,6 +35,7 @@ export const buildServer = (connection: Connection): Server => { new Diagnostics(server, jsonDocuments, jsonSchema, [ new SyntaxValidationDiagnosticsProvider(), + new SchemaRegistrationDiagnosticsProvider(registry, jsonSchema), new SchemaValidationDiagnosticsProvider(jsonSchema) ]); diff --git a/language-server/src/features/diagnostics/SchemaRegistrationDiagnosticsProvider.test.ts b/language-server/src/features/diagnostics/SchemaRegistrationDiagnosticsProvider.test.ts new file mode 100644 index 0000000..037969a --- /dev/null +++ b/language-server/src/features/diagnostics/SchemaRegistrationDiagnosticsProvider.test.ts @@ -0,0 +1,457 @@ +import { describe, test, expect, afterEach } from "vitest"; +import { DiagnosticSeverity } from "vscode-languageserver"; +import { TestClient } from "../../test/TestClient.ts"; + +const schemaId = "https://example.com/duplicate-schema"; + +const schemaWithId = (id: string, type: string) => `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${id}", + "type": "object", + "properties": { + "foo": { "type": "${type}" } + } +}`; + +describe("Schema registration", () => { + let client: TestClient; + + afterEach(async () => { + await client.stop(); + }); + + test("a duplicate identifier is an error on $id and a warning on documents that use it", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + + const instanceDiagnostics = client.getDiagnostics("instance.json"); + const bSchemaUri = await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(instanceDiagnostics).resolves.toEqual([ + { + message: `Ambiguous schema identifier. '${bSchemaUri}' also uses the identifier '${schemaId}'`, + range: { + start: { line: 1, character: 17 }, + end: { line: 1, character: 19 + schemaId.length } + }, + severity: DiagnosticSeverity.Warning, + source: "hyperjump-json-language-server" + }, + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + + const bDiagnostics = client.getDiagnostics("b-schema.json"); + await client.openDocument("b-schema.json"); + await expect(bDiagnostics).resolves.toEqual([ + { + message: expect.stringContaining(`A schema has already been registered for '${schemaId}`) as string, + range: { + start: { line: 2, character: 9 }, + end: { line: 2, character: 11 + schemaId.length } + }, + severity: DiagnosticSeverity.Error, + source: "hyperjump-json-language-server" + } + ]); + }); + + test("a duplicate identifier is a warning on the registered schema's $id", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("a-schema.json"); + await client.openDocument("a-schema.json"); + await expect(initialDiagnostics).resolves.toEqual([]); + + const conflictDiagnostics = client.getDiagnostics("a-schema.json"); + const bSchemaUri = await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toEqual([ + { + message: `Duplicate schema identifier. '${bSchemaUri}' also uses the identifier '${schemaId}'`, + range: { + start: { line: 2, character: 9 }, + end: { line: 2, character: 11 + schemaId.length } + }, + severity: DiagnosticSeverity.Warning, + source: "hyperjump-json-language-server" + } + ]); + + const updatedDiagnostics = client.getDiagnostics("a-schema.json"); + await client.deleteDocument("b-schema.json"); + await expect(updatedDiagnostics).resolves.toEqual([]); + }); + + test("deleting the registered schema registers the duplicate in its place", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toHaveLength(1); + + const conflictDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toHaveLength(2); + + const updatedDiagnostics = client.getDiagnostics("instance.json"); + await client.deleteDocument("a-schema.json"); + await expect(updatedDiagnostics).resolves.toEqual([]); + + const bDiagnostics = client.getDiagnostics("b-schema.json"); + await client.openDocument("b-schema.json"); + await expect(bDiagnostics).resolves.toEqual([]); + }); + + test("an identifier that conflicts with a built-in meta-schema is an error and the built-in is still used", async () => { + const metaSchemaId = "https://json-schema.org/draft/2020-12/schema"; + + client = new TestClient(); + await client.writeDocument("conflict.json", `{ + "$schema": "${metaSchemaId}", + "$id": "${metaSchemaId}" + }`); + await client.start(); + + const conflictDiagnostics = client.getDiagnostics("conflict.json"); + await client.openDocument("conflict.json"); + await expect(conflictDiagnostics).resolves.toEqual([ + { + message: `'${metaSchemaId}' is a built-in schema and can't be redefined`, + range: { + start: { line: 2, character: 13 }, + end: { line: 2, character: 15 + metaSchemaId.length } + }, + severity: DiagnosticSeverity.Error, + source: "hyperjump-json-language-server" + } + ]); + + await client.writeDocument("schema.json", `{ + "$schema": "${metaSchemaId}", + "type": 42 + }`); + const schemaDiagnostics = client.getDiagnostics("schema.json"); + await client.openDocument("schema.json"); + await expect(schemaDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: expect.stringMatching(/^Expected one of/) as string }) + ]); + }); + + test("a schema that fails to register for a reason other than a duplicate is an error on $id", async () => { + client = new TestClient(); + await client.start(); + + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}" + }`); + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toHaveLength(1); + + const updatedDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("bad-schema.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${schemaId}", + "$defs": { + "foo": { "$id": "http://[invalid" } + } + }`); + await expect(updatedDiagnostics).resolves.toHaveLength(1); + + const schemaDiagnostics = client.getDiagnostics("bad-schema.json"); + await client.openDocument("bad-schema.json"); + await expect(schemaDiagnostics).resolves.toEqual([ + expect.objectContaining({ severity: DiagnosticSeverity.Error, range: expect.objectContaining({ start: { line: 2, character: 13 } }) as unknown }) + ]); + }); + + test("deleting the duplicate removes the warning", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toHaveLength(1); + + const conflictDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toHaveLength(2); + + const updatedDiagnostics = client.getDiagnostics("instance.json"); + await client.deleteDocument("b-schema.json"); + await expect(updatedDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + }); + + test("changing the registered schema without changing its identifier keeps the duplicate as a duplicate", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toHaveLength(1); + + const conflictDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toHaveLength(2); + + const updatedDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "boolean")); + await expect(updatedDiagnostics).resolves.toEqual([ + expect.objectContaining({ severity: DiagnosticSeverity.Warning }), + expect.objectContaining({ message: "Expected a ⁨boolean⁩" }) + ]); + }); + + test("a $schema with an empty fragment gets a warning for a duplicate identifier", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}#" + }`); + await client.start(); + + const diagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(diagnostics).resolves.toEqual([ + expect.objectContaining({ severity: DiagnosticSeverity.Warning }) + ]); + }); + + test("deleting a schema that conflicts with a built-in meta-schema keeps the built-in", async () => { + const metaSchemaId = "https://json-schema.org/draft/2020-12/schema"; + + client = new TestClient(); + await client.writeDocument("conflict.json", `{ + "$schema": "${metaSchemaId}", + "$id": "${metaSchemaId}" + }`); + await client.writeDocument("schema.json", `{ + "$schema": "${metaSchemaId}", + "type": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("schema.json"); + await client.openDocument("schema.json"); + await expect(initialDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: expect.stringMatching(/^Expected one of/) as string }) + ]); + + await client.deleteDocument("conflict.json"); + const updatedDiagnostics = client.getDiagnostics("schema.json"); + await client.changeDocument("schema.json", `{ + "$schema": "${metaSchemaId}", + "type": 43 + }`); + await expect(updatedDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: expect.stringMatching(/^Expected one of/) as string }) + ]); + }); + + test("a schema that conflicts with a built-in meta-schema doesn't warn documents that use the meta-schema", async () => { + const metaSchemaId = "https://json-schema.org/draft/2020-12/schema"; + + client = new TestClient(); + await client.writeDocument("schema.json", `{ + "$schema": "${metaSchemaId}", + "type": "string" + }`); + await client.writeDocument("conflict.json", `{ + "$schema": "${metaSchemaId}", + "$id": "${metaSchemaId}", + "type": "object" + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("schema.json"); + await client.openDocument("schema.json"); + await expect(initialDiagnostics).resolves.toEqual([]); + + const conflictDiagnostics = client.getDiagnostics("conflict.json"); + await client.openDocument("conflict.json"); + await expect(conflictDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: `'${metaSchemaId}' is a built-in schema and can't be redefined` }) + ]); + + // If the conflicting schema were used, a string schema would be an error + const updatedDiagnostics = client.getDiagnostics("schema.json"); + await client.changeDocument("schema.json", `{ + "$schema": "${metaSchemaId}", + "type": "number" + }`); + await expect(updatedDiagnostics).resolves.toEqual([]); + }); + + test("changing the identifier of the duplicate removes the error and the warning", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toHaveLength(1); + + const conflictDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toHaveLength(2); + + const bDiagnostics = client.getDiagnostics("b-schema.json"); + await client.openDocument("b-schema.json"); + await expect(bDiagnostics).resolves.toHaveLength(1); + + const updatedBDiagnostics = client.getDiagnostics("b-schema.json"); + await client.writeDocument("b-schema.json", schemaWithId("https://example.com/another-schema", "number")); + await expect(updatedBDiagnostics).resolves.toEqual([]); + + const updatedInstanceDiagnostics = client.getDiagnostics("instance.json"); + await client.changeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await expect(updatedInstanceDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + }); + + test("changing the identifier of the registered schema registers the duplicate in its place", async () => { + client = new TestClient(); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toHaveLength(1); + + const conflictDiagnostics = client.getDiagnostics("instance.json"); + await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toHaveLength(2); + + const bDiagnostics = client.getDiagnostics("b-schema.json"); + await client.openDocument("b-schema.json"); + await expect(bDiagnostics).resolves.toHaveLength(1); + + const updatedBDiagnostics = client.getDiagnostics("b-schema.json"); + await client.writeDocument("a-schema.json", schemaWithId("https://example.com/another-schema", "string")); + await expect(updatedBDiagnostics).resolves.toEqual([]); + + const updatedInstanceDiagnostics = client.getDiagnostics("instance.json"); + await client.changeDocument("instance.json", `{ + "$schema": "${schemaId}", + "foo": 42 + }`); + await expect(updatedInstanceDiagnostics).resolves.toEqual([]); + }); + + test("a schema that references a schema with a duplicate identifier is a warning on documents that use it", async () => { + const parentId = "https://example.com/parent"; + + client = new TestClient(); + await client.writeDocument("parent.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${parentId}", + "$ref": "${schemaId}" + }`); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + await client.writeDocument("instance.json", `{ + "$schema": "${parentId}", + "foo": 42 + }`); + await client.start(); + + const initialDiagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(initialDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + + const conflictDiagnostics = client.getDiagnostics("instance.json"); + const bSchemaUri = await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await expect(conflictDiagnostics).resolves.toEqual([ + { + message: `Ambiguous schema identifier in referenced schema. '${bSchemaUri}' also uses the identifier '${schemaId}'`, + range: { + start: { line: 1, character: 17 }, + end: { line: 1, character: 19 + parentId.length } + }, + severity: DiagnosticSeverity.Warning, + source: "hyperjump-json-language-server" + }, + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + + const updatedDiagnostics = client.getDiagnostics("instance.json"); + await client.deleteDocument("b-schema.json"); + await expect(updatedDiagnostics).resolves.toEqual([ + expect.objectContaining({ message: "Expected a ⁨string⁩" }) + ]); + }); + + test("a schema that indirectly references a schema with a duplicate identifier is a warning on documents that use it", async () => { + const parentId = "https://example.com/parent"; + const middleId = "https://example.com/middle"; + + client = new TestClient(); + await client.writeDocument("parent.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${parentId}", + "$ref": "${middleId}" + }`); + await client.writeDocument("middle.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "${middleId}", + "$ref": "${schemaId}" + }`); + await client.writeDocument("a-schema.json", schemaWithId(schemaId, "string")); + const bSchemaUri = await client.writeDocument("b-schema.json", schemaWithId(schemaId, "number")); + await client.writeDocument("instance.json", `{ + "$schema": "${parentId}" + }`); + await client.start(); + + const diagnostics = client.getDiagnostics("instance.json"); + await client.openDocument("instance.json"); + await expect(diagnostics).resolves.toEqual([ + expect.objectContaining({ + message: `Ambiguous schema identifier in referenced schema. '${bSchemaUri}' also uses the identifier '${schemaId}'` + }) + ]); + }); +}); diff --git a/language-server/src/features/diagnostics/SchemaRegistrationDiagnosticsProvider.ts b/language-server/src/features/diagnostics/SchemaRegistrationDiagnosticsProvider.ts new file mode 100644 index 0000000..9bd376b --- /dev/null +++ b/language-server/src/features/diagnostics/SchemaRegistrationDiagnosticsProvider.ts @@ -0,0 +1,81 @@ +import { DiagnosticSeverity } from "vscode-languageserver"; +import { toAbsoluteIri } from "@hyperjump/uri"; +import { JsonDocument } from "../../models/JsonDocument.ts"; + +import type { Diagnostic } from "vscode-languageserver"; +import type { DiagnosticsProvider } from "./Diagnostics.ts"; +import type { JsonSchema } from "../../services/JsonSchema.ts"; +import type { JsonSchemaRegistry } from "../../services/JsonSchemaRegistry.ts"; + +export class SchemaRegistrationDiagnosticsProvider implements DiagnosticsProvider { + private registry: JsonSchemaRegistry; + private jsonSchema: JsonSchema; + + constructor(registry: JsonSchemaRegistry, jsonSchema: JsonSchema) { + this.registry = registry; + this.jsonSchema = jsonSchema; + } + + async getDiagnostics(jsonDocument: JsonDocument) { + const diagnostics: Diagnostic[] = []; + + await this.registry.ready; + + const idNode = jsonDocument.findNodeAtPointer("/$id") ?? jsonDocument.findNodeAtPointer("/id"); + if (idNode) { + const error = this.registry.getRegistrationError(jsonDocument.uri); + if (error) { + diagnostics.push({ + severity: DiagnosticSeverity.Error, + range: jsonDocument.rangeAt(idNode.offset, idNode.offset + idNode.length), + message: error, + source: "hyperjump-json-language-server" + }); + } + + const schemaUri = await this.registry.getSchemaUri(jsonDocument.uri); + for (const fileUri of schemaUri ? this.registry.getDuplicates(schemaUri) : []) { + diagnostics.push({ + severity: DiagnosticSeverity.Warning, + range: jsonDocument.rangeAt(idNode.offset, idNode.offset + idNode.length), + message: `Duplicate schema identifier. '${fileUri}' also uses the identifier '${schemaUri}'`, + source: "hyperjump-json-language-server" + }); + } + } + + const schemaUri = jsonDocument.getSchemaUri(); + const schemaNode = jsonDocument.findNodeAtPointer("/$schema"); + if (schemaUri && schemaNode) { + const range = jsonDocument.rangeAt(schemaNode.offset, schemaNode.offset + schemaNode.length); + + for (const fileUri of this.registry.getDuplicates(schemaUri)) { + diagnostics.push({ + severity: DiagnosticSeverity.Warning, + range, + message: `Ambiguous schema identifier. '${fileUri}' also uses the identifier '${schemaUri}'`, + source: "hyperjump-json-language-server" + }); + } + + // Schemas referenced by the schema, directly or indirectly, are ambiguous too + const absoluteSchemaUri = toAbsoluteIri(schemaUri); + for (const dependencyUri of await this.jsonSchema.getDependencies(schemaUri)) { + if (dependencyUri === absoluteSchemaUri) { + continue; + } + + for (const fileUri of this.registry.getDuplicates(dependencyUri)) { + diagnostics.push({ + severity: DiagnosticSeverity.Warning, + range, + message: `Ambiguous schema identifier in referenced schema. '${fileUri}' also uses the identifier '${dependencyUri}'`, + source: "hyperjump-json-language-server" + }); + } + } + } + + return diagnostics; + } +} diff --git a/language-server/src/models/JsonDocument.ts b/language-server/src/models/JsonDocument.ts index dbff672..e14a46e 100644 --- a/language-server/src/models/JsonDocument.ts +++ b/language-server/src/models/JsonDocument.ts @@ -2,18 +2,20 @@ 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 { normalizeIri, resolveIri } from "@hyperjump/uri"; import type { Position, Range } from "vscode-languageserver-textdocument"; export class JsonDocument implements TextDocument { private textDocument: TextDocument; + private normalizedUri: string; private documentSchemaUri: string | undefined; private ast: jsonc.Node | undefined; private parseErrors: jsonc.ParseError[] = []; constructor(textDocument: TextDocument) { this.textDocument = textDocument; + this.normalizedUri = normalizeIri(textDocument.uri); this.parse(); } @@ -34,7 +36,7 @@ export class JsonDocument implements TextDocument { } get uri() { - return this.textDocument.uri; + return this.normalizedUri; } get languageId() { diff --git a/language-server/src/services/JsonSchema.ts b/language-server/src/services/JsonSchema.ts index 6049709..bfc32bf 100644 --- a/language-server/src/services/JsonSchema.ts +++ b/language-server/src/services/JsonSchema.ts @@ -96,7 +96,7 @@ export class JsonSchema { this.fileSchemaUris.delete(params.document.uri); }); - registry.onDidChangeSchema(async ({ schemaUri }) => { + registry.onDidChangeSchema(async ({ schemaUri, fileUri }) => { const changedSchemaUris = new Set(); for (const [cachedSchemaUri, compiledSchema] of this.compiledSchemaCache) { if (cachedSchemaUri === schemaUri || await this.dependsOn(compiledSchema, schemaUri)) { @@ -108,7 +108,10 @@ export class JsonSchema { for (const jsonDocument of jsonDocuments.all()) { const documentSchemaUri = await this.getSchemaUri(jsonDocument); - if (!documentSchemaUri || !changedSchemaUris.has(documentSchemaUri)) { + if (jsonDocument.uri !== fileUri + && await this.registry.getSchemaUri(jsonDocument.uri) !== schemaUri + && (!documentSchemaUri || !changedSchemaUris.has(documentSchemaUri)) + ) { continue; } @@ -152,17 +155,7 @@ export class JsonSchema { // The schema might be a workspace schema that hasn't been registered yet await this.registry.ready; - if (!this.compiledSchemaCache.has(schemaUri)) { - this.compiledSchemaCache.set(schemaUri, (async () => { - const startTime = performance.now(); - const schema = await getSchema(schemaUri); - const compiledSchema = await compile(schema); - this.server.console.log(`compile schema for ${abbreviateUri(schemaUri)} (${(performance.now() - startTime).toFixed(2)}ms)`); - return compiledSchema; - })()); - } - - compiledSchemaPromise = this.compiledSchemaCache.get(schemaUri); + compiledSchemaPromise = this.getCompiledSchema(schemaUri); compiledSchema = await compiledSchemaPromise; } @@ -191,15 +184,50 @@ export class JsonSchema { return this.validationCache.get(jsonDocument.uri)!; } + // The identifiers of the schema and every schema it references, directly or indirectly + async getDependencies(schemaUri: string) { + // The schema might be a workspace schema that hasn't been registered yet + await this.registry.ready; + + try { + return schemaDependencies(await this.getCompiledSchema(schemaUri)); + } catch { + // Compilation errors are reported by validation + return new Set(); + } + } + + // Returns the cached promise so callers can tell if the cache entry was replaced + private getCompiledSchema(schemaUri: string) { + if (!this.compiledSchemaCache.has(schemaUri)) { + this.compiledSchemaCache.set(schemaUri, (async () => { + const startTime = performance.now(); + const schema = await getSchema(schemaUri); + const compiledSchema = await compile(schema); + this.server.console.log(`compile schema for ${abbreviateUri(schemaUri)} (${(performance.now() - startTime).toFixed(2)}ms)`); + return compiledSchema; + })()); + } + + return this.compiledSchemaCache.get(schemaUri)!; + } + private async dependsOn(compiledSchema: Promise, schemaUri: string) { try { - const { ast } = await compiledSchema; - return Object.keys(ast).some((key) => { - return key !== "metaData" && key !== "plugins" && toAbsoluteIri(key) === schemaUri; - }); + return schemaDependencies(await compiledSchema).has(schemaUri); } catch { // A schema that failed to compile might be fixed by this change return true; } } } + +const schemaDependencies = ({ ast }: CompiledSchema) => { + const dependencies = new Set(); + for (const key in ast) { + if (key !== "metaData" && key !== "plugins") { + dependencies.add(toAbsoluteIri(key)); + } + } + return dependencies; +}; diff --git a/language-server/src/services/JsonSchemaRegistry.ts b/language-server/src/services/JsonSchemaRegistry.ts index a13c8d9..3af192e 100644 --- a/language-server/src/services/JsonSchemaRegistry.ts +++ b/language-server/src/services/JsonSchemaRegistry.ts @@ -1,5 +1,5 @@ import * as Pact from "@hyperjump/pact"; -import { resolveIri, toAbsoluteIri, toRelativeIri } from "@hyperjump/uri"; +import { normalizeIri, resolveIri, toAbsoluteIri, toRelativeIri } from "@hyperjump/uri"; import ignore from "ignore"; import * as jsonc from "jsonc-parser"; @@ -7,7 +7,7 @@ import type { Server } from "./Server.ts"; import type { Workspace } from "./Workspace.ts"; import type { SchemaObject } from "@hyperjump/json-schema"; import { getKeywordName, hasDialect } from "@hyperjump/json-schema/experimental"; -import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; +import { hasSchema, registerSchema, unregisterSchema } from "@hyperjump/json-schema"; type SchemaStoreEntry = { name: string; @@ -25,6 +25,7 @@ type CatalogMatcher = { type DidChangeSchemaHandler = (params: DidChangeSchemaParams) => Promise | void; type DidChangeSchemaParams = { schemaUri: string; + fileUri: string; }; const readChunkSize = 100; @@ -35,6 +36,7 @@ export class JsonSchemaRegistry { private catalogMatchers: Promise; private schemas: Promise>; private workspaceSchemas: Map = new Map(); + private failedSchemas: Map = new Map(); private didChangeSchemaHandlers: Set = new Set(); private pending: Promise; @@ -67,14 +69,14 @@ export class JsonSchemaRegistry { workspace.onDidChangeWatchedFiles(async (params) => { // Changes are applied in the order they're received. Otherwise, which of two schemas with the // same id gets registered would depend on which file read finishes first. - const applied = this.pending.then(() => this.applyChanges(params.changes.map((change) => change.uri))); + const applied = this.pending.then(() => this.applyChanges(params.changes.map((change) => normalizeIri(change.uri)))); this.pending = applied.then(() => undefined, () => undefined); // Handlers aren't part of the queue because they can wait on `ready` - for (const schemaUris of await applied) { + for (const [fileUri, schemaUris] of await applied) { for (const schemaUri of schemaUris) { for (const handler of this.didChangeSchemaHandlers) { - await handler({ schemaUri }); + await handler({ schemaUri, fileUri }); } } } @@ -138,6 +140,28 @@ export class JsonSchemaRegistry { return this.workspaceSchemas.get(fileUri); } + getRegistrationError(fileUri: string) { + return this.failedSchemas.get(fileUri)?.message; + } + + // Files with schemas that failed to register because another schema already uses the id + getDuplicates(schemaUri: string) { + // Registered ids never have a fragment, but $schema can, e.g. "https://example.com/schema#" + schemaUri = toAbsoluteIri(schemaUri); + return Pact.pipe( + this.failedSchemas, + Pact.filter(([, { id }]) => id === schemaUri && this.isWorkspaceSchema(id)), + Pact.map(([fileUri]) => fileUri), + Pact.collectArray + ); + } + + private isWorkspaceSchema(schemaUri: string) { + return Pact.some((workspaceSchemaUri) => { + return workspaceSchemaUri === schemaUri && hasSchema(schemaUri); + }, this.workspaceSchemas.values()); + } + async has(schemaUri: string) { return (await this.schemas).has(schemaUri); } @@ -156,7 +180,7 @@ export class JsonSchemaRegistry { } private async applyChanges(fileUris: string[]) { - const refreshed: Set[] = []; + const refreshed: [string, Set][] = []; // Reading is the slow part, so files are read in parallel and then applied in order. Chunked so // a large workspace scan doesn't hold every file in memory at once. @@ -164,8 +188,17 @@ export class JsonSchemaRegistry { const chunk = fileUris.slice(start, start + readChunkSize); const contents = await Promise.all(chunk.map((fileUri) => this.readJson(fileUri))); - for (const [index, fileUri] of chunk.entries()) { - refreshed.push(this.refresh(fileUri, contents[index])); + for (const [index, changedFileUri] of chunk.entries()) { + const schemaUris = this.refresh(changedFileUri, contents[index]); + refreshed.push([changedFileUri, schemaUris]); + + // A schema that failed to register because of a duplicate id might succeed now. + // Iterate a copy because a retry that fails again is re-added to the map. + for (const [fileUri, { id }] of [...this.failedSchemas]) { + if (schemaUris.has(id) && !hasSchema(id)) { + refreshed.push([fileUri, this.refresh(fileUri, await this.readJson(fileUri))]); + } + } } } @@ -179,6 +212,8 @@ export class JsonSchemaRegistry { const workspaceSchema = this.toWorkspaceSchema(fileUri, json); const oldSchemaUri = this.workspaceSchemas.get(fileUri); + const oldFailedSchemaUri = this.failedSchemas.get(fileUri)?.id; + this.failedSchemas.delete(fileUri); if (oldSchemaUri) { unregisterSchema(oldSchemaUri); this.workspaceSchemas.delete(fileUri); @@ -190,7 +225,10 @@ export class JsonSchemaRegistry { this.workspaceSchemas.set(fileUri, workspaceSchema.id); this.server.console.log(`Registered local schema: ${workspaceSchema.id} (from ${fileUri})`); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); + const message = hasSchema(workspaceSchema.id) && !this.isWorkspaceSchema(workspaceSchema.id) + ? `'${workspaceSchema.id}' is a built-in schema and can't be redefined` + : error instanceof Error ? error.message : String(error); + this.failedSchemas.set(fileUri, { id: workspaceSchema.id, message }); this.server.console.error(`Failed to process local schema at ${fileUri}: ${message}`); } } else if (workspaceSchema) { @@ -198,9 +236,10 @@ export class JsonSchemaRegistry { } const newSchemaUri = this.workspaceSchemas.get(fileUri); + const newFailedSchemaUri = this.failedSchemas.get(fileUri)?.id; // Not a schema before or after this change - return new Set([oldSchemaUri, newSchemaUri].filter((uri) => uri !== undefined)); + return new Set([oldSchemaUri, oldFailedSchemaUri, newSchemaUri, newFailedSchemaUri].filter((uri) => uri !== undefined)); } private async readJson(fileUri: string): Promise { diff --git a/language-server/src/services/Workspace.ts b/language-server/src/services/Workspace.ts index 17e1a7b..d912648 100644 --- a/language-server/src/services/Workspace.ts +++ b/language-server/src/services/Workspace.ts @@ -94,6 +94,7 @@ export class Workspace { } async findFiles(include: string, exclude?: string, maxResults?: number) { - return await this.server.sendRequest(FindFilesRequest.type, { include, exclude, maxResults }); + const fileUris = await this.server.sendRequest(FindFilesRequest.type, { include, exclude, maxResults }); + return fileUris.map(normalizeIri); } }