Skip to content

Commit 4fd7cc6

Browse files
authored
Get sub schema using parsed data for additional context (#133)
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`)
1 parent 622ae6c commit 4fd7cc6

21 files changed

+230
-1019
lines changed

.changeset/few-ducks-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"codemirror-json-schema": patch
3+
---
4+
5+
Get sub schema using parsed data for additional context

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@
6262
"@shikijs/markdown-it": "^1.1.7",
6363
"@types/json-schema": "^7.0.12",
6464
"@types/node": "^20.4.2",
65+
"best-effort-json-parser": "^1.1.2",
6566
"json-schema": "^0.4.0",
6667
"json-schema-library": "^9.3.5",
68+
"loglevel": "^1.9.1",
6769
"markdown-it": "^14.0.0",
6870
"vite-tsconfig-paths": "^4.3.1",
6971
"yaml": "^2.3.4"

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/features/__tests__/__fixtures__/schemas.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,52 @@ export const testSchema4 = {
134134
},
135135
},
136136
} as JSONSchema7;
137+
138+
export const testSchemaConditionalProperties = {
139+
type: "object",
140+
properties: {
141+
type: {
142+
type: "string",
143+
enum: ["Test_1", "Test_2"],
144+
},
145+
props: {
146+
type: "object",
147+
},
148+
},
149+
allOf: [
150+
{
151+
if: {
152+
properties: {
153+
type: { const: "Test_1" },
154+
},
155+
},
156+
then: {
157+
properties: {
158+
props: {
159+
properties: {
160+
test1Props: { type: "string" },
161+
},
162+
additionalProperties: false,
163+
},
164+
},
165+
},
166+
},
167+
{
168+
if: {
169+
properties: {
170+
type: { const: "Test_2" },
171+
},
172+
},
173+
then: {
174+
properties: {
175+
props: {
176+
properties: {
177+
test2Props: { type: "number" },
178+
},
179+
additionalProperties: false,
180+
},
181+
},
182+
},
183+
},
184+
],
185+
} as JSONSchema7;

src/features/__tests__/json-completion.spec.ts

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { describe, it } from "vitest";
22

33
import { expectCompletion } from "./__helpers__/completion";
44
import { MODES } from "../../constants";
5-
import { testSchema3, testSchema4 } from "./__fixtures__/schemas";
5+
import {
6+
testSchema3,
7+
testSchema4,
8+
testSchemaConditionalProperties,
9+
} from "./__fixtures__/schemas";
610

711
describe.each([
812
{
@@ -61,21 +65,20 @@ describe.each([
6165
},
6266
],
6367
},
64-
// TODO: fix the default template with braces: https://discuss.codemirror.net/t/inserting-literal-via-snippets/8136/4
65-
// {
66-
// name: "include defaults for string with braces",
67-
// mode: MODES.JSON,
68-
// docs: ['{ "bracedStringDefault| }'],
69-
// expectedResults: [
70-
// {
71-
// label: "bracedStringDefault",
72-
// type: "property",
73-
// detail: "string",
74-
// info: "a string with a default value containing braces",
75-
// template: '"bracedStringDefault": "${✨ A message from %{whom}: ✨}"',
76-
// },
77-
// ],
78-
// },
68+
{
69+
name: "include defaults for string with braces",
70+
mode: MODES.JSON,
71+
docs: ['{ "bracedStringDefault| }'],
72+
expectedResults: [
73+
{
74+
label: "bracedStringDefault",
75+
type: "property",
76+
detail: "string",
77+
info: "a string with a default value containing braces",
78+
template: '"bracedStringDefault": "${✨ A message from %{whom\\}: ✨}"',
79+
},
80+
],
81+
},
7982
{
8083
name: "include defaults for enum when available",
8184
mode: MODES.JSON,
@@ -391,6 +394,21 @@ describe.each([
391394
],
392395
schema: testSchema4,
393396
},
397+
{
398+
name: "autocomplete for a schema with conditional properties",
399+
mode: MODES.JSON,
400+
docs: ['{ "type": "Test_1", "props": { t| }}'],
401+
expectedResults: [
402+
{
403+
type: "property",
404+
detail: "string",
405+
info: "",
406+
label: "test1Props",
407+
template: '"test1Props": "#{}"',
408+
},
409+
],
410+
schema: testSchemaConditionalProperties,
411+
},
394412
// JSON5
395413
{
396414
name: "return bare property key when no quotes are used",
@@ -551,6 +569,21 @@ describe.each([
551569
},
552570
],
553571
},
572+
{
573+
name: "autocomplete for a schema with conditional properties",
574+
mode: MODES.JSON5,
575+
docs: ["{ type: 'Test_1', props: { t| }}"],
576+
expectedResults: [
577+
{
578+
type: "property",
579+
detail: "string",
580+
info: "",
581+
label: "test1Props",
582+
template: "test1Props: '#{}'",
583+
},
584+
],
585+
schema: testSchemaConditionalProperties,
586+
},
554587
// YAML
555588
{
556589
name: "return completion data for simple types",
@@ -753,6 +786,21 @@ describe.each([
753786
},
754787
],
755788
},
789+
{
790+
name: "autocomplete for a schema with conditional properties",
791+
mode: MODES.YAML,
792+
docs: ["type: Test_1\nprops: { t| }"],
793+
expectedResults: [
794+
{
795+
type: "property",
796+
detail: "string",
797+
info: "",
798+
label: "test1Props",
799+
template: "test1Props: #{}",
800+
},
801+
],
802+
schema: testSchemaConditionalProperties,
803+
},
756804
])("jsonCompletion", ({ name, docs, mode, expectedResults, schema }) => {
757805
it.each(docs)(`${name} (mode: ${mode})`, async (doc) => {
758806
await expectCompletion(doc, expectedResults, { mode, schema });

src/features/__tests__/json-validation.spec.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,18 @@ describe("json-validation", () => {
6767
],
6868
},
6969
{
70-
name: "not handle invalid json",
70+
name: "can handle invalid json",
7171
mode: MODES.JSON,
7272
doc: '{"foo": "example" "bar": 123}',
73-
errors: [],
73+
// TODO: we don't have a best effort parser for YAML yet so this test will fail
74+
skipYaml: true,
75+
errors: [
76+
{
77+
from: 18,
78+
message: "Additional property `bar` is not allowed",
79+
to: 23,
80+
},
81+
],
7482
},
7583
{
7684
name: "provide range for invalid multiline json",
@@ -166,10 +174,16 @@ describe("json-validation", () => {
166174
],
167175
},
168176
{
169-
name: "not handle invalid json",
177+
name: "can handle invalid json",
170178
mode: MODES.JSON5,
171179
doc: "{foo: 'example' 'bar': 123}",
172-
errors: [],
180+
errors: [
181+
{
182+
from: 16,
183+
message: "Additional property `bar` is not allowed",
184+
to: 21,
185+
},
186+
],
173187
},
174188
{
175189
name: "provide range for invalid multiline json",
@@ -237,7 +251,9 @@ describe("json-validation", () => {
237251
schema: testSchema2,
238252
},
239253
// YAML
240-
...jsonSuite.map((t) => ({ ...t, mode: MODES.YAML })),
254+
...jsonSuite
255+
.map((t) => (!t.skipYaml ? { ...t, mode: MODES.YAML } : null))
256+
.filter((x): x is Exclude<typeof x, null> => !!x),
241257
{
242258
name: "provide range for a value error",
243259
mode: MODES.YAML,

src/features/completion.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { MODES, TOKENS } from "../constants";
3232
import { JSONMode } from "../types";
3333
import { renderMarkdown } from "../utils/markdown";
34+
import { DocumentParser, getDefaultParser } from "../parsers";
3435

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

5152
export interface JSONCompletionOptions {
5253
mode?: JSONMode;
54+
jsonParser?: DocumentParser;
5355
}
5456

5557
export class JSONCompletion {
5658
private schema: JSONSchema7 | null = null;
5759
private mode: JSONMode = MODES.JSON;
60+
private parser: DocumentParser;
61+
5862
constructor(private opts: JSONCompletionOptions) {
5963
this.mode = opts.mode ?? MODES.JSON;
64+
this.parser = this.opts?.jsonParser ?? getDefaultParser(this.mode);
6065
}
6166
public doComplete(ctx: CompletionContext) {
6267
const s = getJSONSchema(ctx.state)!;
@@ -810,7 +815,21 @@ export class JSONCompletion {
810815
): JSONSchema7Definition[] {
811816
const draft = new Draft07(this.schema!);
812817
let pointer = jsonPointerForPosition(ctx.state, ctx.pos, -1, this.mode);
813-
let subSchema = draft.getSchema({ pointer });
818+
// Pass parsed data to getSchema to get the correct schema based on the data context
819+
const { data } = this.parser(ctx.state);
820+
let subSchema = draft.getSchema({
821+
pointer,
822+
data: data ?? undefined,
823+
});
824+
debug.log(
825+
"xxxx",
826+
"draft.getSchema",
827+
subSchema,
828+
"data",
829+
data,
830+
"pointer",
831+
pointer
832+
);
814833
if (isJsonError(subSchema)) {
815834
subSchema = subSchema.data?.schema;
816835
}
@@ -819,7 +838,8 @@ export class JSONCompletion {
819838
!subSchema ||
820839
subSchema.name === "UnknownPropertyError" ||
821840
subSchema.enum ||
822-
subSchema.type === "undefined"
841+
subSchema.type === "undefined" ||
842+
subSchema.type === "null"
823843
) {
824844
pointer = pointer.replace(/\/[^/]*$/, "/");
825845
subSchema = draft.getSchema({ pointer });

src/features/validation.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,12 @@ import { Draft04, type Draft, type JsonError } from "json-schema-library";
55
import { getJSONSchema, schemaStateField } from "./state";
66
import { joinWithOr } from "../utils/formatting";
77
import { JSONMode, JSONPointerData, RequiredPick } from "../types";
8-
import { parseJSONDocumentState } from "../utils/parse-json-document";
98
import { el } from "../utils/dom";
109
import { renderMarkdown } from "../utils/markdown";
1110
import { MODES } from "../constants";
12-
import { parseYAMLDocumentState } from "../utils/parse-yaml-document";
13-
import { parseJSON5DocumentState } from "../utils/parse-json5-document";
1411
import { debug } from "../utils/debug";
12+
import { DocumentParser, getDefaultParser } from "../parsers";
1513

16-
const getDefaultParser = (mode: JSONMode): typeof parseJSONDocumentState => {
17-
switch (mode) {
18-
case MODES.JSON:
19-
return parseJSONDocumentState;
20-
case MODES.JSON5:
21-
return parseJSON5DocumentState;
22-
case MODES.YAML:
23-
return parseYAMLDocumentState;
24-
}
25-
};
2614
// return an object path that matches with the json-source-map pointer
2715
const getErrorPath = (error: JsonError): string => {
2816
// if a pointer is present, return without #
@@ -40,7 +28,7 @@ const getErrorPath = (error: JsonError): string => {
4028
export interface JSONValidationOptions {
4129
mode?: JSONMode;
4230
formatError?: (error: JsonError) => string;
43-
jsonParser?: typeof parseJSONDocumentState;
31+
jsonParser?: DocumentParser;
4432
}
4533

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

7765
private mode: JSONMode = MODES.JSON;
78-
private parser: typeof parseJSONDocumentState = parseJSONDocumentState;
66+
private parser: DocumentParser;
7967
public constructor(private options?: JSONValidationOptions) {
8068
this.mode = this.options?.mode ?? MODES.JSON;
8169
this.parser = this.options?.jsonParser ?? getDefaultParser(this.mode);

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type {
2727
JSONPartialPointerData,
2828
} from "./types";
2929

30-
export * from "./utils/parse-json-document";
30+
export * from "./parsers/json-parser";
3131
export * from "./utils/json-pointers";
3232

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

0 commit comments

Comments
 (0)