Skip to content

Commit

Permalink
Get sub schema using parsed data for additional context (#133)
Browse files Browse the repository at this point in the history
As mentioned in #132, the "calculated" sub schema for a path in the JSON
document can change based on the values of other fields in the document.
`json-schema-library` already has the feature to [get
schema](https://github.com/sagold/json-schema-library?tab=readme-ov-file#getschema)
with the data when the schema is dynamic.

To retrieve the data, I added `best-effort-json-parser` so we can get
_some_ data even if the JSON document isn't in a valid JSON state (which
is going to be the case while writing the document). Given a document in
the following state:

```json
{
  "type": "Test_2",
  "props": {
    te
  }
}
```

it is able to retrieve the data as:

```json
{
    "type": "Test_1",
    "props": {
        "te": null
    }
}
```

...which is sufficient context (at least for all the existing test
cases)

Other changes in this PR include:
- deleted unused (old) json-completion.ts file
- created the `DocumentParser` type and moved parsers into a separate
directory
- added `loglevel` for better log tracing (logs now point to the file
the logs come from as opposed to `debug.ts`)
  • Loading branch information
imolorhe authored Jun 23, 2024
1 parent 622ae6c commit 4fd7cc6
Show file tree
Hide file tree
Showing 21 changed files with 230 additions and 1,019 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-ducks-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"codemirror-json-schema": patch
---

Get sub schema using parsed data for additional context
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@
"@shikijs/markdown-it": "^1.1.7",
"@types/json-schema": "^7.0.12",
"@types/node": "^20.4.2",
"best-effort-json-parser": "^1.1.2",
"json-schema": "^0.4.0",
"json-schema-library": "^9.3.5",
"loglevel": "^1.9.1",
"markdown-it": "^14.0.0",
"vite-tsconfig-paths": "^4.3.1",
"yaml": "^2.3.4"
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions src/features/__tests__/__fixtures__/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,52 @@ export const testSchema4 = {
},
},
} as JSONSchema7;

export const testSchemaConditionalProperties = {
type: "object",
properties: {
type: {
type: "string",
enum: ["Test_1", "Test_2"],
},
props: {
type: "object",
},
},
allOf: [
{
if: {
properties: {
type: { const: "Test_1" },
},
},
then: {
properties: {
props: {
properties: {
test1Props: { type: "string" },
},
additionalProperties: false,
},
},
},
},
{
if: {
properties: {
type: { const: "Test_2" },
},
},
then: {
properties: {
props: {
properties: {
test2Props: { type: "number" },
},
additionalProperties: false,
},
},
},
},
],
} as JSONSchema7;
80 changes: 64 additions & 16 deletions src/features/__tests__/json-completion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, it } from "vitest";

import { expectCompletion } from "./__helpers__/completion";
import { MODES } from "../../constants";
import { testSchema3, testSchema4 } from "./__fixtures__/schemas";
import {
testSchema3,
testSchema4,
testSchemaConditionalProperties,
} from "./__fixtures__/schemas";

