Skip to content
29 changes: 27 additions & 2 deletions language-server/src/build-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Server } from "./services/server.ts";
import { JsonDocuments } from "./services/JsonDocuments.ts";
import { SchemaStore } from "./services/SchemaStore.ts";
import { Diagnostics } from "./features/Diagnostics.ts";
import { SyntaxValidation } from "./features/SyntaxValidation.ts";
import { SchemaValidation } from "./features/SchemaValidation.ts";
Expand All @@ -17,14 +18,38 @@ export type LanguageServerSettings = {

export const buildServer = (connection: Connection): Connection => {
const server = new Server(connection);
const schemaStore = new SchemaStore(server);

const documents = new JsonDocuments(server);
const documents = new JsonDocuments(server, schemaStore);
documents.listen(server);

new Diagnostics(server, documents, [
const diagnostics = new Diagnostics(server, documents, [
new SyntaxValidation(),
new SchemaValidation()
]);

server.onDidChangeWatchedFiles(async (params) => {
for (const change of params.changes) {
schemaStore.clear(change.uri);
}

for (const document of documents.all()) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am figuring out some way to find which open files reference the updated schema, until then i am looping through and revalidating all open documents.

document.revalidate();
await diagnostics.sendDiagnostics(document);
}
});

documents.onDidChangeContent(async (change) => {
const changedUri = change.document.uri;
schemaStore.clear(changedUri);

for (const document of documents.all()) {
if (document.uri !== changedUri && document.getSchemaUri() === changedUri) {
document.revalidate();
await diagnostics.sendDiagnostics(document);
}
}
});

return server;
};
22 changes: 14 additions & 8 deletions language-server/src/features/Diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,27 @@ export type DiagnosticsProvider = {
};

export class Diagnostics {
private server: Server;
private providers: DiagnosticsProvider[];

constructor(server: Server, documents: JsonDocuments, providers: DiagnosticsProvider[]) {
this.server = server;
this.providers = providers;

documents.onDidChangeContent(async (change) => {
const diagnostics = [];
for (const provider of this.providers) {
diagnostics.push(...await provider.getDiagnostics(change.document));
}
await this.sendDiagnostics(change.document);
});
}

async sendDiagnostics(document: JsonDocument) {
const diagnostics = [];
for (const provider of this.providers) {
diagnostics.push(...await provider.getDiagnostics(document));
}

await server.sendDiagnostics({
uri: change.document.uri,
diagnostics: diagnostics
});
await this.server.sendDiagnostics({
uri: document.uri,
diagnostics: diagnostics
});
}
}
53 changes: 50 additions & 3 deletions language-server/src/features/SchemaValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest";
import { describe, test, expect, afterEach, beforeEach } from "vitest";
import { TestClient } from "../test/test-client.ts";
import { unregisterSchema } from "@hyperjump/json-schema";

Expand All @@ -8,12 +8,12 @@ describe("Schema Validation", () => {
let client: TestClient;
let fixtureSchemaUri: string;

beforeAll(async () => {
beforeEach(async () => {
client = new TestClient();
await client.start();
});

afterAll(async () => {
afterEach(async () => {
await client.stop();
});

Expand Down Expand Up @@ -322,4 +322,51 @@ describe("Schema Validation", () => {
const diagnostics2 = await diagnosticsPromise2;
expect(diagnostics2).toHaveLength(0);
});

test("changing the schema should invalidate the cache", async () => {
const diagnosticsPromise1 = new Promise<Diagnostic[]>((resolve) => {
client.onNotification("textDocument/publishDiagnostics", (params: PublishDiagnosticsParams) => {
resolve(params.diagnostics);
});
});

fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
"name": "Alice",
"age" : "not a number"
}`);
const instanceUri = await client.openDocument("instance.json");

const diagnostics1 = await diagnosticsPromise1;
expect(diagnostics1).toHaveLength(1);

const diagnosticsPromise2 = new Promise<Diagnostic[]>((resolve) => {
client.onNotification("textDocument/publishDiagnostics", (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});

await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "string" }
}
}`);

const diagnostics2 = await diagnosticsPromise2;
expect(diagnostics2).toHaveLength(0);
});
});
49 changes: 18 additions & 31 deletions language-server/src/features/SchemaValidation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver";
import { validate } from "@hyperjump/json-schema-errors";
import { JsonDocument } from "../models/JsonDocument.ts";

import type { ErrorObject } from "@hyperjump/json-schema-errors";
Expand All @@ -9,39 +8,27 @@ export class SchemaValidation implements DiagnosticsProvider {
async getDiagnostics(jsonDocument: JsonDocument) {
const schemaDiagnostics: Diagnostic[] = [];

const schemaNode = jsonDocument.findNodeAtPointer("/$schema");
const schemaUri = schemaNode?.value;

// skip schema validation if there are syntax errors hence the parseError.length check
if (schemaUri && jsonDocument.getParseErrors().length === 0) {
let instance = JSON.parse(jsonDocument.getText());
try {
const result = await validate(schemaUri, instance);

if (!result.valid) {
const errors = result.errors;
errors.forEach((error) => {
const pointer = decodeURIComponent(error.instanceLocation.slice(1));
const node = jsonDocument.findNodeAtPointer(pointer);

if (node) {
schemaDiagnostics.push({
severity: DiagnosticSeverity.Error,
range: {
start: jsonDocument.positionAt(node.offset),
end: jsonDocument.positionAt(node.offset + node.length)
},
message: formatError(error),
source: "hyperjump-json-language-server"
});
}
const result = await jsonDocument.getSchemaErrors();

if (result?.valid === false) {
const errors = result.errors;
errors.forEach((error) => {
const pointer = decodeURIComponent(error.instanceLocation.slice(1));
const node = jsonDocument.findNodeAtPointer(pointer);

if (node) {
schemaDiagnostics.push({
severity: DiagnosticSeverity.Error,
range: {
start: jsonDocument.positionAt(node.offset),
end: jsonDocument.positionAt(node.offset + node.length)
},
message: formatError(error),
source: "hyperjump-json-language-server"
});
}
} catch (_error: unknown) {
// TODO: Handle invalid or missing schema errors
}
});
}

return schemaDiagnostics;
}
}
Expand Down
6 changes: 3 additions & 3 deletions language-server/src/features/SyntaxValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { TestClient } from "../test/test-client.ts";

describe("Syntax Validation", () => {
let client: TestClient;

beforeAll(async () => {
beforeEach(async () => {
client = new TestClient();
await client.start();
});

afterAll(async () => {
afterEach(async () => {
await client.stop();
});

Expand Down
42 changes: 39 additions & 3 deletions language-server/src/models/JsonDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,42 @@ import { TextDocumentContentChangeEvent } from "vscode-languageserver";
import { TextDocument } from "vscode-languageserver-textdocument";
import * as jsonc from "jsonc-parser";
import { pointerSegments } from "@hyperjump/json-pointer";
import { SchemaStore } from "../services/SchemaStore.ts";

import type { Position, Range } from "vscode-languageserver-textdocument";
import type { ValidationResult } from "@hyperjump/json-schema-errors";

export class JsonDocument implements TextDocument {
private textDocument: TextDocument;
private schemaStore: SchemaStore;
private ast: jsonc.Node | undefined;
private parseErrors: jsonc.ParseError[] = [];
private schemaErrors: Promise<ValidationResult> | undefined;

constructor(textDocument: TextDocument) {
constructor(textDocument: TextDocument, schemaStore: SchemaStore) {
this.textDocument = textDocument;
this.schemaStore = schemaStore;

this.validate();
}

private validate() {
this.parseErrors = [];
this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors);

if (this.parseErrors.length > 0) {
return;
}

const schemaNode = this.findNodeAtPointer("/$schema");
const schemaUri = schemaNode?.value;

if (schemaUri === undefined) {
return;
}

let instance = JSON.parse(this.getText());
this.schemaErrors = this.schemaStore.validate(schemaUri, instance);
}

get uri() {
Expand Down Expand Up @@ -46,14 +70,26 @@ export class JsonDocument implements TextDocument {

update(changes: TextDocumentContentChangeEvent[], version: number): void {
TextDocument.update(this.textDocument, changes, version);
this.parseErrors = [];
this.ast = jsonc.parseTree(this.textDocument.getText(), this.parseErrors);
this.validate();
}

revalidate() {
this.validate();
}

getParseErrors() {
return this.parseErrors;
}

getSchemaErrors() {
return this.schemaErrors;
}

getSchemaUri() {
const schemaNode = this.findNodeAtPointer("/$schema");
return schemaNode?.value as string | undefined;
}

findNodeAtPointer(pointer: string) {
if (!this.ast) {
return;
Expand Down
5 changes: 3 additions & 2 deletions language-server/src/services/JsonDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import { JsonDocument } from "../models/JsonDocument.ts";
import { Server } from "./server.ts";

import type { DocumentUri, ServerCapabilities, TextDocumentContentChangeEvent } from "vscode-languageserver";
import type { SchemaStore } from "./SchemaStore.ts";

export class JsonDocuments extends TextDocuments<JsonDocument> {
private server: Server;
private hasWorkspaceWatchCapability: boolean = false;

constructor(server: Server) {
constructor(server: Server, schemaStore: SchemaStore) {
super({
create(uri: DocumentUri, languageId: string, version: number, content: string) {
const textDocument = TextDocument.create(uri, languageId, version, content);
return new JsonDocument(textDocument);
return new JsonDocument(textDocument, schemaStore);
},
update(document: JsonDocument, changes: TextDocumentContentChangeEvent[], version: number) {
document.update(changes, version);
Expand Down
36 changes: 36 additions & 0 deletions language-server/src/services/SchemaStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { validate } from "@hyperjump/json-schema-errors";
import { unregisterSchema } from "@hyperjump/json-schema";

import type { EvaluateInstance, Json } from "@hyperjump/json-schema-errors";
import type { Server } from "../services/server.ts";

export class SchemaStore {
private validatorCache: Map<string, EvaluateInstance> = new Map();

constructor(server: Server) {
server.onExit(() => {
this.clearAll();
});
}

async validate(schemaUri: string, instance: Json) {
if (!this.validatorCache.has(schemaUri)) {
this.validatorCache.set(schemaUri, await validate(schemaUri));
}
const validator = this.validatorCache.get(schemaUri)!;

return validator(instance);
}

clearAll() {
for (const schemaUri of this.validatorCache.keys()) {
this.clear(schemaUri);
}
}

clear(schemaUri: string) {
unregisterSchema(schemaUri);
this.validatorCache.delete(schemaUri);
// TODO: Unregister schemas under $id and id
}
}
4 changes: 3 additions & 1 deletion language-server/src/test/test-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export class TestClient {

this.openDocuments.add(documentUri.toString());

return documentUri;
return documentUri.toString();
}

async changeDocument(uri: string, text: string) {
Expand All @@ -269,6 +269,8 @@ export class TestClient {
},
contentChanges: [{ text }]
});

return documentUri.toString();
}

async closeDocument(uri: string) {
Expand Down
Loading