diff --git a/.vscode/settings.json b/.vscode/settings.json index 16876d3..68d96c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - "files.insertFinalNewline": true + "files.insertFinalNewline": true, + "coverage-gutters.coverageBaseDir": "**", + "coverage-gutters.coverageFileNames": ["clover.xml"] } diff --git a/README.md b/README.md index 5bafce2..82be8d9 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,10 @@ This approach allows you to configure the json5 mode and parse linter, as well a import { EditorState } from "@codemirror/state"; import { linter } from "@codemirror/lint"; import { json5, json5ParseLinter, json5Language } from "codemirror-json5"; -import { jsonCompletion } from "codemirror-json-schema"; import { json5SchemaLinter, json5SchemaHover, + json5Completion, } from "codemirror-json-schema/json5"; const schema = { @@ -182,7 +182,7 @@ const json5State = EditorState.create({ linter(json5SchemaLinter(schema)), hoverTooltip(json5SchemaHover(schema)), json5Language.data.of({ - autocomplete: jsonCompletion(schema), + autocomplete: json5Completion(schema), }), ], }); diff --git a/src/__tests__/__helpers__/completion.ts b/src/__tests__/__helpers__/completion.ts index ffe6a76..bbcae18 100644 --- a/src/__tests__/__helpers__/completion.ts +++ b/src/__tests__/__helpers__/completion.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vitest, Mock } from "vitest"; import { json, jsonLanguage } from "@codemirror/lang-json"; +import { json5, json5Language } from "codemirror-json5"; + import { EditorState } from "@codemirror/state"; import { Completion, @@ -38,19 +40,26 @@ type MockedCompletionResult = CompletionResult["options"][0] & { export async function expectCompletion( doc: string, results: MockedCompletionResult[], - schema?: JSONSchema7, - conf: { explicit?: boolean } = {} + + conf: { + explicit?: boolean; + schema?: JSONSchema7; + mode?: "json" | "json5"; + } = {} ) { let cur = doc.indexOf("|"), - currentSchema = schema || testSchema2; + currentSchema = conf?.schema || testSchema2; doc = doc.slice(0, cur) + doc.slice(cur + 1); + const jsonMode = conf?.mode === "json5" ? json5 : json; + const jsonLang = conf?.mode === "json5" ? json5Language : jsonLanguage; + let state = EditorState.create({ doc, selection: { anchor: cur }, extensions: [ - json(), - jsonLanguage.data.of({ - autocomplete: jsonCompletion(currentSchema), + jsonMode(), + jsonLang.data.of({ + autocomplete: jsonCompletion(currentSchema, { mode: conf.mode }), }), ], }); diff --git a/src/__tests__/json-completion.spec.ts b/src/__tests__/json-completion.spec.ts index 1f1ecdd..2695efb 100644 --- a/src/__tests__/json-completion.spec.ts +++ b/src/__tests__/json-completion.spec.ts @@ -46,9 +46,7 @@ describe("jsonCompletion", () => { type: "property", detail: "", info: "an example enum with default bar", - // TODO: should this not autocomplete to default "bar"? - // template: '"enum1": "${bar}"', - template: '"enum1": #{}', + template: '"enum1": "${bar}"', }, { label: "enum2", @@ -109,6 +107,18 @@ describe("jsonCompletion", () => { }, ]); }); + // TODO: accidentally steps up to the parent pointer + it.skip("should include insert text for nested object properties", async () => { + await expectCompletion(`{ "object": { '| } }`, [ + { + detail: "string", + info: "an elegant string", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + ]); + }); it("should include insert text for nested object properties with filter", async () => { await expectCompletion('{ "object": { "f|" } }', [ { @@ -162,5 +172,172 @@ describe("jsonCompletion", () => { type: "property", }, ]); + it("should autocomplete for oneOf without quotes", async () => { + await expectCompletion('{ "oneOfObject": { | } }', [ + { + detail: "string", + info: "", + label: "foo", + template: '"foo": "#{}"', + type: "property", + }, + { + detail: "number", + info: "", + label: "bar", + template: '"bar": #{0}', + type: "property", + }, + { + detail: "string", + info: "", + label: "apple", + template: '"apple": "#{}"', + type: "property", + }, + { + detail: "number", + info: "", + label: "banana", + template: '"banana": #{0}', + type: "property", + }, + ]); + }); + }); +}); + +describe("json5Completion", () => { + it("should return bare property key when no quotes are used", async () => { + await expectCompletion( + "{ f| }", + [ + { + label: "foo", + type: "property", + detail: "string", + info: "", + template: "foo: '#{}'", + }, + ], + { mode: "json5" } + ); + }); + it("should return template for '", async () => { + await expectCompletion( + "{ 'one|' }", + [ + { + label: "oneOfEg", + type: "property", + detail: "", + info: "an example oneOf", + template: "'oneOfEg': ", + }, + { + label: "oneOfEg2", + type: "property", + detail: "", + info: "", + template: "'oneOfEg2': ", + }, + { + detail: "", + info: "", + label: "oneOfObject", + template: "'oneOfObject': ", + type: "property", + }, + ], + { mode: "json5" } + ); + }); + it("should include defaults for enum when available", async () => { + await expectCompletion( + '{ "en|" }', + [ + { + label: "enum1", + type: "property", + detail: "", + info: "an example enum with default bar", + template: '"enum1": "${bar}"', + }, + { + label: "enum2", + type: "property", + detail: "", + info: "an example enum without default", + template: '"enum2": #{}', + }, + ], + { mode: "json5" } + ); + }); + it("should include defaults for boolean when available", async () => { + await expectCompletion( + "{ booleanW| }", + [ + { + type: "property", + detail: "boolean", + info: "an example boolean with default", + label: "booleanWithDefault", + template: "booleanWithDefault: ${true}", + }, + ], + { mode: "json5" } + ); + }); + it("should include insert text for nested object properties", async () => { + await expectCompletion( + "{ object: { f| }", + [ + { + type: "property", + detail: "string", + info: "an elegant string", + label: "foo", + template: "foo: '#{}'", + }, + ], + { mode: "json5" } + ); + }); + it("should include insert text for nested oneOf object properties with a single quote", async () => { + await expectCompletion( + "{ oneOfObject: { '|' }", + [ + { + type: "property", + detail: "string", + info: "", + label: "foo", + template: "'foo': '#{}'", + }, + { + type: "property", + detail: "number", + info: "", + label: "bar", + template: "'bar': #{0}", + }, + { + type: "property", + detail: "string", + info: "", + label: "apple", + template: "'apple': '#{}'", + }, + { + type: "property", + detail: "number", + info: "", + label: "banana", + template: "'banana': #{0}", + }, + ], + { mode: "json5" } + ); }); }); diff --git a/src/json-completion.ts b/src/json-completion.ts index 7b069d6..5f7ec32 100644 --- a/src/json-completion.ts +++ b/src/json-completion.ts @@ -14,7 +14,7 @@ import { getWord, isPropertyNameNode, isPrimitiveValueNode, - stripSurrondingQuotes, + stripSurroundingQuotes, getNodeAtPosition, } from "./utils/node"; import { Draft07, JsonError } from "json-schema-library"; @@ -22,6 +22,16 @@ import { jsonPointerForPosition } from "./utils/jsonPointers"; import { TOKENS } from "./constants"; import getSchema from "./utils/schema-lib/getSchema"; +function json5PropertyInsertSnippet(rawWord: string, value: string) { + if (rawWord.startsWith('"')) { + return `"${value}"`; + } + if (rawWord.startsWith("'")) { + return `'${value}'`; + } + return value; +} + class CompletionCollector { completions = new Map(); reservedKeys = new Set(); @@ -38,8 +48,15 @@ class CompletionCollector { } } +type JSONCompletionOptions = { + mode?: "json" | "json5"; +}; + export class JSONCompletion { - public constructor(private schema: JSONSchema7) {} + public constructor( + private schema: JSONSchema7, + private opts: JSONCompletionOptions + ) {} public doComplete(ctx: CompletionContext) { const result: CompletionResult = { @@ -66,6 +83,7 @@ export class JSONCompletion { } const currentWord = getWord(ctx.state.doc, node); + const rawWord = getWord(ctx.state.doc, node, false); // Calculate overwrite range if (node && (isPrimitiveValueNode(node) || isPropertyNameNode(node))) { result.from = node.from; @@ -134,7 +152,14 @@ export class JSONCompletion { } // property proposals with schema - this.getPropertyCompletions(this.schema, ctx, node, collector, addValue); + this.getPropertyCompletions( + this.schema, + ctx, + node, + collector, + addValue, + rawWord + ); } else { // proposals for values const types: { [type: string]: boolean } = {}; @@ -145,7 +170,7 @@ export class JSONCompletion { // handle filtering result.options = Array.from(collector.completions.values()).filter((v) => - stripSurrondingQuotes(v.label).startsWith(prefix) + stripSurroundingQuotes(v.label).startsWith(prefix) ); debug.log( "xxx", @@ -174,14 +199,15 @@ export class JSONCompletion { ctx: CompletionContext, node: SyntaxNode, collector: CompletionCollector, - addValue: boolean + addValue: boolean, + rawWord: string ) { // don't suggest properties that are already present const properties = node.getChildren(TOKENS.PROPERTY); debug.log("xxx", "getPropertyCompletions", node, ctx, properties); properties.forEach((p) => { const key = getWord(ctx.state.doc, p.getChild(TOKENS.PROPERTY_NAME)); - collector.reserve(stripSurrondingQuotes(key)); + collector.reserve(stripSurroundingQuotes(key)); }); // TODO: Handle separatorAfter @@ -204,7 +230,12 @@ export class JSONCompletion { const completion: Completion = { // label is the unquoted key which will be displayed. label: key, - apply: this.getInsertTextForProperty(key, addValue, value), + apply: this.getInsertTextForProperty( + key, + addValue, + rawWord, + value + ), type: "property", detail: typeStr, info: description, @@ -221,7 +252,7 @@ export class JSONCompletion { if (label) { const completion: Completion = { label, - apply: this.getInsertTextForProperty(label, addValue), + apply: this.getInsertTextForProperty(label, addValue, rawWord), type: "property", }; collector.add(this.applySnippetCompletion(completion)); @@ -233,7 +264,7 @@ export class JSONCompletion { const label = propertyNames.const.toString(); const completion: Completion = { label, - apply: this.getInsertTextForProperty(label, addValue), + apply: this.getInsertTextForProperty(label, addValue, rawWord), type: "property", }; collector.add(this.applySnippetCompletion(completion)); @@ -256,6 +287,7 @@ export class JSONCompletion { private getInsertTextForProperty( key: string, addValue: boolean, + rawWord: string, propertySchema?: JSONSchema7Definition ) { // expand schema property if it is a reference @@ -263,7 +295,10 @@ export class JSONCompletion { ? this.expandSchemaProperty(propertySchema, this.schema) : propertySchema; - let resultText = `"${key}"`; + const isJSON5 = this.opts?.mode === "json5"; + let resultText = isJSON5 + ? json5PropertyInsertSnippet(rawWord, key) + : `"${key}"`; if (!addValue) { return resultText; } @@ -272,69 +307,73 @@ export class JSONCompletion { let value; let nValueProposals = 0; if (typeof propertySchema === "object") { - 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 (typeof propertySchema.default !== "undefined") { if (!value) { value = this.getInsertTextForGuessedValue(propertySchema.default, ""); } nValueProposals++; - } - if ( - Array.isArray(propertySchema.examples) && - propertySchema.examples.length - ) { - if (!value) { - value = this.getInsertTextForGuessedValue( - propertySchema.examples[0], - "" - ); + } else { + if (propertySchema.enum) { + if (!value && propertySchema.enum.length === 1) { + value = this.getInsertTextForGuessedValue( + propertySchema.enum[0], + "" + ); + } + nValueProposals += propertySchema.enum.length; } - nValueProposals += propertySchema.examples.length; - } - if (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"; + 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; } - switch (type) { - case "boolean": - value = "#{}"; - break; - case "string": - value = '"#{}"'; - break; - case "object": - value = "{#{}}"; - break; - case "array": - value = "[#{}]"; - break; - case "number": - case "integer": - value = "#{0}"; - break; - case "null": - value = "#{null}"; - break; - default: - return resultText; + 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 = isJSON5 ? "'#{}'" : '"#{}"'; + break; + case "object": + value = "{#{}}"; + break; + case "array": + value = "[#{}]"; + break; + case "number": + case "integer": + value = "#{0}"; + break; + case "null": + value = "#{null}"; + break; + default: + return resultText; + } } } } @@ -759,8 +798,25 @@ export class JSONCompletion { * provides a JSON schema enabled autocomplete extension for codemirror * @group Codemirror Extensions */ -export function jsonCompletion(schema: JSONSchema7) { - const completion = new JSONCompletion(schema); +export function jsonCompletion( + schema: JSONSchema7, + opts: JSONCompletionOptions = {} +) { + const completion = new JSONCompletion(schema, opts); + return function jsonDoCompletion(ctx: CompletionContext) { + return completion.doComplete(ctx); + }; +} + +/** + * provides a JSON schema enabled autocomplete extension for codemirror and json5 + * @group Codemirror Extensions + */ +export function json5Completion( + schema: JSONSchema7, + opts: Omit = {} +) { + const completion = new JSONCompletion(schema, { ...opts, mode: "json5" }); return function jsonDoCompletion(ctx: CompletionContext) { return completion.doComplete(ctx); }; diff --git a/src/json5-bundled.ts b/src/json5-bundled.ts index 8fe4e89..e9d4c4b 100644 --- a/src/json5-bundled.ts +++ b/src/json5-bundled.ts @@ -1,7 +1,7 @@ import { JSONSchema7 } from "json-schema"; import { json5, json5Language, json5ParseLinter } from "codemirror-json5"; import { hoverTooltip } from "@codemirror/view"; -import { jsonCompletion } from "./json-completion"; +import { json5Completion } from "./json-completion"; import { json5SchemaLinter } from "./json5-validation"; import { json5SchemaHover } from "./json5-hover"; @@ -17,7 +17,7 @@ export function json5Schema(schema: JSONSchema7) { linter(json5ParseLinter()), linter(json5SchemaLinter(schema)), json5Language.data.of({ - autocomplete: jsonCompletion(schema), + autocomplete: json5Completion(schema), }), hoverTooltip(json5SchemaHover(schema)), ]; diff --git a/src/json5.ts b/src/json5.ts index 1383bf4..087e896 100644 --- a/src/json5.ts +++ b/src/json5.ts @@ -1,6 +1,7 @@ // json5 export { json5SchemaLinter } from "./json5-validation"; export { json5SchemaHover } from "./json5-hover"; +export { json5Completion } from "./json-completion"; /** * @group Bundled Codemirror Extensions diff --git a/src/utils/node.ts b/src/utils/node.ts index b60039b..d554633 100644 --- a/src/utils/node.ts +++ b/src/utils/node.ts @@ -12,7 +12,7 @@ export const getNodeAtPosition = ( return syntaxTree(state).resolveInner(pos, side); }; -export const stripSurrondingQuotes = (str: string) => { +export const stripSurroundingQuotes = (str: string) => { return str.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); }; @@ -22,7 +22,7 @@ export const getWord = ( stripQuotes = true ) => { const word = node ? doc.sliceString(node.from, node.to) : ""; - return stripQuotes ? stripSurrondingQuotes(word) : word; + return stripQuotes ? stripSurroundingQuotes(word) : word; }; export const isInvalidValueNode = (node: SyntaxNode) => {