From 589c63b26d2ca78d969ad578d6ca3d314a5e128c Mon Sep 17 00:00:00 2001 From: Kevin DeJong Date: Fri, 26 Sep 2025 10:20:58 -0700 Subject: [PATCH] feat: improve completion handling with quote support and scalar detection - Add quote detection and handling in CompletionUtils - Add scalar node type detection for better syntax tree parsing - Simplify intrinsic function completion providers by using context parameter - Improve JSON parsing with incremental parsing support - Add comprehensive tests for quote handling in completions --- src/autocomplete/CompletionUtils.ts | 20 ++- ...insicFunctionArgumentCompletionProvider.ts | 62 ++------ .../ResourcePropertyCompletionProvider.ts | 4 + src/context/Context.ts | 19 ++- src/context/syntaxtree/SyntaxTree.ts | 90 ++++++++++-- src/context/syntaxtree/utils/NodeType.ts | 5 + .../syntaxtree/utils/TreeSitterTypes.ts | 42 +++++- src/utils/FuzzySearchUtil.ts | 5 +- tst/e2e/autocomplete/YamlNestedJson.test.ts | 120 ++++++++++++++++ tst/e2e/autocomplete/YamlQuotes.test.ts | 123 ++++++++++++++++ tst/e2e/context/Expected.ts | 10 +- ...ntrinsicFunctionCompletionProvider.test.ts | 2 +- ...ResourcePropertyCompletionProvider.test.ts | 132 +++++++++++++++++- .../TopLevelSectionCompletionProvider.test.ts | 2 +- tst/unit/context/Context.test.ts | 61 ++++++++ .../context/syntaxtree/SyntaxTree.test.ts | 19 +++ tst/utils/TemplateBuilder.ts | 10 ++ 17 files changed, 641 insertions(+), 85 deletions(-) create mode 100644 tst/e2e/autocomplete/YamlNestedJson.test.ts create mode 100644 tst/e2e/autocomplete/YamlQuotes.test.ts diff --git a/src/autocomplete/CompletionUtils.ts b/src/autocomplete/CompletionUtils.ts index 25b4700a..8a0f4356 100644 --- a/src/autocomplete/CompletionUtils.ts +++ b/src/autocomplete/CompletionUtils.ts @@ -53,16 +53,32 @@ export function createCompletionItem( sortText?: string; documentation?: string; data?: Record; + context?: Context; }, ): CompletionItem { + let textEdit: TextEdit | undefined = undefined; + let filterText = label; + const insertText = options?.insertText ?? label; + if (options?.context) { + const textInQuotes = options.context.textInQuotes(); + if (textInQuotes) { + const range = createReplacementRange(options.context); + filterText = `${textInQuotes}${String(label)}${textInQuotes}`; + if (range) { + textEdit = TextEdit.replace(range, `${textInQuotes}${insertText}${textInQuotes}`); + } + } + } + return { label, kind, detail: options?.detail ?? ExtensionName, - insertText: options?.insertText ?? label, + insertText: insertText, insertTextFormat: options?.insertTextFormat, insertTextMode: options?.insertTextMode, - filterText: label, + textEdit: textEdit, + filterText: filterText, sortText: options?.sortText, documentation: `${options?.documentation ? `${options?.documentation}\n` : ''}Source: ${ExtensionName}`, data: options?.data, diff --git a/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts b/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts index ae13c337..d487901c 100644 --- a/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts +++ b/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts @@ -462,19 +462,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr private getMappingNameCompletions(mappingsEntities: Map, context: Context): CompletionItem[] { try { - const items = [...mappingsEntities.keys()].map((key) => { - const item = createCompletionItem(key, CompletionItemKind.EnumMember); - - if (context.text.length > 0) { - const range = createReplacementRange(context); - if (range) { - item.textEdit = TextEdit.replace(range, key); - delete item.insertText; - } - } - - return item; - }); + const items = [...mappingsEntities.keys()].map((key) => + createCompletionItem(key, CompletionItemKind.EnumMember, { context }), + ); return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items; } catch (error) { @@ -508,19 +498,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr return undefined; } - const items = topLevelKeys.map((key) => { - const item = createCompletionItem(key, CompletionItemKind.EnumMember); - - if (context.text.length > 0) { - const range = createReplacementRange(context); - if (range) { - item.textEdit = TextEdit.replace(range, key); - delete item.insertText; - } - } - - return item; - }); + const items = topLevelKeys.map((key) => + createCompletionItem(key, CompletionItemKind.EnumMember, { context }), + ); return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items; } catch (error) { @@ -556,19 +536,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr return undefined; } - const items = secondLevelKeys.map((key) => { - const item = createCompletionItem(key, CompletionItemKind.EnumMember); - - if (context.text.length > 0) { - const range = createReplacementRange(context); - if (range) { - item.textEdit = TextEdit.replace(range, key); - delete item.insertText; - } - } - - return item; - }); + const items = secondLevelKeys.map((key) => + createCompletionItem(key, CompletionItemKind.EnumMember, { context }), + ); return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items; } catch (error) { @@ -652,19 +622,7 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr const items = [...resourceEntities.keys()] .filter((logicalId) => logicalId !== context.logicalId) - .map((logicalId) => { - const item = createCompletionItem(logicalId, CompletionItemKind.Reference); - - if (context.text.length > 0) { - const range = createReplacementRange(context); - if (range) { - item.textEdit = TextEdit.replace(range, logicalId); - delete item.insertText; - } - } - - return item; - }); + .map((logicalId) => createCompletionItem(logicalId, CompletionItemKind.Reference, { context })); return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items; } diff --git a/src/autocomplete/ResourcePropertyCompletionProvider.ts b/src/autocomplete/ResourcePropertyCompletionProvider.ts index 239f3e63..2c807c12 100644 --- a/src/autocomplete/ResourcePropertyCompletionProvider.ts +++ b/src/autocomplete/ResourcePropertyCompletionProvider.ts @@ -134,6 +134,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { existingProperties, context.text.length === 0, schema, + context, ); } @@ -162,6 +163,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { const completions = enumValues.map((value, index) => createCompletionItem(String(value), CompletionItemKind.EnumMember, { sortText: `${index}`, + context: context, }), ); @@ -204,6 +206,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { existingProperties: Set, isEmptyText: boolean, schema: ResourceSchema, + context: Context, ): CompletionItem[] { const result: CompletionItem[] = []; @@ -232,6 +235,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { CompletionItemKind.Property, { data: itemData, + context: context, }, ); diff --git a/src/context/Context.ts b/src/context/Context.ts index 94d0fc78..73115aff 100644 --- a/src/context/Context.ts +++ b/src/context/Context.ts @@ -18,6 +18,7 @@ import { NodeType } from './syntaxtree/utils/NodeType'; import { YamlNodeTypes, CommonNodeTypes } from './syntaxtree/utils/TreeSitterTypes'; export type SectionType = TopLevelSection | 'Unknown'; +export type QuoteCharacter = '"' | "'"; export class Context { public readonly section: SectionType; @@ -94,6 +95,16 @@ export class Context { ); } + public textInQuotes(): QuoteCharacter | undefined { + if (NodeType.isNodeType(this.node, YamlNodeTypes.DOUBLE_QUOTE_SCALAR)) { + return '"'; + } else if (NodeType.isNodeType(this.node, YamlNodeTypes.SINGLE_QUOTE_SCALAR)) { + return "'"; + } + + return undefined; + } + public isValue() { // SYNTHETIC_KEY_OR_VALUE can be both key and value if (NodeType.isNodeType(this.node, CommonNodeTypes.SYNTHETIC_KEY_OR_VALUE)) { @@ -121,7 +132,10 @@ export class Context { // Find the parent block_mapping_pair node to get the key position let current = this.node.parent; while (current) { - if (NodeType.isNodeType(current, YamlNodeTypes.BLOCK_MAPPING_PAIR)) { + // if we are in a FLOW (nested JSON) then just return FALSE because this doesn't apply + if (NodeType.isNodeType(current, YamlNodeTypes.FLOW_MAPPING, YamlNodeTypes.FLOW_SEQUENCE)) { + return false; + } else if (NodeType.isNodeType(current, YamlNodeTypes.BLOCK_MAPPING_PAIR)) { // If current node starts on a different row than the key, it could be a new key return current.startPosition.row < this.node.startPosition.row; } @@ -252,6 +266,7 @@ export class Context { section: this.section, logicalId: this.logicalId, text: this.text, + nodeType: this.node.type, propertyPath: this.propertyPath, entitySection: this.entitySection, metadata: `isTopLevel=${this.isTopLevel}, isResourceType=${this.isResourceType}, isIntrinsicFunction=${this.isIntrinsicFunc}, isPseudoParameter=${this.isPseudoParameter}, isResourceAttribute=${this.isResourceAttribute}`, @@ -259,6 +274,8 @@ export class Context { root: { start: this.entityRootNode?.startPosition, end: this.entityRootNode?.endPosition }, entity: this.entity, intrinsicContext: this.intrinsicContext.record(), + isKey: this.isKey(), + isValue: this.isValue(), }; } } diff --git a/src/context/syntaxtree/SyntaxTree.ts b/src/context/syntaxtree/SyntaxTree.ts index 4274e8be..06248454 100644 --- a/src/context/syntaxtree/SyntaxTree.ts +++ b/src/context/syntaxtree/SyntaxTree.ts @@ -109,17 +109,25 @@ export abstract class SyntaxTree { const isYAML = this.type === DocumentType.YAML; let hasError = this.hasErrorInParentChain(initialNode); - if (isYAML && hasError) { - const incrementalNode = this.tryIncrementalParsing(position); - if (incrementalNode) { - initialNode = incrementalNode; - hasError = this.hasErrorInParentChain(initialNode); // Recalculate after incremental parsing + if (hasError) { + if (isYAML) { + const incrementalNode = this.tryIncrementalYamlParsing(position); + if (incrementalNode) { + initialNode = incrementalNode; + hasError = this.hasErrorInParentChain(initialNode); // Recalculate after incremental parsing + } + } else { + const incrementalNode = this.tryIncrementalJsonParsing(position); + if (incrementalNode) { + initialNode = incrementalNode; + hasError = this.hasErrorInParentChain(initialNode); // Recalculate after incremental parsing + } } } - // YAML-specific: if namedDescendantForPosition returned a block_mapping_pair, - // validate we're actually at colon/separator position - if (this.type === DocumentType.YAML && NodeType.isNodeType(initialNode, YamlNodeTypes.BLOCK_MAPPING_PAIR)) { + // 3. validate we're actually at colon/separator position + // prevents : triggering which has a bad parent path and results in false positive autocompletion + if (this.type === DocumentType.YAML && NodeType.isPairNode(initialNode, this.type)) { const key = initialNode.childForFieldName('key'); const value = initialNode.childForFieldName('value'); if ( @@ -131,7 +139,13 @@ export abstract class SyntaxTree { } } - // Special handling for YAML: check for whitespace-only lines first + // 4. if we are in JSON even if this is YAML we probably have the right node already + // For scalar types, return immediately as they're already the most specific node + if (NodeType.isScalarNode(initialNode, this.type) && !hasError) { + return initialNode; + } + + // 5. Special handling for YAML: check for whitespace-only lines first if (this.type === DocumentType.YAML && !hasError) { const currentLine = this.lines[point.row]; const trimmedLine = currentLine?.trim() || ''; @@ -144,7 +158,7 @@ export abstract class SyntaxTree { } } - // 3. Try to find the ideal node immediately: the most specific, valid, small node. + // 6. Try to find the ideal node immediately: the most specific, valid, small node. // This is the best-case scenario and allows for a very fast exit. const specificNode = NodeSearch.findMostSpecificNode( initialNode, @@ -155,7 +169,7 @@ export abstract class SyntaxTree { return specificNode; } - // 4. If no ideal node was found, the initialNode might be large or invalid. + // 7. If no ideal node was found, the initialNode might be large or invalid. // Now, we search for a "better" alternative nearby. const betterNode = NodeSearch.findNearbyNode( this.tree.rootNode, @@ -172,13 +186,13 @@ export abstract class SyntaxTree { return betterNode; } - // 5. Fallback: If no better alternative is found, return the original node + // 8. Fallback: If no better alternative is found, return the original node // ONLY if it's valid. A large but valid node is better than nothing. if (NodeType.isValidNode(initialNode)) { return initialNode; } - // 6. Last Resort: The initial node was invalid, and we found no good alternative. + // 9. Last Resort: The initial node was invalid, and we found no good alternative. // Find any smaller node nearby, even if it's not perfectly valid, as it's // better than returning a large, broken node. const anySmallerNode = NodeSearch.findNearbyNode( @@ -243,6 +257,13 @@ export abstract class SyntaxTree { return undefined; }; + if (NodeType.isNodeType(node, YamlNodeTypes.FLOW_MAPPING)) { + const syntheticKey = createSyntheticNode('', point, point, node); + syntheticKey.type = CommonNodeTypes.SYNTHETIC_KEY; + syntheticKey.grammarType = CommonNodeTypes.SYNTHETIC_KEY; + return syntheticKey; + } + let closestKey = findClosestKey(); if (closestKey) { @@ -306,7 +327,7 @@ export abstract class SyntaxTree { return false; } - private tryIncrementalParsing(position: Position): SyntaxNode | undefined { + private tryIncrementalYamlParsing(position: Position): SyntaxNode | undefined { const currentLine = this.lines[position.line] ?? ''; const textBeforeCursor = currentLine.slice(0, Math.max(0, position.character)); const textAfterCursor = currentLine.slice(Math.max(0, position.character)); @@ -324,6 +345,33 @@ export abstract class SyntaxTree { return undefined; } + private tryIncrementalJsonParsing(position: Position): SyntaxNode | undefined { + const currentLine = this.lines[position.line] ?? ''; + const textBeforeCursor = currentLine.slice(0, Math.max(0, position.character)); + const textAfterCursor = currentLine.slice(Math.max(0, position.character)); + + // Strategy 1: If typing a key, add colon and space + if (textBeforeCursor.endsWith(':') && textBeforeCursor.trim() && !textAfterCursor.trim()) { + // Insert colon and space at cursor position + const modifiedLines = [...this.lines]; + modifiedLines[position.line] = textBeforeCursor + ' null'; + const completedContent = modifiedLines.join('\n'); + const result = this.testIncrementalParsing(completedContent, position); + if (result) return result; + } else if (currentLine.includes('"')) { + // Strategy 2: Handle any quoted string as potential incomplete key + // Look for patterns like "text" and convert to "text": null + const modifiedLines = [...this.lines]; + // Replace quoted strings that aren't followed by : with complete key-value pairs + modifiedLines[position.line] = currentLine.replace(/"([^"]*)"\s*(?!:)/g, '"$1": null'); + const completedContent = modifiedLines.join('\n'); + const result = this.testIncrementalParsing(completedContent, position); + if (result) return result; + } + + return undefined; + } + private testIncrementalParsing(completedContent: string, position: Position): SyntaxNode | undefined { try { // Parse the completed content to create a temporary tree @@ -437,7 +485,7 @@ export abstract class SyntaxTree { if (NodeType.isPairNode(parent, this.type)) { // This is a key-value pair. Add the key to our semantic path. const key = NodeType.extractKeyFromPair(parent, this.type); - if (key) { + if (key !== undefined) { propertyPath.push(key); } entityPath.push(parent); @@ -474,6 +522,18 @@ export abstract class SyntaxTree { propertyPath.push(index); entityPath.push(current); } + } else if ( + NodeType.isNodeType(parent, YamlNodeTypes.FLOW_NODE) && + NodeType.isNodeType(current, YamlNodeTypes.DOUBLE_QUOTE_SCALAR) + ) { + // Could be incomplete key in nested JSON but need to look to grandparent + const grandparent = parent.parent; + if (grandparent && NodeType.isNodeType(grandparent, YamlNodeTypes.FLOW_MAPPING)) { + // Is incomplete key pair in an object + // { "" } + propertyPath.push(current.text.replace(/^,?\s*"|"\s*/g, '')); + entityPath.push(current); + } } current = parent; diff --git a/src/context/syntaxtree/utils/NodeType.ts b/src/context/syntaxtree/utils/NodeType.ts index af80d964..942d6d3e 100644 --- a/src/context/syntaxtree/utils/NodeType.ts +++ b/src/context/syntaxtree/utils/NodeType.ts @@ -48,6 +48,11 @@ export class NodeType { return types.includes(node.type); } + public static isScalarNode(node: SyntaxNode, documentType: DocumentType): boolean { + const set = documentType === DocumentType.JSON ? JSON_NODE_SETS.scalar : YAML_NODE_SETS.scalar; + return set.has(node.type); + } + public static isNotNodeType(node: SyntaxNode, ...types: string[]) { if (types.length === 1) { return node.type !== types[0]; diff --git a/src/context/syntaxtree/utils/TreeSitterTypes.ts b/src/context/syntaxtree/utils/TreeSitterTypes.ts index 160d4f35..aa360cd2 100644 --- a/src/context/syntaxtree/utils/TreeSitterTypes.ts +++ b/src/context/syntaxtree/utils/TreeSitterTypes.ts @@ -7,6 +7,12 @@ export enum JsonNodeTypes { OBJECT = 'object', PAIR = 'pair', // represents a key-value pair ARRAY = 'array', + // Scalar types + STRING = 'string', + NUMBER = 'number', + TRUE = 'true', + FALSE = 'false', + NULL = 'null', } export enum YamlNodeTypes { @@ -26,6 +32,17 @@ export enum YamlNodeTypes { FLOW_SEQUENCE = 'flow_sequence', BLOCK_SEQUENCE_ITEM = 'block_sequence_item', FLOW_SEQUENCE_ITEM = 'flow_sequence_item', + + // Scalar types + BOOLEAN_SCALAR = 'boolean_scalar', + FLOAT_SCALAR = 'float_scalar', + INTEGER_SCALAR = 'integer_scalar', + NULL_SCALAR = 'null_scalar', + STRING_SCALAR = 'string_scalar', + TIMESTAMP_SCALAR = 'timestamp_scalar', + BLOCK_SCALAR = 'block_scalar', + DOUBLE_QUOTE_SCALAR = 'double_quote_scalar', + SINGLE_QUOTE_SCALAR = 'single_quote_scalar', } export enum CommonNodeTypes { @@ -38,17 +55,38 @@ export enum CommonNodeTypes { SYNTHETIC_KEY_OR_VALUE = 'synthetic_key_or_value', } -export const JSON_NODE_SETS: Record<'object' | 'pair' | 'array', ReadonlySet> = { +export const JSON_NODE_SETS: Record<'object' | 'pair' | 'array' | 'scalar', ReadonlySet> = { object: new Set([JsonNodeTypes.OBJECT]), pair: new Set([JsonNodeTypes.PAIR]), array: new Set([JsonNodeTypes.ARRAY]), + scalar: new Set([ + JsonNodeTypes.STRING, + JsonNodeTypes.NUMBER, + JsonNodeTypes.TRUE, + JsonNodeTypes.FALSE, + JsonNodeTypes.NULL, + ]), } as const; -export const YAML_NODE_SETS: Record<'mapping' | 'pair' | 'sequence' | 'sequence_item', ReadonlySet> = { +export const YAML_NODE_SETS: Record< + 'mapping' | 'pair' | 'sequence' | 'sequence_item' | 'scalar', + ReadonlySet +> = { mapping: new Set([YamlNodeTypes.BLOCK_MAPPING, YamlNodeTypes.FLOW_MAPPING]), pair: new Set([YamlNodeTypes.BLOCK_MAPPING_PAIR, YamlNodeTypes.FLOW_PAIR]), sequence: new Set([YamlNodeTypes.BLOCK_SEQUENCE, YamlNodeTypes.FLOW_SEQUENCE]), sequence_item: new Set([YamlNodeTypes.BLOCK_SEQUENCE_ITEM, YamlNodeTypes.FLOW_SEQUENCE_ITEM]), + scalar: new Set([ + YamlNodeTypes.BOOLEAN_SCALAR, + YamlNodeTypes.FLOAT_SCALAR, + YamlNodeTypes.INTEGER_SCALAR, + YamlNodeTypes.NULL_SCALAR, + YamlNodeTypes.STRING_SCALAR, + YamlNodeTypes.TIMESTAMP_SCALAR, + YamlNodeTypes.BLOCK_SCALAR, + YamlNodeTypes.DOUBLE_QUOTE_SCALAR, + YamlNodeTypes.SINGLE_QUOTE_SCALAR, + ]), } as const; export const LARGE_NODE_TYPES: ReadonlySet = new Set([CommonNodeTypes.DOCUMENT, CommonNodeTypes.STREAM]); diff --git a/src/utils/FuzzySearchUtil.ts b/src/utils/FuzzySearchUtil.ts index f632e20d..3ea9b28f 100644 --- a/src/utils/FuzzySearchUtil.ts +++ b/src/utils/FuzzySearchUtil.ts @@ -1,5 +1,5 @@ import Fuse, { IFuseOptions } from 'fuse.js'; -import { CompletionItem, InsertTextFormat } from 'vscode-languageserver'; +import { CompletionItem } from 'vscode-languageserver'; export type FuzzySearchFunction = (items: CompletionItem[], query: string) => CompletionItem[]; @@ -28,9 +28,6 @@ export function fuzzySearch( const item = result.item; item.sortText = index < 10 ? `0${index}` : String(index); item.preselect = index === 0; - if (item.insertTextFormat !== InsertTextFormat.Snippet) { - item.filterText = query; - } return item; }); diff --git a/tst/e2e/autocomplete/YamlNestedJson.test.ts b/tst/e2e/autocomplete/YamlNestedJson.test.ts new file mode 100644 index 00000000..6cd3df3b --- /dev/null +++ b/tst/e2e/autocomplete/YamlNestedJson.test.ts @@ -0,0 +1,120 @@ +import { describe, it } from 'vitest'; +import { DocumentType } from '../../../src/document/Document'; +import { CompletionExpectationBuilder, TemplateBuilder, TemplateScenario } from '../../utils/TemplateBuilder'; + +describe('YAML Completion when using quotes', () => { + const content = `Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: { + "" + }`; + + it('Should autocomplete keys in nested json', () => { + const template = new TemplateBuilder(DocumentType.YAML); + const scenario: TemplateScenario = { + name: 'Get Keys with double quotes', + steps: [ + { + action: 'type', + content: content, + position: { line: 4, character: 6 }, + description: 'Empty key', + verification: { + position: { line: 4, character: 7 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Value']) + .expectItemDetails({ + Value: { + textEdit: { + newText: '"Value"', + }, + }, + }) + .build(), + }, + }, + { + action: 'type', + content: 'V', + position: { line: 4, character: 7 }, + description: 'One letter provided', + verification: { + position: { line: 4, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Value']) + .expectItemDetails({ + Value: { + textEdit: { + newText: '"Value"', + }, + }, + }) + .build(), + }, + }, + ], + }; + template.executeScenario(scenario); + }); + + it('Should autocomplete enums in nested json', () => { + const template = new TemplateBuilder(DocumentType.YAML); + const scenario: TemplateScenario = { + name: 'Get enum values with double quotes', + steps: [ + { + action: 'type', + content: content, + position: { line: 0, character: 0 }, + description: 'Build template', + }, + { + action: 'type', + content: 'Type', + position: { line: 4, character: 7 }, + description: 'Build key', + }, + { + action: 'type', + content: ': ""', + position: { line: 4, character: 12 }, + description: 'Empty quotes', + verification: { + position: { line: 4, character: 15 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['String']) + .expectItemDetails({ + String: { + textEdit: { + newText: '"String"', + }, + }, + }) + .build(), + }, + }, + { + action: 'type', + content: 'S', + position: { line: 4, character: 15 }, + description: 'One character exists', + verification: { + position: { line: 4, character: 16 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['String']) + .expectItemDetails({ + String: { + textEdit: { + newText: '"String"', + }, + }, + }) + .build(), + }, + }, + ], + }; + template.executeScenario(scenario); + }); +}); diff --git a/tst/e2e/autocomplete/YamlQuotes.test.ts b/tst/e2e/autocomplete/YamlQuotes.test.ts new file mode 100644 index 00000000..84d1ab7f --- /dev/null +++ b/tst/e2e/autocomplete/YamlQuotes.test.ts @@ -0,0 +1,123 @@ +import { describe, it } from 'vitest'; +import { DocumentType } from '../../../src/document/Document'; +import { CompletionExpectationBuilder, TemplateBuilder, TemplateScenario } from '../../utils/TemplateBuilder'; + +describe('YAML Completion when using quotes', () => { + const content = `Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + `; + + describe('Double quotes', () => { + const template = new TemplateBuilder(DocumentType.YAML); + + it('Object Keys', () => { + const scenario: TemplateScenario = { + name: 'Get Keys with double quotes', + steps: [ + { + action: 'initialize', + content: `${content}""`, + verification: { + position: { line: 4, character: 7 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Type']) + .expectItemDetails({ + Type: { + textEdit: { + newText: '"Type"', + }, + }, + }) + .build(), + }, + }, + ], + }; + template.executeScenario(scenario); + }); + + it('Enum values', () => { + const scenario: TemplateScenario = { + name: 'No empty lines Value property key', + steps: [ + { + action: 'initialize', + content: `${content}"Type": ""`, + verification: { + position: { line: 4, character: 15 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['String']) + .expectItemDetails({ + String: { + textEdit: { + newText: '"String"', + }, + }, + }) + .build(), + }, + }, + ], + }; + template.executeScenario(scenario); + }); + }); + + describe('Single quotes', () => { + const template = new TemplateBuilder(DocumentType.YAML); + + it('Object keys', () => { + const scenario: TemplateScenario = { + name: 'Get Keys with single quotes', + steps: [ + { + action: 'initialize', + content: `${content}''`, + verification: { + position: { line: 4, character: 7 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Type']) + .expectItemDetails({ + Type: { + textEdit: { + newText: "'Type'", + }, + }, + }) + .build(), + }, + }, + ], + }; + template.executeScenario(scenario); + }); + + it('Enum values', () => { + const scenario: TemplateScenario = { + name: 'Get enum values with single quotes', + steps: [ + { + action: 'initialize', + content: `${content}'Type': ''`, + verification: { + position: { line: 4, character: 15 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['String']) + .expectItemDetails({ + String: { + textEdit: { + newText: "'String'", + }, + }, + }) + .build(), + }, + }, + ], + }; + template.executeScenario(scenario); + }); + }); +}); diff --git a/tst/e2e/context/Expected.ts b/tst/e2e/context/Expected.ts index e6c8a2d9..78b394bd 100644 --- a/tst/e2e/context/Expected.ts +++ b/tst/e2e/context/Expected.ts @@ -918,13 +918,13 @@ export const EXPECTED: Record< section: 'Resources', logicalId: 'LambdaFunction', numPositions: 778, - valid: 777, + valid: 778, numVerifiedPositions: 778, noContext: 0, - topLevelErrors: 1, - logicalIdErrors: 1, - entityMismatchErrors: 1, - failingPositions: [[312, 0]], + topLevelErrors: 0, + logicalIdErrors: 0, + entityMismatchErrors: 0, + failingPositions: [], }, LambdaRole: { section: 'Resources', diff --git a/tst/unit/autocomplete/IntrinsicFunctionCompletionProvider.test.ts b/tst/unit/autocomplete/IntrinsicFunctionCompletionProvider.test.ts index 05d4c2ab..c90e9651 100644 --- a/tst/unit/autocomplete/IntrinsicFunctionCompletionProvider.test.ts +++ b/tst/unit/autocomplete/IntrinsicFunctionCompletionProvider.test.ts @@ -198,7 +198,7 @@ describe('IntrinsicFunctionCompletionProvider', () => { // Check that the fuzzy search properties are set expect(base64Function!.sortText).toBeDefined(); - expect(base64Function!.filterText).toBe('Fn::Base'); + expect(base64Function!.filterText).toBe('Fn::Base64'); }); test('should return fuzzy matched results for typos in function name', () => { diff --git a/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts b/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts index b9654751..fa6bfccf 100644 --- a/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts +++ b/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts @@ -753,6 +753,78 @@ describe('ResourcePropertyCompletionProvider', () => { expect(valueItem!.kind).toBe(CompletionItemKind.Property); }); + test('should handle double quoted property names in YAML', () => { + const mockSchema = new ResourceSchema(Schemas.S3Bucket.contents); + const mockSchemas = new Map(); + mockSchemas.set('AWS::S3::Bucket', mockSchema); + + const combinedSchemas = new CombinedSchemas(); + Object.defineProperty(combinedSchemas, 'schemas', { + get: () => mockSchemas, + }); + mockComponents.schemaRetriever.getDefault.returns(combinedSchemas); + + const mockContext = createResourceContext('MyBucket', { + text: `"Bucket"`, + propertyPath: ['Resources', 'MyBucket', 'Properties', 'Bucket'], + data: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + nodeType: 'double_quote_scalar', // Simulate double quoted context + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBeGreaterThan(0); + + // Find BucketName completion item + const bucketNameItem = result!.find((item) => item.label === 'BucketName'); + expect(bucketNameItem).toBeDefined(); + + // Should have textEdit with quotes + expect(bucketNameItem!.textEdit).toBeDefined(); + expect(bucketNameItem!.textEdit?.newText).toBe('"BucketName"'); + expect(bucketNameItem!.filterText).toBe('"BucketName"'); + }); + + test('should handle single quoted property names in YAML', () => { + const mockSchema = new ResourceSchema(Schemas.S3Bucket.contents); + const mockSchemas = new Map(); + mockSchemas.set('AWS::S3::Bucket', mockSchema); + + const combinedSchemas = new CombinedSchemas(); + Object.defineProperty(combinedSchemas, 'schemas', { + get: () => mockSchemas, + }); + mockComponents.schemaRetriever.getDefault.returns(combinedSchemas); + + const mockContext = createResourceContext('MyBucket', { + text: 'Bucket', + propertyPath: ['Resources', 'MyBucket', 'Properties', 'Bucket'], + data: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + nodeType: 'single_quote_scalar', // Simulate single quoted context + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBeGreaterThan(0); + + // Find BucketName completion item + const bucketNameItem = result!.find((item) => item.label === 'BucketName'); + expect(bucketNameItem).toBeDefined(); + + // Should have textEdit with single quotes + expect(bucketNameItem!.textEdit).toBeDefined(); + expect(bucketNameItem!.textEdit?.newText).toBe("'BucketName'"); + expect(bucketNameItem!.filterText).toBe("'BucketName'"); + }); + // Enum Value Completion Tests (migrated from ResourceEnumValueCompletionProvider) describe('Enum Value Completions', () => { const accessControlEnumValues = [ @@ -903,10 +975,10 @@ describe('ResourcePropertyCompletionProvider', () => { test('should return empty array when schema is not found for resource type in enum context', () => { const mockContext = createResourceContext('MyResource', { text: '', - propertyPath: ['Resources', 'MyResource', 'Properties', 'SomeProperty', 'Value'], + propertyPath: ['Resources', 'MyResource', 'Properties', 'SomeProperty'], data: { Type: 'AWS::Unknown::Resource', - Properties: { SomeProperty: {} }, + Properties: { SomeProperty: '' }, }, }); const testSchemas = combinedSchemas([]); @@ -917,5 +989,61 @@ describe('ResourcePropertyCompletionProvider', () => { expect(result).toBeDefined(); expect(result!.length).toBe(0); }); + + test('should handle double quoted enum values in YAML', () => { + setupS3SchemaWithEnums(); + + const mockContext = createResourceContext('MyBucket', { + text: `"Pub"`, + propertyPath: ['Resources', 'MyBucket', 'Properties', 'AccessControl'], + data: { + Type: 'AWS::S3::Bucket', + Properties: { AccessControl: '' }, + }, + nodeType: 'double_quote_scalar', // Simulate double quoted context + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBe(2); // PublicRead and PublicReadWrite + + // Find PublicRead completion item + const publicReadItem = result!.find((item) => item.label === 'PublicRead'); + expect(publicReadItem).toBeDefined(); + + // Should have textEdit with double quotes + expect(publicReadItem!.textEdit).toBeDefined(); + expect(publicReadItem!.textEdit?.newText).toBe('"PublicRead"'); + expect(publicReadItem!.filterText).toBe('"PublicRead"'); + }); + + test('should handle single quoted enum values in YAML', () => { + setupS3SchemaWithEnums(); + + const mockContext = createResourceContext('MyBucket', { + text: `'Priv'`, + propertyPath: ['Resources', 'MyBucket', 'Properties', 'AccessControl'], + data: { + Type: 'AWS::S3::Bucket', + Properties: { AccessControl: '' }, + }, + nodeType: 'single_quote_scalar', // Simulate single quoted context + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBe(1); // Only Private matches + + // Find Private completion item + const privateItem = result!.find((item) => item.label === 'Private'); + expect(privateItem).toBeDefined(); + + // Should have textEdit with single quotes + expect(privateItem!.textEdit).toBeDefined(); + expect(privateItem!.textEdit?.newText).toBe("'Private'"); + expect(privateItem!.filterText).toBe("'Private'"); + }); }); }); diff --git a/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts b/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts index 49813d48..d11efd88 100644 --- a/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts +++ b/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts @@ -85,7 +85,7 @@ describe('TopLevelSectionCompletionProvider', () => { // Should find Parameters as a match const parametersItem = result!.find((item) => item.label === 'Parameters'); expect(parametersItem).toBeDefined(); - expect(parametersItem!.filterText).toBe('Par'); + expect(parametersItem!.filterText).toBe('Parameters'); }); test('should handle context with whitespace-only text', () => { diff --git a/tst/unit/context/Context.test.ts b/tst/unit/context/Context.test.ts index daacc0c2..2f3be5d9 100644 --- a/tst/unit/context/Context.test.ts +++ b/tst/unit/context/Context.test.ts @@ -591,4 +591,65 @@ Resources: expect(context!.atEntityKeyLevel()).toBe(false); }); }); + + describe('textInQuotes method', () => { + // Create test templates with quoted values + const quotedYamlTemplate = `AWSTemplateFormatVersion: "2010-09-09" +Resources: + TestResource: + Type: "AWS::S3::Bucket" + Properties: + BucketName: "my-bucket" + Tags: + - Key: 'Environment' + Value: 'Production'`; + + const quotedYamlUri = 'file:///quoted-test.yaml'; + + beforeAll(() => { + syntaxTreeManager.add(quotedYamlUri, quotedYamlTemplate); + }); + + it('should return double quote for double quoted values', () => { + // Position in "2010-09-09" (double quoted) + const context = getContextAt(0, 30, quotedYamlUri); + expect(context).toBeDefined(); + expect(context!.textInQuotes()).toBe('"'); + }); + + it('should return double quote for double quoted resource type', () => { + // Position in "AWS::S3::Bucket" (double quoted) + const context = getContextAt(3, 15, quotedYamlUri); + expect(context).toBeDefined(); + expect(context!.textInQuotes()).toBe('"'); + }); + + it('should return double quote for double quoted property value', () => { + // Position in "my-bucket" (double quoted) + const context = getContextAt(5, 20, quotedYamlUri); + expect(context).toBeDefined(); + expect(context!.textInQuotes()).toBe('"'); + }); + + it('should return single quote for single quoted values', () => { + // Position in 'Environment' (single quoted) + const context = getContextAt(7, 15, quotedYamlUri); + expect(context).toBeDefined(); + expect(context!.textInQuotes()).toBe("'"); + }); + + it('should return single quote for single quoted tag value', () => { + // Position in 'Production' (single quoted) + const context = getContextAt(8, 20, quotedYamlUri); + expect(context).toBeDefined(); + expect(context!.textInQuotes()).toBe("'"); + }); + + it('should return undefined for unquoted values', () => { + // Position in unquoted resource name + const context = getContextAt(2, 5, quotedYamlUri); + expect(context).toBeDefined(); + expect(context!.textInQuotes()).toBeUndefined(); + }); + }); }); diff --git a/tst/unit/context/syntaxtree/SyntaxTree.test.ts b/tst/unit/context/syntaxtree/SyntaxTree.test.ts index 47a3e6be..7d615be2 100644 --- a/tst/unit/context/syntaxtree/SyntaxTree.test.ts +++ b/tst/unit/context/syntaxtree/SyntaxTree.test.ts @@ -1486,6 +1486,25 @@ Resources: expect(node).toBeDefined(); expect(node.type).toBe(CommonNodeTypes.SYNTHETIC_KEY_OR_VALUE); }); + + it('should return Key for cursor after being in a mapping', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: { + + }`; + const tree = createSyntaxTree(template, DocumentType.YAML); + // Position after the dash (where cursor would be) + const node = tree.getNodeAtPosition({ line: 4, character: 6 }); + expect(node).toBeDefined(); + expect(node.type).toBe(CommonNodeTypes.SYNTHETIC_KEY); + + // Validate the path is correct + const pathInfo = tree.getPathAndEntityInfo(node); + expect(pathInfo).toBeDefined(); + expect(pathInfo.propertyPath).toStrictEqual(['Resources', 'Bucket', 'Properties', '']); + }); }); describe('topLevelSections', () => { diff --git a/tst/utils/TemplateBuilder.ts b/tst/utils/TemplateBuilder.ts index c5534be7..5f15fc1a 100644 --- a/tst/utils/TemplateBuilder.ts +++ b/tst/utils/TemplateBuilder.ts @@ -67,6 +67,9 @@ class CompletionExpectation extends Expectation { detail?: string; documentation?: string; insertText?: string; + textEdit?: { + newText: string; + }; }; }; } @@ -494,6 +497,13 @@ export class TemplateBuilder { details.insertText, ); } + if (details.textEdit?.newText !== undefined) { + expectAt( + item.textEdit?.newText, + position, + `TextEdit NewText mismatch for item '${label}'${desc}`, + ).toBe(details.textEdit?.newText); + } } } }