diff --git a/src/services/jsonCompletion.ts b/src/services/jsonCompletion.ts index 6234ed9f..6cc2d0c2 100644 --- a/src/services/jsonCompletion.ts +++ b/src/services/jsonCompletion.ts @@ -15,7 +15,7 @@ import { PromiseConstructor, Thenable, ASTNode, ObjectASTNode, ArrayASTNode, PropertyASTNode, ClientCapabilities, TextDocument, - CompletionItem, CompletionItemKind, CompletionList, Position, Range, TextEdit, InsertTextFormat, MarkupContent, MarkupKind + CompletionItem, CompletionItemKind, CompletionList, Position, Range, TextEdit, InsertTextFormat, MarkupContent, MarkupKind, StringASTNode } from '../jsonLanguageTypes'; import * as l10n from '@vscode/l10n'; @@ -86,6 +86,18 @@ export class JSONCompletion { const supportsCommitCharacters = false; //this.doesSupportsCommitCharacters(); disabled for now, waiting for new API: https://github.com/microsoft/vscode/issues/42544 const proposed = new Map(); + let insertPrevEdit: { nodeAfter: ASTNode, char: string } | undefined; + const prevNodeForComma = this.getPreviousNode(node, document, offset); + if (prevNodeForComma) { + const prevNodeEnd = prevNodeForComma.offset + prevNodeForComma.length; + if (prevNodeEnd < offset && this.evaluateSeparatorAfter(document, prevNodeEnd, offset) === ',') { + insertPrevEdit = { + nodeAfter: prevNodeForComma, + char: ',', + }; + } + } + const collector: CompletionsCollector = { add: (suggestion: CompletionItem) => { let label = suggestion.label; @@ -105,6 +117,17 @@ export class JSONCompletion { suggestion.commitCharacters = suggestion.kind === CompletionItemKind.Property ? propertyCommitCharacters : valueCommitCharacters; } suggestion.label = label; + if (insertPrevEdit) { + const { nodeAfter } = insertPrevEdit; + const insertPrevPos = document.positionAt(nodeAfter.offset + nodeAfter.length); + suggestion.additionalTextEdits = [{ + range: { + start: insertPrevPos, + end: insertPrevPos + }, + newText: insertPrevEdit.char, + }]; + } proposed.set(label, suggestion); result.items.push(suggestion); } else { @@ -736,7 +759,7 @@ export class JSONCompletion { } private getInsertTextForPlainText(text: string): string { - return text.replace(/[\\\$\}]/g, '\\$&'); // escape $, \ and } + return text.replace(/[\\\$\}]/g, '\\$&'); // escape $, \ and } } private getInsertTextForValue(value: any, separatorAfter: string): string { @@ -913,10 +936,14 @@ export class JSONCompletion { return text.substring(i + 1, offset); } - private evaluateSeparatorAfter(document: TextDocument, offset: number) { + private evaluateSeparatorAfter(document: TextDocument, offset: number, validateOffset?: number) { const scanner = Json.createScanner(document.getText(), true); scanner.setPosition(offset); const token = scanner.scan(); + // Insert if didn't find comma before requesting offset + if (validateOffset && scanner.getPosition() > validateOffset) { + return ','; + } switch (token) { case Json.SyntaxKind.CommaToken: case Json.SyntaxKind.CloseBraceToken: @@ -947,6 +974,39 @@ export class JSONCompletion { return 0; } + /** Find last item after offset */ + private getPreviousNode(node: ASTNode | undefined , document: TextDocument, offset: number) { + switch (node?.type) { + case 'string': + case 'number': + case 'boolean': + case 'property': + case 'null': { + node = node?.parent?.type === 'property' ? node.parent.parent : node?.parent; + } + } + if (!node) { + return; + } + const { children } = node; + if (!children) { + return; + } + let foundIndex: number | undefined; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (offset > child.offset + child.length) { + foundIndex = i; + break; + } else if (offset >= child.offset) { + foundIndex = i - 1; + break; + } + } + const previousNode = foundIndex !== undefined ? children[foundIndex] : undefined; + return previousNode?.type === 'property' ? previousNode.valueNode : previousNode; + } + private isInComment(document: TextDocument, start: number, offset: number) { const scanner = Json.createScanner(document.getText(), false); scanner.setPosition(start); diff --git a/src/test/completion.test.ts b/src/test/completion.test.ts index 29610748..6d0183e2 100644 --- a/src/test/completion.test.ts +++ b/src/test/completion.test.ts @@ -41,7 +41,7 @@ const assertCompletion = function (completions: CompletionList, expected: ItemDe } if (expected.resultText !== undefined && match.textEdit !== undefined) { const edit = TextEdit.is(match.textEdit) ? match.textEdit : TextEdit.replace(match.textEdit.replace, match.textEdit.newText); - assert.equal(applyEdits(document, [edit]), expected.resultText); + assert.equal(applyEdits(document, [edit, ...match.additionalTextEdits ?? []]), expected.resultText); } if (expected.sortText !== undefined) { assert.equal(match.sortText, expected.sortText); @@ -198,7 +198,7 @@ suite('JSON Completion', () => { await testCompletionsFor('{ "b": 1 "a|}', schema, { count: 2, items: [ - { label: 'a', documentation: 'A', resultText: '{ "b": 1 "a": ${1:0}' } + { label: 'a', documentation: 'A', resultText: '{ "b": 1, "a": ${1:0}' } ] }); await testCompletionsFor('{ "|}', schema, { @@ -229,7 +229,7 @@ suite('JSON Completion', () => { }); await testCompletionsFor('{ "a": 1 "b|"}', schema, { items: [ - { label: 'b', documentation: 'B', resultText: '{ "a": 1 "b": "$1"}' }, + { label: 'b', documentation: 'B', resultText: '{ "a": 1, "b": "$1"}' }, ] }); await testCompletionsFor('{ "c|"\n"b": "v"}', schema, { @@ -589,6 +589,79 @@ suite('JSON Completion', () => { }); }); + test('Insert comma before', async function () { + + const schema: JSONSchema = { + type: 'object', + properties: { + 'a': { + type: 'array', + items: { + type: 'boolean', + }, + }, + 'b': { + type: 'boolean', + }, + c: {} + } + }; + // insert comma + await testCompletionsFor('{ "a": [] | }', schema, { + count: 2, + items: [ + { label: 'c', resultText: '{ "a": [], "c" }' }, + ] + }); + await testCompletionsFor('{ "a": [] "|" }', schema, { + count: 2, + items: [ + { label: 'c', resultText: '{ "a": [], "c" }' }, + ] + }); + await testCompletionsFor('{ "a": [] |"c" }', schema, { + count: 2, + items: [ + { label: 'c', resultText: '{ "a": [], "c" }' }, + ] + }); + // probably only colon should be inserted + await testCompletionsFor('{ "c": "" "a": | }', schema, { + count: 1, + items: [ + { label: '[]', resultText: '{ "c": "", "a": [$1] }' }, + ] + }); + + // array + await testCompletionsFor('{ "a": [ false t| ] }', schema, { + count: 2, + items: [ + { label: 'true', resultText: '{ "a": [ false, true ] }' }, + ] + }); + await testCompletionsFor('{ "a": [ false |true ] }', schema, { + count: 2, + items: [ + { label: 'true', resultText: '{ "a": [ false, true ] }' }, + ] + }); + + + await testCompletionsFor('{ "c": "" "a" | }', schema, { + count: 1, + items: [ + { label: '[]', resultText: '{ "c": "", "a" [$1] }' }, + ] + }); + await testCompletionsFor('{ "c": "", "a" | }', schema, { + count: 1, + items: [ + { label: '[]', resultText: '{ "c": "", "a" [$1] }' }, + ] + }); + }); + test('Complete with required anyOf', async function () { const schema: JSONSchema = {