Skip to content

Commit

Permalink
Refactor to use JsonNode and JsonSchemaDocument
Browse files Browse the repository at this point in the history
  • Loading branch information
jdesrosiers committed May 5, 2024
1 parent dc14ea4 commit e758ba1
Show file tree
Hide file tree
Showing 13 changed files with 693 additions and 725 deletions.
2 changes: 1 addition & 1 deletion language-server/src/annotation-tests/applicators.json
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@
{
"location": "#",
"keyword": "title",
"expected": ["Else", "If"]
"expected": ["Else"]
}
]
}
Expand Down
29 changes: 20 additions & 9 deletions language-server/src/annotations.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";

import { registerSchema, unregisterSchema } from "@hyperjump/json-schema/draft-2020-12";
import { annotate } from "./json-schema.js";
import { getSchema, compile, interpret } from "@hyperjump/json-schema/experimental";
import * as Instance from "./json-instance.js";
import { toAbsoluteUri } from "./util.js";
import { TextDocument } from "vscode-languageserver-textdocument";
import { JsoncInstance } from "./jsonc-instance.js";
import { parseTree } from "jsonc-parser";


const __filename = fileURLToPath(import.meta.url);
Expand All @@ -28,10 +29,13 @@ describe("Annotations", () => {
suites.forEach((suite) => {
describe(suite.title + "\n" + JSON.stringify(suite.schema, null, " "), () => {
let id;
let compiled;

beforeAll(async () => {
id = `${host}/${encodeURIComponent(suite.title)}`;
id = `${host}/${encodeURI(suite.title)}`;
registerSchema(suite.schema, id, dialectId);
const schema = await getSchema(id);
compiled = await compile(schema);
});

afterAll(() => {
Expand All @@ -42,17 +46,24 @@ describe("Annotations", () => {
describe("Instance: " + JSON.stringify(subject.instance), () => {
let instance;

beforeEach(async () => {
beforeAll(async () => {
const instanceJson = JSON.stringify(subject.instance, null, " ");
const textDocument = TextDocument.create(id, "json", 1, instanceJson);
instance = await annotate(id, JsoncInstance.fromTextDocument(textDocument));
const json = textDocument.getText();
const root = parseTree(json, [], {
disallowComments: false,
allowTrailingComma: true,
allowEmptyContent: true
});
instance = Instance.fromJsonc(root);
interpret(compiled, instance);
});

subject.assertions.forEach((assertion) => {
it(`${assertion.keyword} annotations at '${assertion.location}' should be ${JSON.stringify(assertion.expected)}`, () => {
const dialect = suite.schema.$schema ? toAbsoluteUri(suite.schema.$schema) : undefined;
const annotations = instance.get(assertion.location)
.annotation(assertion.keyword, dialect);
const dialect = suite.schema.$schema ? toAbsoluteUri(suite.schema.$schema) : dialectId;
const subject = Instance.get(assertion.location, instance);
const annotations = subject ? Instance.annotation(subject, assertion.keyword, dialect) : [];
expect(annotations).to.eql(assertion.expected);
});
});
Expand Down
67 changes: 67 additions & 0 deletions language-server/src/json-instance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as JsonPointer from "@hyperjump/json-pointer";
import * as Instance from "@hyperjump/json-schema/annotated-instance/experimental";
import { getNodeValue } from "jsonc-parser";


export const fromJsonc = (node, uri = "", pointer = "", parent = undefined) => {
const jsonNode = cons(uri, pointer, getNodeValue(node), node.type, [], parent, node.offset, node.length);

switch (node.type) {
case "array":
jsonNode.children = node.children.map((child, index) => {
const itemPointer = JsonPointer.append(index, pointer);
return fromJsonc(child, uri, itemPointer, jsonNode);
});
break;

case "object":
jsonNode.children = node.children.map((child) => {
const propertyPointer = JsonPointer.append(getNodeValue(child.children[0]), pointer);
return fromJsonc(child, uri, propertyPointer, jsonNode);
});
break;

case "property":
jsonNode.children = node.children.map((child) => {
return fromJsonc(child, uri, pointer, jsonNode);
});
break;
}

return jsonNode;
};

// eslint-disable-next-line import/export
export const cons = (uri, pointer, value, type, children, parent, offset, textLength) => {
const node = Instance.cons(uri, pointer, value, type, children, parent);
node.offset = offset;
node.textLength = textLength;

return node;
};

// eslint-disable-next-line import/export
export const annotation = (node, keyword, dialect = "https://json-schema.org/draft/2020-12/schema") => {
return Instance.annotation(node, keyword, dialect);
};

export const findNodeAtOffset = (node, offset, includeRightBound = false) => {
if (contains(node, offset, includeRightBound)) {
for (let i = 0; i < node.children.length && node.children[i].offset <= offset; i++) {
const item = findNodeAtOffset(node.children[i], offset, includeRightBound);
if (item) {
return item;
}
}

return node;
}
};

const contains = (node, offset, includeRightBound = false) => {
return (offset >= node.offset && offset < (node.offset + node.textLength))
|| includeRightBound && (offset === (node.offset + node.textLength));
};

// eslint-disable-next-line import/export
export * from "@hyperjump/json-schema/annotated-instance/experimental";
204 changes: 204 additions & 0 deletions language-server/src/json-schema-document.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { getSchema, compile, interpret, getKeywordName, hasDialect, BASIC } from "@hyperjump/json-schema/experimental";
import * as JsonPointer from "@hyperjump/json-pointer";
import { resolveIri, toAbsoluteIri } from "@hyperjump/uri";
import { getNodeValue, parseTree } from "jsonc-parser";
import * as Instance from "./json-instance.js";


export class JsonSchemaDocument {
constructor(textDocument) {
this.textDocument = textDocument;
this.schemaResources = [];
this.errors = [];
}

static async fromTextDocument(textDocument, contextDialectUri) {
const document = new JsonSchemaDocument(textDocument);

const json = textDocument.getText();
if (json) {
const root = parseTree(json, [], {
disallowComments: false,
allowTrailingComma: true,
allowEmptyContent: true
});

document.#buildSchemaResources(root, textDocument.uri, contextDialectUri);

for (const { dialectUri, schemaResource } of document.schemaResources) {
if (!hasDialect(dialectUri)) {
const $schema = Instance.get("#/$schema", schemaResource);
if ($schema && Instance.typeOf($schema) === "string") {
document.errors.push({
keyword: "https://json-schema.org/keyword/schema",
instanceNode: $schema,
message: "Unknown dialect"
});
} else if (dialectUri) {
document.errors.push({
keyword: "https://json-schema.org/keyword/schema",
instanceNode: schemaResource,
message: "Unknown dialect"
});
} else {
document.errors.push({
keyword: "https://json-schema.org/keyword/schema",
instanceLocation: schemaResource,
message: "No dialect"
});
}

continue;
}

const schema = await getSchema(dialectUri);
const compiled = await compile(schema);
const output = interpret(compiled, schemaResource, BASIC);
if (!output.valid) {
for (const error of output.errors) {
document.errors.push({
keyword: error.keyword,
keywordNode: await getSchema(error.absoluteKeywordLocation),
instanceNode: Instance.get(error.instanceLocation, schemaResource)
});
}
}
}
}

return document;
}

#buildSchemaResources(node, uri = "", dialectUri = "", pointer = "", parent = undefined) {
const jsonNode = Instance.cons(uri, pointer, getNodeValue(node), node.type, [], parent, node.offset, node.length);

switch (node.type) {
case "array":
jsonNode.children = node.children.map((child, index) => {
const itemPointer = JsonPointer.append(index, pointer);
return this.#buildSchemaResources(child, uri, dialectUri, itemPointer, jsonNode);
});
break;

case "object":
if (pointer === "") {
// Resource root
const $schema = nodeStep(node, "$schema");
if ($schema?.type === "string") {
try {
dialectUri = toAbsoluteIri(getNodeValue($schema));
} catch (error) {
// Ignore
}
}

const idToken = keywordNameFor("https://json-schema.org/keyword/id", dialectUri);
const $idNode = idToken && nodeStep(node, idToken);
if ($idNode) {
uri = toAbsoluteIri(resolveIri(getNodeValue($idNode), uri));
jsonNode.baseUri = uri;
}

const legacyIdToken = keywordNameFor("https://json-schema.org/keyword/draft-04/id", dialectUri);
const legacy$idNode = legacyIdToken && nodeStep(node, legacyIdToken);
if (legacy$idNode?.type === "string") {
const legacy$id = getNodeValue(legacy$idNode);
if (legacy$id[0] !== "#") {
uri = toAbsoluteIri(resolveIri(legacy$id, uri));
jsonNode.baseUri = uri;
}
}
} else {
// Check for embedded schema
const embeddedDialectUri = getEmbeddedDialectUri(node, dialectUri);
if (embeddedDialectUri) {
this.#buildSchemaResources(node, uri, embeddedDialectUri);

return Instance.cons(uri, pointer, true, "boolean", [], parent, node.offset, node.length);
}
}

for (const child of node.children) {
const propertyPointer = JsonPointer.append(getNodeValue(child.children[0]), pointer);
const propertyNode = this.#buildSchemaResources(child, uri, dialectUri, propertyPointer, jsonNode);

if (propertyNode) {
jsonNode.children.push(propertyNode);
}
}
break;

case "property":
if (node.children.length !== 2) {
return;
}

jsonNode.children = node.children.map((child) => {
return this.#buildSchemaResources(child, uri, dialectUri, pointer, jsonNode);
});
break;
}

if (jsonNode.pointer === "") {
this.schemaResources.push({ dialectUri, schemaResource: jsonNode });
}

return jsonNode;
}

* annotatedWith(keyword, dialectId = "https://json-schema.org/draft/2020-12/schema") {
for (const { schemaResource } of this.schemaResources) {
for (const node of Instance.allNodes(schemaResource)) {
if (Instance.annotation(node, keyword, dialectId).length > 0) {
yield node;
}
}
}
}

findNodeAtOffset(offset) {
for (const { schemaResource } of this.schemaResources) {
const node = Instance.findNodeAtOffset(schemaResource, offset);
if (node) {
return node;
}
}
}
}

const getEmbeddedDialectUri = (node, dialectUri) => {
const $schema = nodeStep(node, "$schema");
if ($schema?.type === "string") {
const embeddedDialectUri = toAbsoluteIri(getNodeValue($schema));
if (!hasDialect(embeddedDialectUri)) {
return embeddedDialectUri;
} else {
dialectUri = embeddedDialectUri;
}
}

const idToken = keywordNameFor("https://json-schema.org/keyword/id", dialectUri);
const $idNode = idToken && nodeStep(node, idToken);
if ($idNode?.type === "string") {
return dialectUri;
}

const legacyIdToken = keywordNameFor("https://json-schema.org/keyword/draft-04/id", dialectUri);
const legacy$idNode = legacyIdToken && nodeStep(node, legacyIdToken);
if (legacy$idNode?.type === "string" && getNodeValue(legacy$idNode)[0] !== "#") {
return dialectUri;
}
};

const nodeStep = (node, key) => {
const property = node.children.find((property) => getNodeValue(property.children[0]) === key);
return property?.children[1];
};

const keywordNameFor = (keywordUri, dialectUri) => {
try {
return getKeywordName(dialectUri, keywordUri);
} catch (error) {
return undefined;
}
};
Loading

0 comments on commit e758ba1

Please sign in to comment.