diff --git a/src/autocomplete/CompletionFormatter.ts b/src/autocomplete/CompletionFormatter.ts index 98925062..3de5f067 100644 --- a/src/autocomplete/CompletionFormatter.ts +++ b/src/autocomplete/CompletionFormatter.ts @@ -1,11 +1,23 @@ -import { CompletionItem, CompletionItemKind, CompletionList, InsertTextFormat } from 'vscode-languageserver'; +import { + CompletionItem, + CompletionItemKind, + CompletionList, + InsertTextFormat, + Range, + Position, + TextEdit, +} from 'vscode-languageserver'; import { Context } from '../context/Context'; import { ResourceAttributesSet, TopLevelSection, TopLevelSectionsSet } from '../context/ContextType'; +import { Resource } from '../context/semantic/Entity'; +import { EntityType } from '../context/semantic/SemanticTypes'; import { NodeType } from '../context/syntaxtree/utils/NodeType'; import { DocumentType } from '../document/Document'; +import { SchemaRetriever } from '../schema/SchemaRetriever'; import { EditorSettings } from '../settings/Settings'; import { LoggerFactory } from '../telemetry/LoggerFactory'; import { getIndentationString } from '../utils/IndentationUtils'; +import { RESOURCE_ATTRIBUTE_TYPES } from './CompletionUtils'; export type CompletionItemData = { type?: 'object' | 'array' | 'simple'; @@ -17,6 +29,8 @@ export interface ExtendedCompletionItem extends CompletionItem { } export class CompletionFormatter { + // In CompletionFormatter class + private static readonly log = LoggerFactory.getLogger(CompletionFormatter); private static instance: CompletionFormatter; @@ -38,12 +52,18 @@ export class CompletionFormatter { return `{INDENT${numberOfIndents}}`; } - format(completions: CompletionList, context: Context, editorSettings: EditorSettings): CompletionList { + format( + completions: CompletionList, + context: Context, + editorSettings: EditorSettings, + lineContent?: string, + schemaRetriever?: SchemaRetriever, + ): CompletionList { try { const documentType = context.documentType; - - const formattedItems = completions.items.map((item) => this.formatItem(item, documentType, editorSettings)); - + const formattedItems = completions.items.map((item) => + this.formatItem(item, documentType, editorSettings, context, lineContent, schemaRetriever), + ); return { ...completions, items: formattedItems, @@ -58,6 +78,9 @@ export class CompletionFormatter { item: CompletionItem, documentType: DocumentType, editorSettings: EditorSettings, + context: Context, + lineContent?: string, + schemaRetriever?: SchemaRetriever, ): CompletionItem { const formattedItem = { ...item }; @@ -66,10 +89,28 @@ export class CompletionFormatter { return formattedItem; } + // Set filterText for ALL items (including snippets) when in JSON with quotes + const isInJsonString = documentType === DocumentType.JSON && context.syntaxNode.type === 'string'; + if (isInJsonString) { + formattedItem.filterText = `"${context.text}"`; + } + const textToFormat = item.insertText ?? item.label; if (documentType === DocumentType.JSON) { - formattedItem.insertText = this.formatForJson(textToFormat); + const result = this.formatForJson( + editorSettings, + textToFormat, + item, + context, + lineContent, + schemaRetriever, + ); + formattedItem.textEdit = TextEdit.replace(result.range, result.text); + if (result.isSnippet) { + formattedItem.insertTextFormat = InsertTextFormat.Snippet; + } + delete formattedItem.insertText; } else { formattedItem.insertText = this.formatForYaml(textToFormat, item, editorSettings); } @@ -77,8 +118,169 @@ export class CompletionFormatter { return formattedItem; } - private formatForJson(label: string): string { - return label; + private formatForJson( + editorSettings: EditorSettings, + label: string, + item: CompletionItem, + context: Context, + lineContent?: string, + schemaRetriever?: SchemaRetriever, + ): { text: string; range: Range; isSnippet: boolean } { + const shouldFormat = context.syntaxNode.type === 'string' && !context.isValue() && lineContent; + + const itemData = item.data as CompletionItemData | undefined; + + let formatAsObject = itemData?.type === 'object'; + let formatAsArray = itemData?.type === 'array'; + let formatAsString = false; + + if (this.isTopLevelSection(label)) { + if (label === String(TopLevelSection.Description)) { + formatAsString = true; + } else { + formatAsObject = true; + } + } + // If type is not in item.data and we have schemaRetriever, look it up from schema + if ((!itemData?.type || itemData?.type === 'simple') && schemaRetriever && context.entity) { + const propertyType = this.getPropertyTypeFromSchema(schemaRetriever, context, label); + + switch (propertyType) { + case 'object': { + formatAsObject = true; + break; + } + case 'array': { + formatAsArray = true; + + break; + } + case 'string': { + formatAsString = true; + + break; + } + // No default + } + } + + const indentation = ' '.repeat(context.startPosition.column); + const indentString = getIndentationString(editorSettings, DocumentType.JSON); + + let replacementText = `${indentation}"${label}":`; + let isSnippet = false; + + if (shouldFormat) { + isSnippet = true; + if (formatAsObject) { + replacementText = `${indentation}"${label}": {\n${indentation}${indentString}$0\n${indentation}}`; + } else if (formatAsArray) { + replacementText = `${indentation}"${label}": [\n${indentation}${indentString}$0\n${indentation}]`; + } else if (formatAsString) { + replacementText = `${indentation}"${label}": "$0"`; + } + } + + const range = Range.create( + Position.create(context.startPosition.row, 0), + Position.create(context.endPosition.row, context.endPosition.column + 1), + ); + + return { + text: replacementText, + range: range, + isSnippet: isSnippet, + }; + } + + /** + * Get the type of a property from the CloudFormation schema + * @param schemaRetriever - SchemaRetriever instance to get schemas + * @param context - Current context with entity and property path information + * @param propertyName - Name of the property to look up + * @returns The first type found in the schema ('object', 'array', 'string', etc.) or undefined + */ + private getPropertyTypeFromSchema( + schemaRetriever: SchemaRetriever, + context: Context, + propertyName: string, + ): string | undefined { + let resourceSchema; + + if (ResourceAttributesSet.has(propertyName)) { + return RESOURCE_ATTRIBUTE_TYPES[propertyName]; + } + + const entity = context.entity; + if (!entity || context.getEntityType() !== EntityType.Resource) { + return undefined; + } + + const resourceType = (entity as Resource).Type; + if (!resourceType) { + return undefined; + } + + try { + const combinedSchemas = schemaRetriever.getDefault(); + + resourceSchema = combinedSchemas.schemas.get(resourceType); + if (!resourceSchema) { + return undefined; + } + } catch { + return undefined; + } + + const propertiesIndex = context.propertyPath.indexOf('Properties'); + let propertyPath: string[]; + + if (propertiesIndex === -1) { + propertyPath = [propertyName]; + } else { + const pathAfterProperties = context.propertyPath.slice(propertiesIndex + 1).map(String); + + if ( + pathAfterProperties.length > 0 && + pathAfterProperties[pathAfterProperties.length - 1] === context.text + ) { + propertyPath = [...pathAfterProperties.slice(0, -1), propertyName]; + } else if (pathAfterProperties[pathAfterProperties.length - 1] === propertyName) { + propertyPath = pathAfterProperties; + } else { + propertyPath = [...pathAfterProperties, propertyName]; + } + } + + // Build JSON pointer path using wildcard notation for array indices + // CloudFormation schemas use /properties/Tags/*/Key format for array item properties + const schemaPath = propertyPath.map((part) => (Number.isNaN(Number(part)) ? part : '*')); + const jsonPointerParts = ['properties', ...schemaPath]; + + const jsonPointerPath = '/' + jsonPointerParts.join('/'); + + try { + const propertyDefinitions = resourceSchema.resolveJsonPointerPath(jsonPointerPath); + + if (propertyDefinitions.length === 0) { + return undefined; + } + + const propertyDef = propertyDefinitions[0]; + + if (propertyDef && 'type' in propertyDef) { + const type = propertyDef.type; + if (Array.isArray(type)) { + return type[0]; + } else if (typeof type === 'string') { + return type; + } + } + + return undefined; + } catch { + return undefined; + } } private formatForYaml(label: string, item: CompletionItem | undefined, editorSettings: EditorSettings): string { diff --git a/src/autocomplete/CompletionRouter.ts b/src/autocomplete/CompletionRouter.ts index 758ac407..edac93ea 100644 --- a/src/autocomplete/CompletionRouter.ts +++ b/src/autocomplete/CompletionRouter.ts @@ -12,6 +12,7 @@ import { Entity, Output, Parameter } from '../context/semantic/Entity'; import { EntityType } from '../context/semantic/SemanticTypes'; import { DocumentType } from '../document/Document'; import { DocumentManager } from '../document/DocumentManager'; +import { SchemaRetriever } from '../schema/SchemaRetriever'; import { CfnExternal } from '../server/CfnExternal'; import { CfnInfraCore } from '../server/CfnInfraCore'; import { CfnLspProviders } from '../server/CfnLspProviders'; @@ -48,6 +49,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable { private readonly contextManager: ContextManager, private readonly completionProviderMap: Map, private readonly documentManager: DocumentManager, + private readonly schemaRetriever: SchemaRetriever, private readonly entityFieldCompletionProviderMap = createEntityFieldProviders(), ) {} @@ -89,6 +91,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable { const completions = provider?.getCompletions(context, params) ?? []; const editorSettings = this.documentManager.getEditorSettingsForDocument(params.textDocument.uri); + const lineContent = this.documentManager.getLine(params.textDocument.uri, context.startPosition.row); if (completions instanceof Promise) { return await completions.then((result) => { @@ -99,6 +102,8 @@ export class CompletionRouter implements SettingsConfigurable, Closeable { }, context, editorSettings, + lineContent, + this.schemaRetriever, ); }); } else if (completions) { @@ -107,7 +112,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable { items: completions.slice(0, this.completionSettings.maxCompletions), }; - return this.formatter.format(completionList, context, editorSettings); + return this.formatter.format(completionList, context, editorSettings, lineContent, this.schemaRetriever); } return; } @@ -272,10 +277,13 @@ export class CompletionRouter implements SettingsConfigurable, Closeable { } static create(core: CfnInfraCore, external: CfnExternal, providers: CfnLspProviders) { + CompletionFormatter.getInstance(); return new CompletionRouter( core.contextManager, createCompletionProviders(core, external, providers), core.documentManager, + external.schemaRetriever, + createEntityFieldProviders(), ); } } diff --git a/src/autocomplete/CompletionUtils.ts b/src/autocomplete/CompletionUtils.ts index effaaf78..c0a71d4c 100644 --- a/src/autocomplete/CompletionUtils.ts +++ b/src/autocomplete/CompletionUtils.ts @@ -16,6 +16,18 @@ import { LoggerFactory } from '../telemetry/LoggerFactory'; import { ExtensionName } from '../utils/ExtensionConfig'; import { ExtendedCompletionItem } from './CompletionFormatter'; +// Resource attributes are not given a data.type upon completionItem creation and +// no schema for resource attributes. This is used for formatting completion items. +export const RESOURCE_ATTRIBUTE_TYPES: Record = { + DependsOn: 'string', // string | string[] - array of resource logical IDs + Condition: 'string', // Reference to a condition name + Metadata: 'object', // Arbitrary JSON/YAML object + CreationPolicy: 'object', // Configuration object with specific properties + DeletionPolicy: 'string', // Enum: "Delete" | "Retain" | "Snapshot" + UpdatePolicy: 'object', // Configuration object with update policies + UpdateReplacePolicy: 'string', // Enum: "Delete" | "Retain" | "Snapshot" +}; + /** * Creates a replacement range from a context's start and end positions. * This is used for text edits in completion items. diff --git a/src/autocomplete/TopLevelSectionCompletionProvider.ts b/src/autocomplete/TopLevelSectionCompletionProvider.ts index e39a15bf..d8ab4f92 100644 --- a/src/autocomplete/TopLevelSectionCompletionProvider.ts +++ b/src/autocomplete/TopLevelSectionCompletionProvider.ts @@ -1,6 +1,6 @@ import { CompletionItem, CompletionItemKind, CompletionParams, InsertTextFormat } from 'vscode-languageserver'; import { Context } from '../context/Context'; -import { TopLevelSection, TopLevelSections } from '../context/ContextType'; +import { TopLevelSection, TopLevelSections, TopLevelSectionsWithLogicalIdsSet } from '../context/ContextType'; import { SyntaxTreeManager } from '../context/syntaxtree/SyntaxTreeManager'; import { DocumentType } from '../document/Document'; import { DocumentManager } from '../document/DocumentManager'; @@ -119,7 +119,17 @@ ${CompletionFormatter.getIndentPlaceholder(1)}\${1:ConditionName}: $2`, return this.constantsFeatureFlag.isEnabled(); } return true; - }).map((section) => createCompletionItem(section, CompletionItemKind.Class)); + }).map((section) => { + const shouldBeObject = TopLevelSectionsWithLogicalIdsSet.has(section); + + const options = shouldBeObject + ? { + data: { type: 'object' }, + } + : undefined; + + return createCompletionItem(section, CompletionItemKind.Class, options); + }); } private getTopLevelSectionSnippetCompletions(context: Context, params: CompletionParams): CompletionItem[] { diff --git a/tst/unit/autocomplete/CompletionFormatter.test.ts b/tst/unit/autocomplete/CompletionFormatter.test.ts index 2dfd8c3b..2618f66b 100644 --- a/tst/unit/autocomplete/CompletionFormatter.test.ts +++ b/tst/unit/autocomplete/CompletionFormatter.test.ts @@ -1,10 +1,13 @@ -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CompletionItemKind, CompletionList } from 'vscode-languageserver'; import { CompletionFormatter } from '../../../src/autocomplete/CompletionFormatter'; import { ResourceAttribute, TopLevelSection } from '../../../src/context/ContextType'; import { DocumentType } from '../../../src/document/Document'; +import { CombinedSchemas } from '../../../src/schema/CombinedSchemas'; +import { ResourceSchema } from '../../../src/schema/ResourceSchema'; +import { SchemaRetriever } from '../../../src/schema/SchemaRetriever'; import { DefaultSettings } from '../../../src/settings/Settings'; -import { createTopLevelContext } from '../../utils/MockContext'; +import { createResourceContext, createTopLevelContext } from '../../utils/MockContext'; describe('CompletionFormatAdapter', () => { let formatter: CompletionFormatter; @@ -47,20 +50,22 @@ describe('CompletionFormatAdapter', () => { }); test('should adapt completions for JSON document type', () => { - const mockContext = createTopLevelContext('Unknown', { type: DocumentType.JSON }); + const mockContext = createTopLevelContext('Unknown', { type: DocumentType.JSON, nodeType: 'string' }); const result = formatter.format(mockCompletions, mockContext, defaultEditorSettings); expect(result).toBeDefined(); expect(result.items).toHaveLength(2); - expect(result.items[0].insertText).toBe('Resources'); - expect(result.items[1].insertText).toBe('AWS::EC2::Instance'); + // JSON uses textEdit instead of insertText + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].insertText).toBeUndefined(); + expect(result.items[1].textEdit).toBeDefined(); }); }); describe('individual item adaptation', () => { test('should adapt item for JSON document type', () => { - const mockContext = createTopLevelContext('Unknown', { type: DocumentType.JSON }); + const mockContext = createTopLevelContext('Unknown', { type: DocumentType.JSON, nodeType: 'string' }); const completions: CompletionList = { isIncomplete: false, items: [{ label: 'Resources', kind: CompletionItemKind.Property }], @@ -68,7 +73,9 @@ describe('CompletionFormatAdapter', () => { const result = formatter.format(completions, mockContext, defaultEditorSettings); - expect(result.items[0].insertText).toBe('Resources'); + // JSON uses textEdit instead of insertText + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].insertText).toBeUndefined(); }); test('should adapt item for YAML document type', () => { @@ -288,4 +295,386 @@ describe('CompletionFormatAdapter', () => { expect(result.items[2].insertText).toBe('ShouldCreateCache'); }); }); + + describe('JSON formatting with schema-based type lookup', () => { + let mockSchemaRetriever: SchemaRetriever; + let mockCombinedSchemas: CombinedSchemas; + let mockResourceSchema: ResourceSchema; + + beforeEach(() => { + mockResourceSchema = { + resolveJsonPointerPath: vi.fn((path: string) => { + // Simulate schema lookup for different properties + switch (path) { + case '/properties/BucketName': { + return [{ type: 'string' }]; + } + case '/properties/Tags': { + return [{ type: 'array' }]; + } + case '/properties/BucketEncryption': { + return [{ type: 'object' }]; + } + case '/properties/VersioningConfiguration': { + return [{ type: 'object' }]; + } + case '/properties/PublicAccessBlockConfiguration': { + return [{ type: 'object' }]; + } + // No default + } + return []; + }), + } as unknown as ResourceSchema; + + mockCombinedSchemas = { + schemas: new Map([['AWS::S3::Bucket', mockResourceSchema]]), + } as CombinedSchemas; + + mockSchemaRetriever = { + getDefault: vi.fn(() => mockCombinedSchemas), + } as unknown as SchemaRetriever; + }); + + test('should format object properties with braces in JSON', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'BucketEncryption', + propertyPath: ['Resources', 'MyBucket', 'Properties', 'BucketEncryption'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'BucketEncryption', + kind: CompletionItemKind.Property, + data: { type: 'simple' }, + }, + ], + }; + + const lineContent = ' "BucketEncryption"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].textEdit?.newText).toContain('"BucketEncryption": {'); + expect(result.items[0].textEdit?.newText).toContain('}'); + }); + + test('should format array properties with brackets in JSON', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'Tags', + propertyPath: ['Resources', 'MyBucket', 'Properties', 'Tags'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'Tags', + kind: CompletionItemKind.Property, + data: { type: 'simple' }, + }, + ], + }; + + const lineContent = ' "Tags"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].textEdit?.newText).toContain('"Tags": ['); + expect(result.items[0].textEdit?.newText).toContain(']'); + }); + + test('should format string properties with quotes in JSON', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'BucketName', + propertyPath: ['Resources', 'MyBucket', 'Properties', 'BucketName'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'BucketName', + kind: CompletionItemKind.Property, + data: { type: 'simple' }, + }, + ], + }; + + const lineContent = ' "BucketName"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].textEdit?.newText).toContain('"BucketName": "$0"'); + }); + + test('should use explicit data.type when provided', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'Properties', + propertyPath: ['Resources', 'MyBucket', 'Properties'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'Properties', + kind: CompletionItemKind.Property, + data: { type: 'object' }, + }, + ], + }; + + const lineContent = ' "Properties"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].textEdit?.newText).toContain('"Properties": {'); + // Should not call schema lookup since explicit type is provided + expect(mockResourceSchema.resolveJsonPointerPath).not.toHaveBeenCalled(); + }); + + test('should handle resource attributes with predefined types', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'Metadata', + propertyPath: ['Resources', 'MyBucket', 'Metadata'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'Metadata', + kind: CompletionItemKind.Property, + }, + ], + }; + + const lineContent = ' "Metadata"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].textEdit?.newText).toContain('"Metadata": {'); + expect(result.items[0].textEdit?.newText).toContain('}'); + }); + + test('should handle DependsOn as string type', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'DependsOn', + propertyPath: ['Resources', 'MyBucket'], + data: { Type: 'AWS::S3::Bucket' }, + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'DependsOn', + kind: CompletionItemKind.Property, + }, + ], + }; + + const lineContent = ' "DependsOn"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + // DependsOn is a string type, so should not format as object + expect(result.items[0].textEdit?.newText).toContain('"DependsOn":'); + expect(result.items[0].textEdit?.newText).not.toContain('{'); + }); + + test('should not format when lineContent is missing', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'BucketEncryption', + propertyPath: ['Resources', 'MyBucket', 'Properties'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'BucketEncryption', + kind: CompletionItemKind.Property, + }, + ], + }; + + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + undefined, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + // Without lineContent, shouldFormat is false, so it just adds the basic format with indentation + expect(result.items[0].textEdit?.newText).toContain('"BucketEncryption":'); + }); + + test('should handle missing schema gracefully', () => { + const mockContext = createResourceContext('MyResource', { + type: DocumentType.JSON, + text: 'SomeProperty', + propertyPath: ['Resources', 'MyResource', 'Properties'], + data: { Type: 'AWS::Unknown::Resource' }, + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'SomeProperty', + kind: CompletionItemKind.Property, + }, + ], + }; + + const lineContent = ' "SomeProperty"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + // Should not crash and should return basic formatting + expect(result.items[0].textEdit).toBeDefined(); + expect(result.items[0].textEdit?.newText).toContain('"SomeProperty":'); + }); + + test('should handle array type in schema', () => { + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'Tags', + propertyPath: ['Resources', 'MyBucket', 'Properties', 'Tags'], + data: { Type: 'AWS::S3::Bucket' }, + nodeType: 'string', + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'Tags', + kind: CompletionItemKind.Property, + }, + ], + }; + + const lineContent = ' "Tags"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + const newText = result.items[0].textEdit?.newText ?? ''; + expect(newText).toContain('"Tags": ['); + expect(newText).toContain(']'); + }); + + test('should preserve indentation in formatted output', () => { + // Create a mock context with custom startPosition + const mockContext = createResourceContext('MyBucket', { + type: DocumentType.JSON, + text: 'BucketEncryption', + propertyPath: ['Resources', 'MyBucket', 'Properties'], + data: { Type: 'AWS::S3::Bucket' }, + }); + + // Override the startPosition using Object.defineProperty to bypass readonly + Object.defineProperty(mockContext, 'startPosition', { + value: { row: 5, column: 8 }, + writable: false, + configurable: true, + }); + + const completions: CompletionList = { + isIncomplete: false, + items: [ + { + label: 'BucketEncryption', + kind: CompletionItemKind.Property, + }, + ], + }; + + const lineContent = ' "BucketEncryption"'; + const result = formatter.format( + completions, + mockContext, + defaultEditorSettings, + lineContent, + mockSchemaRetriever, + ); + + expect(result.items[0].textEdit).toBeDefined(); + const newText = result.items[0].textEdit?.newText ?? ''; + // Should maintain the 8-space indentation + expect(newText).toMatch(/^ {8}"/); + }); + }); }); diff --git a/tst/unit/autocomplete/CompletionRouter.test.ts b/tst/unit/autocomplete/CompletionRouter.test.ts index 6f475d30..23ff94a0 100644 --- a/tst/unit/autocomplete/CompletionRouter.test.ts +++ b/tst/unit/autocomplete/CompletionRouter.test.ts @@ -211,6 +211,7 @@ describe('CompletionRouter', () => { contextManager, completionProviderMap, mockDocumentManager, + mockComponents.schemaRetriever, entityFieldProviderMap, ); diff --git a/tst/utils/TemplateBuilder.ts b/tst/utils/TemplateBuilder.ts index 978f2519..e36b852c 100644 --- a/tst/utils/TemplateBuilder.ts +++ b/tst/utils/TemplateBuilder.ts @@ -136,7 +136,13 @@ export class TemplateBuilder { const completionProviders = createCompletionProviders(core, external, providers); - this.completionRouter = new CompletionRouter(this.contextManager, completionProviders, this.documentManager); + this.completionRouter = new CompletionRouter( + this.contextManager, + completionProviders, + this.documentManager, + this.schemaRetriever, + undefined, + ); this.hoverRouter = new HoverRouter(this.contextManager, this.schemaRetriever); this.initialize(startingContent); }