From 4fd7cc69084bdad3c8c93d9d0f0a936fa120cbae Mon Sep 17 00:00:00 2001 From: Samuel Date: Sun, 23 Jun 2024 18:59:49 +0200 Subject: [PATCH] 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`) --- .changeset/few-ducks-explain.md | 5 + package.json | 2 + pnpm-lock.yaml | 15 + .../__tests__/__fixtures__/schemas.ts | 49 + .../__tests__/json-completion.spec.ts | 80 +- .../__tests__/json-validation.spec.ts | 26 +- src/features/completion.ts | 24 +- src/features/validation.ts | 18 +- src/index.ts | 2 +- src/json-completion.ts | 953 ------------------ src/json5/index.ts | 2 +- src/json5/validation.ts | 2 +- .../__tests__/json-parser.spec.ts} | 5 +- .../__tests__/yaml-parser.spec.ts} | 2 +- src/parsers/index.ts | 22 + .../json-parser.ts} | 9 +- .../json5-parser.ts} | 9 +- .../yaml-parser.ts} | 2 +- src/utils/debug.ts | 18 +- src/yaml/index.ts | 2 +- src/yaml/validation.ts | 2 +- 21 files changed, 230 insertions(+), 1019 deletions(-) create mode 100644 .changeset/few-ducks-explain.md delete mode 100644 src/json-completion.ts rename src/{utils/__tests__/parse-json-document.spec.ts => parsers/__tests__/json-parser.spec.ts} (87%) rename src/{utils/__tests__/parse-yaml-document.spec.ts => parsers/__tests__/yaml-parser.spec.ts} (90%) create mode 100644 src/parsers/index.ts rename src/{utils/parse-json-document.ts => parsers/json-parser.ts} (81%) rename src/{utils/parse-json5-document.ts => parsers/json5-parser.ts} (84%) rename src/{utils/parse-yaml-document.ts => parsers/yaml-parser.ts} (92%) diff --git a/.changeset/few-ducks-explain.md b/.changeset/few-ducks-explain.md new file mode 100644 index 0000000..bf96a97 --- /dev/null +++ b/.changeset/few-ducks-explain.md @@ -0,0 +1,5 @@ +--- +"codemirror-json-schema": patch +--- + +Get sub schema using parsed data for additional context diff --git a/package.json b/package.json index 1b87b6a..c6b4ae6 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3657a67..157925e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ dependencies: '@types/node': specifier: ^20.4.2 version: 20.4.2 + best-effort-json-parser: + specifier: ^1.1.2 + version: 1.1.2 json-schema: specifier: ^0.4.0 version: 0.4.0 json-schema-library: specifier: ^9.3.5 version: 9.3.5 + loglevel: + specifier: ^1.9.1 + version: 1.9.1 markdown-it: specifier: ^14.0.0 version: 14.0.0 @@ -1134,6 +1140,10 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /best-effort-json-parser@1.1.2: + resolution: {integrity: sha512-RD7tyk24pNCDwEKFACauR6Lqp5m6BHUrehwyhN/pA8V3QYWq8Y+hk9vHZvKiThZsdEFTaUqN49duVsamgCd8/g==} + dev: false + /better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -2217,6 +2227,11 @@ packages: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} dev: true + /loglevel@1.9.1: + resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==} + engines: {node: '>= 0.6.0'} + dev: false + /loupe@2.3.6: resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==} deprecated: Please upgrade to 2.3.7 which fixes GHSA-4q6p-r6v2-jvc5 diff --git a/src/features/__tests__/__fixtures__/schemas.ts b/src/features/__tests__/__fixtures__/schemas.ts index 92ed071..e6ba10b 100644 --- a/src/features/__tests__/__fixtures__/schemas.ts +++ b/src/features/__tests__/__fixtures__/schemas.ts @@ -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; diff --git a/src/features/__tests__/json-completion.spec.ts b/src/features/__tests__/json-completion.spec.ts index a7b3ac8..6be53bd 100644 --- a/src/features/__tests__/json-completion.spec.ts +++ b/src/features/__tests__/json-completion.spec.ts @@ -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([ { @@ -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, @@ -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", @@ -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", @@ -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 }); diff --git a/src/features/__tests__/json-validation.spec.ts b/src/features/__tests__/json-validation.spec.ts index a4789f7..3fcc536 100644 --- a/src/features/__tests__/json-validation.spec.ts +++ b/src/features/__tests__/json-validation.spec.ts @@ -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", @@ -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", @@ -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 => !!x), { name: "provide range for a value error", mode: MODES.YAML, diff --git a/src/features/completion.ts b/src/features/completion.ts index 24ab2c9..9c5f2d1 100644 --- a/src/features/completion.ts +++ b/src/features/completion.ts @@ -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(); @@ -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)!; @@ -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; } @@ -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 }); diff --git a/src/features/validation.ts b/src/features/validation.ts index 689befd..8fadbb6 100644 --- a/src/features/validation.ts +++ b/src/features/validation.ts @@ -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 # @@ -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; @@ -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); diff --git a/src/index.ts b/src/index.ts index 4398132..8713564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/json-completion.ts b/src/json-completion.ts deleted file mode 100644 index c4b2f9d..0000000 --- a/src/json-completion.ts +++ /dev/null @@ -1,953 +0,0 @@ -import { - Completion, - CompletionContext, - CompletionResult, - snippetCompletion, -} from "@codemirror/autocomplete"; -import { syntaxTree } from "@codemirror/language"; -import { SyntaxNode } from "@lezer/common"; -import { JSONSchema7, JSONSchema7Definition } from "json-schema"; -import { debug } from "./utils/debug"; -import { - findNodeIndexInArrayNode, - getChildValueNode, - getWord, - isPropertyNameNode, - isPrimitiveValueNode, - stripSurroundingQuotes, - getNodeAtPosition, - getClosestNode, - getMatchingChildrenNodes, - getMatchingChildNode, - getChildrenNodes, - surroundingDoubleQuotesToSingle, -} from "./utils/node"; -import { getJSONSchema } from "./features/state"; -import { Draft07, isJsonError } from "json-schema-library"; -import { - jsonPointerForPosition, - resolveTokenName, -} from "./utils/json-pointers"; -import { MODES, TOKENS } from "./constants"; -import { JSONMode } from "./types"; - -class CompletionCollector { - completions = new Map(); - reservedKeys = new Set(); - - reserve(key: string) { - this.reservedKeys.add(key); - } - - add(completion: Completion) { - if (this.reservedKeys.has(completion.label)) { - return; - } - this.completions.set(completion.label, completion); - } -} - -export interface JSONCompletionOptions { - mode?: JSONMode; -} - -export class JSONCompletion { - private schema: JSONSchema7 | null = null; - private mode: JSONMode = MODES.JSON; - constructor(private opts: JSONCompletionOptions) { - this.mode = this.opts.mode ?? MODES.JSON; - } - public doComplete(ctx: CompletionContext) { - const s = getJSONSchema(ctx.state)!; - this.schema = this.expandSchemaProperty(s, s) ?? s; - if (!this.schema) { - // todo: should we even do anything without schema - // without taking over the existing mode responsibilties? - return []; - } - - const result: CompletionResult = { - from: ctx.pos, - to: ctx.pos, - options: [], - filter: false, // will be handled manually - }; - - const text = ctx.state.doc.sliceString(0); - let node: SyntaxNode | null = getNodeAtPosition(ctx.state, ctx.pos); - - // position node word prefix (without quotes) for matching - let prefix = ctx.state.sliceDoc(node.from, ctx.pos).replace(/^(["'])/, ""); - - debug.log("xxx", "node", node, "prefix", prefix, "ctx", ctx); - - // Only show completions if we are filling out a word or right after the starting quote, or if explicitly requested - if ( - !( - isPrimitiveValueNode(node, this.mode) || - isPropertyNameNode(node, this.mode) - ) && - !ctx.explicit - ) { - debug.log("xxx", "no completions for non-word/primitive", node); - return result; - } - - const currentWord = getWord(ctx.state.doc, node); - const rawWord = getWord(ctx.state.doc, node, false); - // Calculate overwrite range - if ( - node && - (isPrimitiveValueNode(node, this.mode) || - isPropertyNameNode(node, this.mode)) - ) { - result.from = node.from; - result.to = node.to; - } else { - const word = ctx.matchBefore(/[A-Za-z0-9._]*/); - const overwriteStart = ctx.pos - currentWord.length; - debug.log( - "xxx", - "overwriteStart after", - overwriteStart, - "ctx.pos", - ctx.pos, - "word", - word, - "currentWord", - currentWord, - "=>", - text[overwriteStart - 1], - "..", - text[overwriteStart], - "..", - text - ); - result.from = - node.name === TOKENS.INVALID ? word?.from ?? ctx.pos : overwriteStart; - result.to = ctx.pos; - } - - const collector = new CompletionCollector(); - - let addValue = true; - - const closestPropertyNameNode = getClosestNode( - node, - TOKENS.PROPERTY_NAME, - this.mode - ); - // if we are inside a property name node, we need to get the parent property name node - // The only reason we would be inside a property name node is if the current node is invalid or a literal/primitive node - if (closestPropertyNameNode) { - debug.log( - "xxx", - "closestPropertyNameNode", - closestPropertyNameNode, - "node", - node - ); - node = closestPropertyNameNode; - } - if (isPropertyNameNode(node, this.mode)) { - debug.log("xxx", "isPropertyNameNode", node); - const parent = node.parent; - if (parent) { - // get value node from parent - const valueNode = getChildValueNode(parent, this.mode); - addValue = - !valueNode || - (valueNode.name === TOKENS.INVALID && - valueNode.from - valueNode.to === 0) || - // TODO: Verify this doesn't break anything else - (valueNode.parent - ? getChildrenNodes(valueNode.parent).length <= 1 - : false); - debug.log( - "xxx", - "addValue", - addValue, - getChildValueNode(parent, this.mode), - node - ); - // find object node - node = getClosestNode(parent, TOKENS.OBJECT, this.mode) ?? null; - } - } - - debug.log( - "xxx", - node, - currentWord, - ctx, - "node at pos", - getNodeAtPosition(ctx.state, ctx.pos) - ); - - // proposals for properties - if ( - node && - [TOKENS.OBJECT, TOKENS.JSON_TEXT].includes( - resolveTokenName(node.name, this.mode) as any - ) && - (isPropertyNameNode(getNodeAtPosition(ctx.state, ctx.pos), this.mode) || - closestPropertyNameNode) - ) { - // don't suggest keys when the cursor is just before the opening curly brace - if (node.from === ctx.pos) { - debug.log("xxx", "no completions for just before opening brace"); - return result; - } - - // property proposals with schema - this.getPropertyCompletions( - this.schema, - ctx, - node, - collector, - addValue, - rawWord - ); - } else { - // proposals for values - const types: { [type: string]: boolean } = {}; - - // value proposals with schema - const res = this.getValueCompletions(this.schema, ctx, types, collector); - debug.log("xxx", "getValueCompletions res", res); - if (res) { - // TODO: While this works, we also need to handle the completion from and to positions to use it - // // use the value node to calculate the prefix - // prefix = res.valuePrefix; - // debug.log("xxx", "using valueNode prefix", prefix); - } - } - - // handle filtering - result.options = Array.from(collector.completions.values()).filter((v) => - stripSurroundingQuotes(v.label).startsWith(prefix) - ); - - debug.log( - "xxx", - "result", - result, - "prefix", - prefix, - "collector.completions", - collector.completions, - "reservedKeys", - collector.reservedKeys - ); - return result; - } - private applySnippetCompletion(completion: Completion) { - return snippetCompletion( - typeof completion.apply !== "string" - ? completion.label - : completion.apply, - completion - ); - } - - private getPropertyCompletions( - schema: JSONSchema7, - ctx: CompletionContext, - node: SyntaxNode, - collector: CompletionCollector, - addValue: boolean, - rawWord: string - ) { - // don't suggest properties that are already present - const properties = getMatchingChildrenNodes( - node, - TOKENS.PROPERTY, - this.mode - ); - debug.log("xxx", "getPropertyCompletions", node, ctx, properties); - properties.forEach((p) => { - const key = getWord( - ctx.state.doc, - getMatchingChildNode(p, TOKENS.PROPERTY_NAME, this.mode) - ); - collector.reserve(stripSurroundingQuotes(key)); - }); - - // TODO: Handle separatorAfter - - // Get matching schemas - const schemas = this.getSchemas(schema, ctx); - debug.log("xxx", "propertyCompletion schemas", schemas); - - schemas.forEach((s) => { - if (typeof s !== "object") { - return; - } - - const properties = s.properties; - if (properties) { - Object.entries(properties).forEach(([key, value]) => { - if (typeof value === "object") { - const description = value.description ?? ""; - const type = value.type ?? ""; - const typeStr = Array.isArray(type) ? type.toString() : type; - const completion: Completion = { - // label is the unquoted key which will be displayed. - label: key, - apply: this.getInsertTextForProperty( - key, - addValue, - rawWord, - value - ), - type: "property", - detail: typeStr, - info: description, - }; - collector.add(this.applySnippetCompletion(completion)); - } - }); - } - const propertyNames = s.propertyNames; - if (typeof propertyNames === "object") { - if (propertyNames.enum) { - propertyNames.enum.forEach((v) => { - const label = v?.toString(); - if (label) { - const completion: Completion = { - label, - apply: this.getInsertTextForProperty(label, addValue, rawWord), - type: "property", - }; - collector.add(this.applySnippetCompletion(completion)); - } - }); - } - - if (propertyNames.const) { - const label = propertyNames.const.toString(); - const completion: Completion = { - label, - apply: this.getInsertTextForProperty(label, addValue, rawWord), - type: "property", - }; - collector.add(this.applySnippetCompletion(completion)); - } - } - }); - } - - // apply is the quoted key which will be applied. - // Normally the label needs to match the token - // prefix i.e. if the token begins with `"to`, then the - // label needs to have the quotes as well for it to match. - // However we are manually filtering the results so we can - // just use the unquoted key as the label, which is nicer - // and gives us more control. - // If no property value is present, then we add the colon as well. - // Use snippetCompletion to handle insert value + position cursor e.g. "key": "#{}" - // doc: https://codemirror.net/docs/ref/#autocomplete.snippetCompletion - // idea: https://discuss.codemirror.net/t/autocomplete-cursor-position-in-apply-function/4088/3 - private getInsertTextForProperty( - key: string, - addValue: boolean, - rawWord: string, - propertySchema?: JSONSchema7Definition - ) { - // expand schema property if it is a reference - propertySchema = propertySchema - ? this.expandSchemaProperty(propertySchema, this.schema!) - : propertySchema; - - let resultText = this.getInsertTextForPropertyName(key, rawWord); - - if (!addValue) { - return resultText; - } - resultText += ": "; - - let value; - let nValueProposals = 0; - if (typeof propertySchema === "object") { - if (typeof propertySchema.default !== "undefined") { - if (!value) { - value = this.getInsertTextForGuessedValue(propertySchema.default, ""); - } - nValueProposals++; - } else { - if (propertySchema.enum) { - if (!value && propertySchema.enum.length === 1) { - value = this.getInsertTextForGuessedValue( - propertySchema.enum[0], - "" - ); - } - nValueProposals += propertySchema.enum.length; - } - if (typeof propertySchema.const !== "undefined") { - if (!value) { - value = this.getInsertTextForGuessedValue(propertySchema.const, ""); - } - nValueProposals++; - } - if ( - Array.isArray(propertySchema.examples) && - propertySchema.examples.length - ) { - if (!value) { - value = this.getInsertTextForGuessedValue( - propertySchema.examples[0], - "" - ); - } - nValueProposals += propertySchema.examples.length; - } - if (value === undefined && nValueProposals === 0) { - let type = Array.isArray(propertySchema.type) - ? propertySchema.type[0] - : propertySchema.type; - if (!type) { - if (propertySchema.properties) { - type = "object"; - } else if (propertySchema.items) { - type = "array"; - } - } - switch (type) { - case "boolean": - value = "#{}"; - break; - case "string": - value = this.getInsertTextForString(""); - break; - case "object": - switch (this.mode) { - case MODES.JSON5: - value = "{#{}}"; - break; - case MODES.YAML: - value = "#{}"; - break; - default: - value = "{#{}}"; - break; - } - break; - case "array": - value = "[#{}]"; - break; - case "number": - case "integer": - value = "#{0}"; - break; - case "null": - value = "#{null}"; - break; - default: - // always advance the cursor after completing a property - value = "#{}"; - break; - } - } - } - } - if (!value || nValueProposals > 1) { - debug.log( - "xxx", - "value", - value, - "nValueProposals", - nValueProposals, - propertySchema - ); - value = "#{}"; - } - - return resultText + value; - } - private getInsertTextForPropertyName(key: string, rawWord: string) { - switch (this.mode) { - case MODES.JSON5: - case MODES.YAML: { - if (rawWord.startsWith('"')) { - return `"${key}"`; - } - if (rawWord.startsWith("'")) { - return `'${key}'`; - } - return key; - } - default: - return `"${key}"`; - } - } - private getInsertTextForString(value: string, prf = "#") { - switch (this.mode) { - case MODES.JSON5: - return `'${prf}{${value}}'`; - case MODES.YAML: - return `${prf}{${value}}`; - default: - return `"${prf}{${value}}"`; - } - } - - // TODO: Is this actually working? - private getInsertTextForGuessedValue( - value: any, - separatorAfter = "" - ): string { - switch (typeof value) { - case "object": - if (value === null) { - return "${null}" + separatorAfter; - } - return this.getInsertTextForValue(value, separatorAfter); - case "string": { - let snippetValue = JSON.stringify(value); - snippetValue = snippetValue.substr(1, snippetValue.length - 2); // remove quotes - snippetValue = this.getInsertTextForPlainText(snippetValue); // escape \ and } - return this.getInsertTextForString(snippetValue, "$") + separatorAfter; - } - case "number": - case "boolean": - return "${" + JSON.stringify(value) + "}" + separatorAfter; - } - return this.getInsertTextForValue(value, separatorAfter); - } - private getInsertTextForPlainText(text: string): string { - return text.replace(/[\\$}]/g, "\\$&"); // escape $, \ and } - } - - private getInsertTextForValue(value: any, separatorAfter: string): string { - const text = JSON.stringify(value, null, "\t"); - if (text === "{}") { - return "{#{}}" + separatorAfter; - } else if (text === "[]") { - return "[#{}]" + separatorAfter; - } - return this.getInsertTextForPlainText(text + separatorAfter); - } - - private getValueCompletions( - schema: JSONSchema7, - ctx: CompletionContext, - types: { [type: string]: boolean }, - collector: CompletionCollector - ) { - let node: SyntaxNode | null = syntaxTree(ctx.state).resolveInner( - ctx.pos, - -1 - ); - let valueNode: SyntaxNode | null = null; - let parentKey: string | undefined = undefined; - - debug.log("xxx", "getValueCompletions", node, ctx); - - if (node && isPrimitiveValueNode(node, this.mode)) { - valueNode = node; - node = node.parent; - } - - if (!node) { - this.addSchemaValueCompletions(schema, types, collector); - return; - } - - if (resolveTokenName(node.name, this.mode) === TOKENS.PROPERTY) { - const keyNode = getMatchingChildNode( - node, - TOKENS.PROPERTY_NAME, - this.mode - ); - if (keyNode) { - parentKey = getWord(ctx.state.doc, keyNode); - node = node.parent; - } - } - - debug.log("xxx", "node", node, "parentKey", parentKey); - if ( - node && - (parentKey !== undefined || - resolveTokenName(node.name, this.mode) === TOKENS.ARRAY) - ) { - // Get matching schemas - const schemas = this.getSchemas(schema, ctx); - for (const s of schemas) { - if (typeof s !== "object") { - return; - } - - if ( - resolveTokenName(node.name, this.mode) === TOKENS.ARRAY && - s.items - ) { - let c = collector; - if (s.uniqueItems) { - c = { - ...c, - add(completion) { - if (!c.completions.has(completion.label)) { - collector.add(completion); - } - }, - reserve(key) { - collector.reserve(key); - }, - }; - } - if (Array.isArray(s.items)) { - let arrayIndex = 0; - if (valueNode) { - // get index of next node in array - const foundIdx = findNodeIndexInArrayNode( - node, - valueNode, - this.mode - ); - - if (foundIdx >= 0) { - arrayIndex = foundIdx; - } - } - const itemSchema = s.items[arrayIndex]; - if (itemSchema) { - this.addSchemaValueCompletions(itemSchema, types, c); - } - } else { - this.addSchemaValueCompletions(s.items, types, c); - } - } - - if (parentKey !== undefined) { - let propertyMatched = false; - if (s.properties) { - const propertySchema = s.properties[parentKey]; - if (propertySchema) { - propertyMatched = true; - this.addSchemaValueCompletions(propertySchema, types, collector); - } - } - if (s.patternProperties && !propertyMatched) { - for (const pattern of Object.keys(s.patternProperties)) { - const regex = this.extendedRegExp(pattern); - if (regex?.test(parentKey)) { - propertyMatched = true; - const propertySchema = s.patternProperties[pattern]; - if (propertySchema) { - this.addSchemaValueCompletions( - propertySchema, - types, - collector - ); - } - } - } - } - if (s.additionalProperties && !propertyMatched) { - const propertySchema = s.additionalProperties; - this.addSchemaValueCompletions(propertySchema, types, collector); - } - } - if (types["boolean"]) { - this.addBooleanValueCompletion(true, collector); - this.addBooleanValueCompletion(false, collector); - } - if (types["null"]) { - this.addNullValueCompletion(collector); - } - } - } - - // TODO: We need to pass the from and to for the value node as well - // TODO: What should be the from and to when the value node is null? - // TODO: (NOTE: if we pass a prefix but no from and to, it will autocomplete the value but replace - // TODO: the entire property nodewhich isn't what we want). Instead we need to change the from and to - // TODO: based on the corresponding (relevant) value node - const valuePrefix = valueNode - ? getWord(ctx.state.doc, valueNode, true, false) - : ""; - - return { - valuePrefix, - }; - } - - private addSchemaValueCompletions( - schema: JSONSchema7Definition, - types: { [type: string]: boolean }, - collector: CompletionCollector - ) { - if (typeof schema === "object") { - this.addEnumValueCompletions(schema, collector); - this.addDefaultValueCompletions(schema, collector); - this.collectTypes(schema, types); - if (Array.isArray(schema.allOf)) { - schema.allOf.forEach((s) => - this.addSchemaValueCompletions(s, types, collector) - ); - } - if (Array.isArray(schema.anyOf)) { - schema.anyOf.forEach((s) => - this.addSchemaValueCompletions(s, types, collector) - ); - } - if (Array.isArray(schema.oneOf)) { - schema.oneOf.forEach((s) => - this.addSchemaValueCompletions(s, types, collector) - ); - } - } - } - private addDefaultValueCompletions( - schema: JSONSchema7, - collector: CompletionCollector, - arrayDepth = 0 - ): void { - let hasProposals = false; - if (typeof schema.default !== "undefined") { - let type = schema.type; - let value = schema.default; - for (let i = arrayDepth; i > 0; i--) { - value = [value]; - type = "array"; - } - const completionItem: Completion = { - type: type?.toString(), - ...this.getAppliedValue(value), - detail: "Default value", - }; - collector.add(completionItem); - hasProposals = true; - } - if (Array.isArray(schema.examples)) { - schema.examples.forEach((example) => { - let type = schema.type; - let value = example; - for (let i = arrayDepth; i > 0; i--) { - value = [value]; - type = "array"; - } - collector.add({ - type: type?.toString(), - ...this.getAppliedValue(value), - }); - hasProposals = true; - }); - } - if ( - !hasProposals && - typeof schema.items === "object" && - !Array.isArray(schema.items) && - arrayDepth < 5 /* beware of recursion */ - ) { - this.addDefaultValueCompletions(schema.items, collector, arrayDepth + 1); - } - } - - private addEnumValueCompletions( - schema: JSONSchema7, - collector: CompletionCollector - ): void { - if (typeof schema.const !== "undefined") { - collector.add({ - type: schema.type?.toString(), - ...this.getAppliedValue(schema.const), - - info: schema.description, - }); - } - - if (Array.isArray(schema.enum)) { - for (let i = 0, length = schema.enum.length; i < length; i++) { - const enm = schema.enum[i]; - collector.add({ - type: schema.type?.toString(), - ...this.getAppliedValue(enm), - info: schema.description, - }); - } - } - } - - private addBooleanValueCompletion( - value: boolean, - collector: CompletionCollector - ): void { - collector.add({ - type: "boolean", - label: value ? "true" : "false", - }); - } - - private addNullValueCompletion(collector: CompletionCollector): void { - collector.add({ - type: "null", - label: "null", - }); - } - - private collectTypes( - schema: JSONSchema7, - types: { [type: string]: boolean } - ) { - if (Array.isArray(schema.enum) || typeof schema.const !== "undefined") { - return; - } - const type = schema.type; - if (Array.isArray(type)) { - type.forEach((t) => (types[t] = true)); - } else if (type) { - types[type] = true; - } - } - - private getSchemas( - schema: JSONSchema7, - ctx: CompletionContext - ): JSONSchema7Definition[] { - const draft = new Draft07(this.schema!); - let pointer = jsonPointerForPosition(ctx.state, ctx.pos, -1, this.mode); - let subSchema = draft.getSchema({ pointer }); - if (isJsonError(subSchema)) { - subSchema = subSchema.data?.schema; - } - // if we don't have a schema for the current pointer, try the parent pointer - if ( - !subSchema || - subSchema.name === "UnknownPropertyError" || - subSchema.enum || - subSchema.type === "undefined" - ) { - pointer = pointer.replace(/\/[^/]*$/, "/"); - subSchema = draft.getSchema({ pointer }); - } - - debug.log("xxx", "pointer..", JSON.stringify(pointer)); - - // For some reason, it returns undefined schema for the root pointer - // We use the root schema in that case as the relevant (sub)schema - if (!pointer || pointer === "/") { - subSchema = this.expandSchemaProperty(schema, schema) ?? schema; - } - // const subSchema = new Draft07(this.schema).getSchema(pointer); - debug.log("xxx", "subSchema..", subSchema); - if (!subSchema) { - return []; - } - - if (Array.isArray(subSchema.allOf)) { - return [ - subSchema, - ...subSchema.allOf.map((s) => this.expandSchemaProperty(s, schema)), - ]; - } - if (Array.isArray(subSchema.oneOf)) { - return [ - subSchema, - ...subSchema.oneOf.map((s) => this.expandSchemaProperty(s, schema)), - ]; - } - if (Array.isArray(subSchema.anyOf)) { - return [ - subSchema, - ...subSchema.anyOf.map((s) => this.expandSchemaProperty(s, schema)), - ]; - } - - return [subSchema as JSONSchema7]; - } - - private expandSchemaProperty( - property: T, - schema: JSONSchema7 - ) { - if (typeof property === "object" && property.$ref) { - const refSchema = this.getReferenceSchema(schema, property.$ref); - if (typeof refSchema === "object") { - const dereferenced = { - ...property, - ...refSchema, - }; - Reflect.deleteProperty(dereferenced, "$ref"); - - return dereferenced; - } - } - return property; - } - - private getReferenceSchema(schema: JSONSchema7, ref: string) { - const refPath = ref.split("/"); - let curReference: Record | undefined = schema; - refPath.forEach((cur) => { - if (!cur) { - return; - } - if (cur === "#") { - curReference = schema; - return; - } - if (typeof curReference === "object") { - curReference = curReference[cur]; - } - }); - - return curReference; - } - - private getAppliedValue(value: any): { label: string; apply: string } { - const stripped = stripSurroundingQuotes(JSON.stringify(value)); - switch (this.mode) { - case MODES.JSON5: - return { - label: stripped, - apply: surroundingDoubleQuotesToSingle(JSON.stringify(value)), - }; - case MODES.YAML: - return { - label: stripped, - apply: stripped, - }; - default: - return { - label: stripped, - apply: JSON.stringify(value), - }; - } - } - - // private getValueFromLabel(value: any): string { - // return JSON.parse(value); - // } - - private extendedRegExp(pattern: string): RegExp | undefined { - let flags = ""; - if (pattern.startsWith("(?i)")) { - pattern = pattern.substring(4); - flags = "i"; - } - try { - return new RegExp(pattern, flags + "u"); - } catch (e) { - // could be an exception due to the 'u ' flag - try { - return new RegExp(pattern, flags); - } catch (e) { - // invalid pattern - return undefined; - } - } - } -} -/** - * provides a JSON schema enabled autocomplete extension for codemirror - * @group Codemirror Extensions - */ -export function jsonCompletion(opts: JSONCompletionOptions = {}) { - const completion = new JSONCompletion(opts); - return function jsonDoCompletion(ctx: CompletionContext) { - return completion.doComplete(ctx); - }; -} diff --git a/src/json5/index.ts b/src/json5/index.ts index 39d3881..3d79c57 100644 --- a/src/json5/index.ts +++ b/src/json5/index.ts @@ -8,4 +8,4 @@ export { json5Completion } from "./completion"; */ export { json5Schema } from "./bundled"; -export * from "../utils/parse-json5-document"; +export * from "../parsers/json5-parser"; diff --git a/src/json5/validation.ts b/src/json5/validation.ts index 5250675..8d3774b 100644 --- a/src/json5/validation.ts +++ b/src/json5/validation.ts @@ -3,8 +3,8 @@ import { JSONValidation, type JSONValidationOptions, } from "../features/validation"; -import { parseJSON5DocumentState } from "../utils/parse-json5-document"; import { MODES } from "../constants"; +import { parseJSON5DocumentState } from "../parsers/json5-parser"; /** * Instantiates a JSONValidation instance with the JSON5 mode diff --git a/src/utils/__tests__/parse-json-document.spec.ts b/src/parsers/__tests__/json-parser.spec.ts similarity index 87% rename from src/utils/__tests__/parse-json-document.spec.ts rename to src/parsers/__tests__/json-parser.spec.ts index b4467a2..2a94f11 100644 --- a/src/utils/__tests__/parse-json-document.spec.ts +++ b/src/parsers/__tests__/json-parser.spec.ts @@ -1,7 +1,6 @@ import { it, describe, expect } from "vitest"; - -import { parseJSONDocument } from "../parse-json-document"; -import { parseJSON5Document } from "../parse-json5-document"; +import { parseJSONDocument } from "../json-parser"; +import { parseJSON5Document } from "../json5-parser"; describe("parseJSONDocument", () => { it("should return a map of all pointers for a json4 document", () => { diff --git a/src/utils/__tests__/parse-yaml-document.spec.ts b/src/parsers/__tests__/yaml-parser.spec.ts similarity index 90% rename from src/utils/__tests__/parse-yaml-document.spec.ts rename to src/parsers/__tests__/yaml-parser.spec.ts index 8114508..49935ad 100644 --- a/src/utils/__tests__/parse-yaml-document.spec.ts +++ b/src/parsers/__tests__/yaml-parser.spec.ts @@ -1,8 +1,8 @@ import { it, describe, expect } from "vitest"; import { yaml } from "@codemirror/lang-yaml"; -import { parseYAMLDocumentState } from "../parse-yaml-document"; import { EditorState } from "@codemirror/state"; +import { parseYAMLDocumentState } from "../yaml-parser"; const testDoc = `--- object: diff --git a/src/parsers/index.ts b/src/parsers/index.ts new file mode 100644 index 0000000..98eaf41 --- /dev/null +++ b/src/parsers/index.ts @@ -0,0 +1,22 @@ +import { JSONMode, JSONPointersMap } from "../types"; +import { MODES } from "../constants"; +import { EditorState } from "@codemirror/state"; +import { parseJSONDocumentState } from "./json-parser"; +import { parseJSON5DocumentState } from "./json5-parser"; +import { parseYAMLDocumentState } from "./yaml-parser"; + +export const getDefaultParser = (mode: JSONMode): DocumentParser => { + switch (mode) { + case MODES.JSON: + return parseJSONDocumentState; + case MODES.JSON5: + return parseJSON5DocumentState; + case MODES.YAML: + return parseYAMLDocumentState; + } +}; + +export type DocumentParser = (state: EditorState) => { + data: unknown; + pointers: JSONPointersMap; +}; diff --git a/src/utils/parse-json-document.ts b/src/parsers/json-parser.ts similarity index 81% rename from src/utils/parse-json-document.ts rename to src/parsers/json-parser.ts index 966676d..c3f0d59 100644 --- a/src/utils/parse-json-document.ts +++ b/src/parsers/json-parser.ts @@ -1,7 +1,8 @@ import { json } from "@codemirror/lang-json"; import { EditorState } from "@codemirror/state"; -import { getJsonPointers } from "./json-pointers"; +import { parse } from "best-effort-json-parser"; import { MODES } from "../constants"; +import { getJsonPointers } from "../utils/json-pointers"; /** * Return parsed data and json pointers for a given codemirror EditorState @@ -12,7 +13,11 @@ export function parseJSONDocumentState(state: EditorState) { try { data = JSON.parse(state.doc.toString()); // return pointers regardless of whether JSON.parse succeeds - } catch {} + } catch { + try { + data = parse(state.doc.toString()); + } catch {} + } const pointers = getJsonPointers(state, MODES.JSON); return { data, pointers }; } diff --git a/src/utils/parse-json5-document.ts b/src/parsers/json5-parser.ts similarity index 84% rename from src/utils/parse-json5-document.ts rename to src/parsers/json5-parser.ts index 787cd58..e439f99 100644 --- a/src/utils/parse-json5-document.ts +++ b/src/parsers/json5-parser.ts @@ -5,7 +5,8 @@ import { json5 as json5mode } from "codemirror-json5"; import json5 from "json5"; import { EditorState } from "@codemirror/state"; -import { getJsonPointers } from "./json-pointers"; +import { parse } from "best-effort-json-parser"; +import { getJsonPointers } from "../utils/json-pointers"; import { MODES } from "../constants"; /** @@ -17,7 +18,11 @@ export function parseJSON5DocumentState(state: EditorState) { try { data = json5.parse(state.doc.toString()); // return pointers regardless of whether JSON.parse succeeds - } catch {} + } catch { + try { + data = parse(state.doc.toString()); + } catch {} + } const pointers = getJsonPointers(state, MODES.JSON5); return { data, pointers }; } diff --git a/src/utils/parse-yaml-document.ts b/src/parsers/yaml-parser.ts similarity index 92% rename from src/utils/parse-yaml-document.ts rename to src/parsers/yaml-parser.ts index 12f7d99..9a06cef 100644 --- a/src/utils/parse-yaml-document.ts +++ b/src/parsers/yaml-parser.ts @@ -5,7 +5,7 @@ import { yaml } from "@codemirror/lang-yaml"; import YAML from "yaml"; import { EditorState } from "@codemirror/state"; -import { getJsonPointers } from "./json-pointers"; +import { getJsonPointers } from "../utils/json-pointers"; import { MODES } from "../constants"; /** diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 4e00c25..afa6a0d 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,14 +1,4 @@ -export const debug = { - log: (...args: any[]) => { - if (process.env.NODE_ENV !== "development") { - return; - } - console.log(...args); - }, - warn: (...args: any[]) => { - if (process.env.NODE_ENV !== "development") { - return; - } - console.warn(...args); - }, -}; +import log from "loglevel"; + +log.setLevel(process.env.NODE_ENV !== "development" ? "silent" : "debug"); +export const debug = log; diff --git a/src/yaml/index.ts b/src/yaml/index.ts index 2906448..23d0fa8 100644 --- a/src/yaml/index.ts +++ b/src/yaml/index.ts @@ -8,4 +8,4 @@ export { yamlCompletion } from "./completion"; */ export { yamlSchema } from "./bundled"; -export * from "../utils/parse-yaml-document"; +export * from "../parsers/yaml-parser"; diff --git a/src/yaml/validation.ts b/src/yaml/validation.ts index 1d44686..80213be 100644 --- a/src/yaml/validation.ts +++ b/src/yaml/validation.ts @@ -3,8 +3,8 @@ import { JSONValidation, type JSONValidationOptions, } from "../features/validation"; -import { parseYAMLDocumentState } from "../utils/parse-yaml-document"; import { MODES } from "../constants"; +import { parseYAMLDocumentState } from "../parsers/yaml-parser"; /** * Instantiates a JSONValidation instance with the YAML mode