describe.each([
{
Expand Down Expand Up @@ -61,21 +65,20 @@ describe.each([
},
],
},
// TODO: fix the default template with braces: https://discuss.codemirror.net/t/inserting-literal-via-snippets/8136/4
// {
// name: "include defaults for string with braces",
// mode: MODES.JSON,
// docs: ['{ "bracedStringDefault| }'],
// expectedResults: [
// {
// label: "bracedStringDefault",
// type: "property",
// detail: "string",
// info: "a string with a default value containing braces",
// template: '"bracedStringDefault": "${✨ A message from %{whom}: ✨}"',
// },
// ],
// },
{
name: "include defaults for string with braces",
mode: MODES.JSON,
docs: ['{ "bracedStringDefault| }'],
expectedResults: [
{
label: "bracedStringDefault",
type: "property",
detail: "string",
info: "a string with a default value containing braces",
template: '"bracedStringDefault": "${✨ A message from %{whom\\}: ✨}"',
},
],
},
{
name: "include defaults for enum when available",
mode: MODES.JSON,
Expand Down Expand Up @@ -391,6 +394,21 @@ describe.each([
],
schema: testSchema4,
},
{
name: "autocomplete for a schema with conditional properties",
mode: MODES.JSON,
docs: ['{ "type": "Test_1", "props": { t| }}'],
expectedResults: [
{
type: "property",
detail: "string",
info: "",
label: "test1Props",
template: '"test1Props": "#{}"',
},
],
schema: testSchemaConditionalProperties,
},
// JSON5
{
name: "return bare property key when no quotes are used",
Expand Down Expand Up @@ -551,6 +569,21 @@ describe.each([
},
],
},
{
name: "autocomplete for a schema with conditional properties",
mode: MODES.JSON5,
docs: ["{ type: 'Test_1', props: { t| }}"],
expectedResults: [
{
type: "property",
detail: "string",
info: "",
label: "test1Props",
template: "test1Props: '#{}'",
},
],
schema: testSchemaConditionalProperties,
},
// YAML
{
name: "return completion data for simple types",
Expand Down Expand Up @@ -753,6 +786,21 @@ describe.each([
},
],
},
{
name: "autocomplete for a schema with conditional properties",
mode: MODES.YAML,
docs: ["type: Test_1\nprops: { t| }"],
expectedResults: [
{
type: "property",
detail: "string",
info: "",
label: "test1Props",
template: "test1Props: #{}",
},
],
schema: testSchemaConditionalProperties,
},
])("jsonCompletion", ({ name, docs, mode, expectedResults, schema }) => {
it.each(docs)(`${name} (mode: ${mode})`, async (doc) => {
await expectCompletion(doc, expectedResults, { mode, schema });
Expand Down
26 changes: 21 additions & 5 deletions src/features/__tests__/json-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,18 @@ describe("json-validation", () => {
],
},
{
name: "not handle invalid json",
name: "can handle invalid json",
mode: MODES.JSON,
doc: '{"foo": "example" "bar": 123}',
errors: [],
// TODO: we don't have a best effort parser for YAML yet so this test will fail
skipYaml: true,
errors: [
{
from: 18,
message: "Additional property `bar` is not allowed",
to: 23,
},
],
},
{
name: "provide range for invalid multiline json",
Expand Down Expand Up @@ -166,10 +174,16 @@ describe("json-validation", () => {
],
},
{
name: "not handle invalid json",
name: "can handle invalid json",
mode: MODES.JSON5,
doc: "{foo: 'example' 'bar': 123}",
errors: [],
errors: [
{
from: 16,
message: "Additional property `bar` is not allowed",
to: 21,
},
],
},
{
name: "provide range for invalid multiline json",
Expand Down Expand Up @@ -237,7 +251,9 @@ describe("json-validation", () => {
schema: testSchema2,
},
// YAML
...jsonSuite.map((t) => ({ ...t, mode: MODES.YAML })),
...jsonSuite
.map((t) => (!t.skipYaml ? { ...t, mode: MODES.YAML } : null))
.filter((x): x is Exclude<typeof x, null> => !!x),
{
name: "provide range for a value error",
mode: MODES.YAML,
Expand Down
24 changes: 22 additions & 2 deletions src/features/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { MODES, TOKENS } from "../constants";
import { JSONMode } from "../types";
import { renderMarkdown } from "../utils/markdown";
import { DocumentParser, getDefaultParser } from "../parsers";

class CompletionCollector {
completions = new Map<string, Completion>();
Expand All @@ -50,13 +51,17 @@ class CompletionCollector {

export interface JSONCompletionOptions {
mode?: JSONMode;
jsonParser?: DocumentParser;
}

export class JSONCompletion {
private schema: JSONSchema7 | null = null;
private mode: JSONMode = MODES.JSON;
private parser: DocumentParser;

constructor(private opts: JSONCompletionOptions) {
this.mode = opts.mode ?? MODES.JSON;
this.parser = this.opts?.jsonParser ?? getDefaultParser(this.mode);
}
public doComplete(ctx: CompletionContext) {
const s = getJSONSchema(ctx.state)!;
Expand Down Expand Up @@ -810,7 +815,21 @@ export class JSONCompletion {
): JSONSchema7Definition[] {
const draft = new Draft07(this.schema!);
let pointer = jsonPointerForPosition(ctx.state, ctx.pos, -1, this.mode);
let subSchema = draft.getSchema({ pointer });
// Pass parsed data to getSchema to get the correct schema based on the data context
const { data } = this.parser(ctx.state);
let subSchema = draft.getSchema({
pointer,
data: data ?? undefined,
});
debug.log(
"xxxx",
"draft.getSchema",
subSchema,
"data",
data,
"pointer",
pointer
);
if (isJsonError(subSchema)) {
subSchema = subSchema.data?.schema;
}
Expand All @@ -819,7 +838,8 @@ export class JSONCompletion {
!subSchema ||
subSchema.name === "UnknownPropertyError" ||
subSchema.enum ||
subSchema.type === "undefined"
subSchema.type === "undefined" ||
subSchema.type === "null"
) {
pointer = pointer.replace(/\/[^/]*$/, "/");
subSchema = draft.getSchema({ pointer });
Expand Down
18 changes: 3 additions & 15 deletions src/features/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,12 @@ import { Draft04, type Draft, type JsonError } from "json-schema-library";
import { getJSONSchema, schemaStateField } from "./state";
import { joinWithOr } from "../utils/formatting";
import { JSONMode, JSONPointerData, RequiredPick } from "../types";
import { parseJSONDocumentState } from "../utils/parse-json-document";
import { el } from "../utils/dom";
import { renderMarkdown } from "../utils/markdown";
import { MODES } from "../constants";
import { parseYAMLDocumentState } from "../utils/parse-yaml-document";
import { parseJSON5DocumentState } from "../utils/parse-json5-document";
import { debug } from "../utils/debug";
import { DocumentParser, getDefaultParser } from "../parsers";

const getDefaultParser = (mode: JSONMode): typeof parseJSONDocumentState => {
switch (mode) {
case MODES.JSON:
return parseJSONDocumentState;
case MODES.JSON5:
return parseJSON5DocumentState;
case MODES.YAML:
return parseYAMLDocumentState;
}
};
// return an object path that matches with the json-source-map pointer
const getErrorPath = (error: JsonError): string => {
// if a pointer is present, return without #
Expand All @@ -40,7 +28,7 @@ const getErrorPath = (error: JsonError): string => {
export interface JSONValidationOptions {
mode?: JSONMode;
formatError?: (error: JsonError) => string;
jsonParser?: typeof parseJSONDocumentState;
jsonParser?: DocumentParser;
}

type JSONValidationSettings = RequiredPick<JSONValidationOptions, "jsonParser">;
Expand Down Expand Up @@ -75,7 +63,7 @@ export class JSONValidation {
private schema: Draft | null = null;

private mode: JSONMode = MODES.JSON;
private parser: typeof parseJSONDocumentState = parseJSONDocumentState;
private parser: DocumentParser;
public constructor(private options?: JSONValidationOptions) {
this.mode = this.options?.mode ?? MODES.JSON;
this.parser = this.options?.jsonParser ?? getDefaultParser(this.mode);
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type {
JSONPartialPointerData,
} from "./types";

export * from "./utils/parse-json-document";
export * from "./parsers/json-parser";
export * from "./utils/json-pointers";

export * from "./features/state";
Loading

0 comments on commit 4fd7cc6

Please sign in to comment.