diff --git a/language-server/package-lock.json b/language-server/package-lock.json index b2905a9..c54a88f 100644 --- a/language-server/package-lock.json +++ b/language-server/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@hyperjump/json-schema": "^1.17.6", + "@hyperjump/json-schema-errors": "github:hyperjump-io/json-schema-errors", "jsonc-parser": "^3.3.1", "merge-anything": "^6.0.6", "vscode-languageserver": "^10.0.0", @@ -17,6 +18,15 @@ "vscode-uri": "^3.1.0" } }, + "node_modules/@fluent/bundle": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.19.1.tgz", + "integrity": "sha512-SWJLZrPamDPsJlFFOW1nkgN0j0rbPbmSdmK0XAoXlyqKieLtMVl4vzng3aR5pwKoUx0scug8+YY2oct3fdfy9A==", + "engines": { + "node": ">=18.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@hyperjump/browser": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.1.tgz", @@ -70,6 +80,23 @@ "@hyperjump/browser": "^1.1.0" } }, + "node_modules/@hyperjump/json-schema-errors": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/hyperjump-io/json-schema-errors.git#72e8bc4e5b49a5b0ab278bed68d41f82c00ac774", + "license": "MIT", + "dependencies": { + "@fluent/bundle": "^0.19.1", + "@hyperjump/json-pointer": "^1.1.1", + "@hyperjump/json-schema": "^1.17.2", + "@hyperjump/pact": "^1.4.0", + "@hyperjump/uri": "^1.3.2", + "json-stringify-deterministic": "^1.0.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/hyperjump-io" + } + }, "node_modules/@hyperjump/json-schema-formats": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@hyperjump/json-schema-formats/-/json-schema-formats-1.0.1.tgz", @@ -126,7 +153,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", "engines": { "node": ">=18" }, @@ -159,7 +185,6 @@ "version": "6.0.6", "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-6.0.6.tgz", "integrity": "sha512-F3K1W45PvTjRZzbcYIhXntNr8cux00gUxR8IzNPPG+80gNlAHZGVBwFyN4x5yjw/7QkLPKDbRQBK4KrJKo69mw==", - "license": "MIT", "dependencies": { "is-what": "^5.2.0" }, @@ -226,8 +251,7 @@ "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" }, "node_modules/vscode-languageserver-types": { "version": "3.18.0", @@ -238,8 +262,7 @@ "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" } } } diff --git a/language-server/package.json b/language-server/package.json index d906b91..ab9c4d1 100644 --- a/language-server/package.json +++ b/language-server/package.json @@ -20,6 +20,7 @@ "keywords": [], "dependencies": { "@hyperjump/json-schema": "^1.17.6", + "@hyperjump/json-schema-errors": "github:hyperjump-io/json-schema-errors", "jsonc-parser": "^3.3.1", "merge-anything": "^6.0.6", "vscode-languageserver": "^10.0.0", diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 522de59..84ca587 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -1,9 +1,14 @@ import "@hyperjump/json-schema/draft-2020-12"; +import "@hyperjump/json-schema/draft-2019-09"; +import "@hyperjump/json-schema/draft-07"; +import "@hyperjump/json-schema/draft-06"; +import "@hyperjump/json-schema/draft-04"; import { TextDocuments } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import { Server } from "./services/server.ts"; import { Diagnostics } from "./features/Diagnostics.ts"; import { SyntaxValidation } from "./features/SyntaxValidation.ts"; +import { SchemaValidation } from "./features/SchemaValidation.ts"; import type { Connection } from "vscode-languageserver"; @@ -17,7 +22,8 @@ export const buildServer = (connection: Connection): Connection => { documents.listen(server); new Diagnostics(server, documents, [ - new SyntaxValidation() + new SyntaxValidation(), + new SchemaValidation() ]); return server; diff --git a/language-server/src/features/Diagnostics.ts b/language-server/src/features/Diagnostics.ts index 4228586..36e89b1 100644 --- a/language-server/src/features/Diagnostics.ts +++ b/language-server/src/features/Diagnostics.ts @@ -5,7 +5,7 @@ import { Server } from "../services/server.ts"; import type { ServerCapabilities, Diagnostic } from "vscode-languageserver"; export type DiagnosticsProvider = { - getDiagnostics(textDocument: TextDocument): Diagnostic[]; + getDiagnostics(textDocument: TextDocument): Promise; }; export class Diagnostics { @@ -27,7 +27,7 @@ export class Diagnostics { documents.onDidChangeContent(async (change) => { const diagnostics = []; for (const provider of this.providers) { - diagnostics.push(...provider.getDiagnostics(change.document)); + diagnostics.push(...await provider.getDiagnostics(change.document)); } await server.sendDiagnostics({ diff --git a/language-server/src/features/SchemaValidation.ts b/language-server/src/features/SchemaValidation.ts new file mode 100644 index 0000000..a24bb6b --- /dev/null +++ b/language-server/src/features/SchemaValidation.ts @@ -0,0 +1,67 @@ +import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import * as jsonc from "jsonc-parser"; +import { validate } from "@hyperjump/json-schema-errors"; +import { pointerSegments } from "@hyperjump/json-pointer"; + +import type { ErrorObject } from "@hyperjump/json-schema-errors"; +import type { DiagnosticsProvider } from "./Diagnostics.ts"; + +export class SchemaValidation implements DiagnosticsProvider { + async getDiagnostics(textDocument: TextDocument) { + const text = textDocument.getText(); + const parseErrors: jsonc.ParseError[] = []; + + const tree = jsonc.parseTree(text, parseErrors); + const schemaDiagnostics: Diagnostic[] = []; + + const schemaNode = tree ? jsonc.findNodeAtLocation(tree, ["$schema"]) : undefined; + const schemaUri = schemaNode?.value; + + // skip schema validation if there are syntax errors hence the parseError.length check + if (schemaUri && parseErrors.length === 0) { + let instance = JSON.parse(text); + const result = await validate(schemaUri, instance); + + if (!result.valid) { + const errors = result.errors; + errors.forEach((error) => { + const node = tree ? findNodeByPointer(tree, error.instanceLocation) : undefined; + + if (node) { + schemaDiagnostics.push({ + severity: DiagnosticSeverity.Error, + range: { + start: textDocument.positionAt(node.offset), + end: textDocument.positionAt(node.offset + node.length) + }, + message: formatError(error), + source: "json-language-server" + }); + } + }); + } + } + + return schemaDiagnostics; + } +} + +const findNodeByPointer = (node: jsonc.Node, pointer: string) => { + if (pointer === "#") { + return node; + } + const segments = [...pointerSegments(pointer.slice(1))]; + const path = segments.map((s) => /^\d+$/.test(s) ? parseInt(s) : s); + return jsonc.findNodeAtLocation(node, path); +}; + +const formatError = (error: ErrorObject, depth = 0): string => { + let msg = error.message; + if (error.alternatives && error.alternatives.length > 0) { + const indent = " ".repeat(depth + 1); + const lines = error.alternatives.flatMap((alt) => alt.map((subErr) => `${indent}- ${formatError(subErr, depth + 1)}`)); + msg += `:\n${lines.join("\n")}`; + } + return msg; +}; diff --git a/language-server/src/features/SyntaxValidation.ts b/language-server/src/features/SyntaxValidation.ts index 289f1a5..c0a8cd4 100644 --- a/language-server/src/features/SyntaxValidation.ts +++ b/language-server/src/features/SyntaxValidation.ts @@ -1,11 +1,11 @@ -import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver"; +import { DiagnosticSeverity } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import * as jsonc from "jsonc-parser"; import type { DiagnosticsProvider } from "./Diagnostics.ts"; export class SyntaxValidation implements DiagnosticsProvider { - getDiagnostics(textDocument: TextDocument): Diagnostic[] { + async getDiagnostics(textDocument: TextDocument) { const text = textDocument.getText(); const parseErrors: jsonc.ParseError[] = []; diff --git a/language-server/src/language-server.test.ts b/language-server/src/language-server.test.ts index e56838e..02a9596 100644 --- a/language-server/src/language-server.test.ts +++ b/language-server/src/language-server.test.ts @@ -1,9 +1,11 @@ -import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; import { TextDocumentSyncKind } from "vscode-languageserver"; import { TestClient } from "./test/test-client.ts"; +import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; describe("JSON Language Server", () => { let client: TestClient; + const fixtureSchemaUri = "https://example.com/person"; beforeAll(async () => { client = new TestClient(); @@ -14,6 +16,10 @@ describe("JSON Language Server", () => { await client.stop(); }); + afterEach(() => { + unregisterSchema(fixtureSchemaUri); + }); + test("textDocumentSync = Incremental", () => { expect(client.serverCapabilities?.textDocumentSync).to.equal(TextDocumentSyncKind.Incremental); }); @@ -45,4 +51,166 @@ describe("JSON Language Server", () => { const diagnostics = await diagnosticsPromise; expect(diagnostics).toHaveLength(0); }); + + test("JSON Validation using Hyperjump - Valid Case", async () => { + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + const testSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" } + } + }; + + registerSchema(testSchema, fixtureSchemaUri); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": "Alice", + "age" : 39 + }`); + await client.openDocument("instance.json"); + + const diagnostics = await diagnosticsPromise; + expect(diagnostics).toHaveLength(0); + }); + + test("JSON Validation using Hyperjump - Invalid Case", async () => { + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + const testSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" } + } + }; + + registerSchema(testSchema, fixtureSchemaUri); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "name": 1234, + "age" : "hello" + }`); + await client.openDocument("instance.json"); + + const diagnostics = (await diagnosticsPromise) as any[]; + expect(diagnostics).toHaveLength(2); + const messages = diagnostics.map((d) => d.message); + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringMatching(/Expected a.*string/), + expect.stringMatching(/Expected a.*number/) + ]) + ); + }); + + test("JSON Validation using Hyperjump - anyOf Formatting Case", async () => { + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + const testSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + value: { + anyOf: [ + { type: "string" }, + { type: "number" } + ] + } + } + }; + + registerSchema(testSchema, fixtureSchemaUri); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "value": true + }`); + await client.openDocument("instance.json"); + + const diagnostics = (await diagnosticsPromise) as any[]; + expect(diagnostics).toHaveLength(1); + const cleanMessage = diagnostics[0].message.replace(/[\u2068\u2069]/g, ""); + expect(cleanMessage).toBe("Expected the value to match at least one alternative:\n - Expected a string\n - Expected a number"); + }); + + test("JSON Validation using Hyperjump - oneOf Formatting Case", async () => { + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + const testSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + value: { + oneOf: [ + { type: "string" }, + { type: "number" } + ] + } + } + }; + + registerSchema(testSchema, fixtureSchemaUri); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "value": true + }`); + await client.openDocument("instance.json"); + + const diagnostics = (await diagnosticsPromise) as any[]; + expect(diagnostics).toHaveLength(1); + const cleanMessage = diagnostics[0].message.replace(/[\u2068\u2069]/g, ""); + expect(cleanMessage).toBe("Expected the value to match exactly one alternative, but none matched:\n - Expected a string\n - Expected a number"); + }); + + test("JSON Validation using Hyperjump - property name with slash (escape sequence)", async () => { + const diagnosticsPromise = new Promise((resolve) => { + client.onNotification("textDocument/publishDiagnostics", (params) => { + resolve(params.diagnostics); + }); + }); + + const testSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + "foo/bar": { type: "string" } + } + }; + + registerSchema(testSchema, fixtureSchemaUri); + + await client.writeDocument("instance.json", `{ + "$schema": "${fixtureSchemaUri}", + "foo/bar": 11 + }`); + await client.openDocument("instance.json"); + + const diagnostics = (await diagnosticsPromise) as any[]; + expect(diagnostics).toHaveLength(1); + const cleanMessage = diagnostics[0].message.replace(/[\u2068\u2069]/g, ""); + expect(cleanMessage).toBe("Expected a string"); + }); });