diff --git a/src/artifacts/resourceAttributes/CreationPolicyPropertyDocs.ts b/src/artifacts/resourceAttributes/CreationPolicyPropertyDocs.ts index 4cdd4d15..c09f8696 100644 --- a/src/artifacts/resourceAttributes/CreationPolicyPropertyDocs.ts +++ b/src/artifacts/resourceAttributes/CreationPolicyPropertyDocs.ts @@ -140,3 +140,30 @@ export function supportsAutoScalingCreationPolicy(resourceType: string): boolean export function supportsStartFleet(resourceType: string): boolean { return START_FLEET_SUPPORTED_RESOURCE_TYPES.includes(resourceType); } + +export interface CreationPolicyPropertySchema { + type: 'object' | 'simple'; + supportedResourceTypes?: ReadonlyArray; + properties?: Record; +} + +export const CREATION_POLICY_SCHEMA: Record = { + [CreationPolicyProperty.ResourceSignal]: { + type: 'object', + properties: { + [ResourceSignalProperty.Count]: { type: 'simple' }, + [ResourceSignalProperty.Timeout]: { type: 'simple' }, + }, + }, + [CreationPolicyProperty.AutoScalingCreationPolicy]: { + type: 'object', + supportedResourceTypes: AUTO_SCALING_CREATION_POLICY_SUPPORTED_RESOURCE_TYPES, + properties: { + [AutoScalingCreationPolicyProperty.MinSuccessfulInstancesPercent]: { type: 'simple' }, + }, + }, + [CreationPolicyProperty.StartFleet]: { + type: 'simple', + supportedResourceTypes: START_FLEET_SUPPORTED_RESOURCE_TYPES, + }, +}; diff --git a/src/autocomplete/ResourcePropertyCompletionProvider.ts b/src/autocomplete/ResourcePropertyCompletionProvider.ts index d7049558..53ab2dd1 100644 --- a/src/autocomplete/ResourcePropertyCompletionProvider.ts +++ b/src/autocomplete/ResourcePropertyCompletionProvider.ts @@ -1,5 +1,11 @@ import { CompletionItem, CompletionItemKind, CompletionParams } from 'vscode-languageserver'; +import { + supportsCreationPolicy, + CREATION_POLICY_SCHEMA, + CreationPolicyPropertySchema, +} from '../artifacts/resourceAttributes/CreationPolicyPropertyDocs'; import { Context } from '../context/Context'; +import { ResourceAttribute, TopLevelSection, ResourceAttributesSet } from '../context/ContextType'; import { Resource } from '../context/semantic/Entity'; import { CfnValue } from '../context/semantic/SemanticTypes'; import { NodeType } from '../context/syntaxtree/utils/NodeType'; @@ -41,6 +47,9 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { return []; } + if (context.isResourceAttributeProperty() || this.isAtResourceAttributeLevel(context)) { + return this.getResourceAttributePropertyCompletions(context, resource); + } const schema = this.schemaRetriever.getDefault().schemas.get(resource.Type); if (!schema) { return []; @@ -308,4 +317,152 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { const refProperty = schema.resolveRef(ref); return refProperty?.type === 'array'; } + + private isAtResourceAttributeLevel(context: Context): boolean { + if (context.section !== TopLevelSection.Resources || !context.hasLogicalId) { + return false; + } + + const lastSegment = context.propertyPath[context.propertyPath.length - 1]; + return ResourceAttributesSet.has(lastSegment as string); + } + + private getResourceAttributePropertyCompletions(context: Context, resource: Resource): CompletionItem[] { + const propertyPath = this.getResourceAttributePropertyPath(context); + + if (propertyPath.length === 0 || !resource.Type) { + return []; + } + + const attributeType = propertyPath[0] as ResourceAttribute; + const existingProperties = this.getExistingProperties(context); + + switch (attributeType) { + case ResourceAttribute.CreationPolicy: { + return this.getCreationPolicyCompletions(propertyPath, resource.Type, context, existingProperties); + } + //TODO: add other resource attributes + default: { + return []; + } + } + } + + private getResourceAttributePropertyPath(context: Context): ReadonlyArray { + let propertyPath = context.getResourceAttributePropertyPath(); + + if (propertyPath.length === 0 && this.isAtResourceAttributeLevel(context)) { + const lastSegment = context.propertyPath[context.propertyPath.length - 1]; + if (ResourceAttributesSet.has(lastSegment as string)) { + propertyPath = [lastSegment as string]; + } + } + + if (context.isKey() && propertyPath.length > 1) { + const lastSegment = propertyPath[propertyPath.length - 1]; + + if (lastSegment === context.text && context.text !== '') { + propertyPath = propertyPath.slice(0, -1); + } + } + + return propertyPath; + } + private getCreationPolicyCompletions( + propertyPath: ReadonlyArray, + resourceType: string, + context: Context, + existingProperties: Set, + ): CompletionItem[] { + if (!supportsCreationPolicy(resourceType)) { + return []; + } + + return this.getSchemaBasedCompletions( + CREATION_POLICY_SCHEMA, + propertyPath, + resourceType, + context, + existingProperties, + ); + } + + private getSchemaBasedCompletions( + schema: Record, + propertyPath: ReadonlyArray, + resourceType: string, + context: Context, + existingProperties: Set, + ): CompletionItem[] { + const completions: CompletionItem[] = []; + const filteredPath = propertyPath.filter((segment) => segment !== ''); + const depth = filteredPath.length; + + if (!context.isKey()) { + return completions; + } + + // Root level + if (depth === 1) { + for (const [propertyName, propertySchema] of Object.entries(schema)) { + if (existingProperties.has(propertyName)) { + continue; + } + + if ( + propertySchema.supportedResourceTypes && + !propertySchema.supportedResourceTypes.includes(resourceType) + ) { + continue; + } + + completions.push( + createCompletionItem(propertyName, CompletionItemKind.Property, { + data: { type: propertySchema.type }, + context: context, + }), + ); + } + } + // Nested levels + else if (depth >= 2) { + const parentPropertyName = filteredPath[1]; + const parentSchema = schema[parentPropertyName]; + + if (parentSchema?.properties) { + if ( + parentSchema.supportedResourceTypes && + !parentSchema.supportedResourceTypes.includes(resourceType) + ) { + return completions; + } + + let currentSchema = parentSchema.properties; + for (let i = 2; i < depth - 1; i++) { + const segmentName = filteredPath[i]; + const segmentSchema = currentSchema[segmentName]; + if (segmentSchema?.properties) { + currentSchema = segmentSchema.properties; + } else { + return completions; + } + } + + for (const [propertyName, propertySchema] of Object.entries(currentSchema)) { + if (existingProperties.has(propertyName)) { + continue; + } + + completions.push( + createCompletionItem(propertyName, CompletionItemKind.Property, { + data: { type: propertySchema.type }, + context: context, + }), + ); + } + } + } + + return completions; + } } diff --git a/src/autocomplete/ResourceSectionCompletionProvider.ts b/src/autocomplete/ResourceSectionCompletionProvider.ts index 432c5757..7aec1217 100644 --- a/src/autocomplete/ResourceSectionCompletionProvider.ts +++ b/src/autocomplete/ResourceSectionCompletionProvider.ts @@ -1,5 +1,6 @@ import { CompletionItem, CompletionParams, CompletionTriggerKind } from 'vscode-languageserver'; import { Context } from '../context/Context'; +import { ResourceAttributesSet, TopLevelSection } from '../context/ContextType'; import { Resource } from '../context/semantic/Entity'; import { CfnExternal } from '../server/CfnExternal'; import { CfnInfraCore } from '../server/CfnInfraCore'; @@ -36,6 +37,10 @@ export class ResourceSectionCompletionProvider implements CompletionProvider { { provider: 'Resource Completion', position: params.position, + entitySection: context.entitySection, + propertyPath: context.propertyPath, + atEntityKeyLevel: context.atEntityKeyLevel(), + text: context.text, }, 'Processing resource completion request', ); @@ -48,12 +53,18 @@ export class ResourceSectionCompletionProvider implements CompletionProvider { return this.resourceProviders .get(ResourceCompletionType.Type) ?.getCompletions(context, params) as CompletionItem[]; - } else if (context.entitySection === 'Properties') { + } else if ( + context.entitySection === 'Properties' || + ResourceAttributesSet.has(context.entitySection as string) + ) { const schemaPropertyCompletions = this.resourceProviders .get(ResourceCompletionType.Property) ?.getCompletions(context, params) as CompletionItem[]; - if (params.context?.triggerKind === CompletionTriggerKind.Invoked && context.propertyPath.length === 3) { + if ( + params.context?.triggerKind === CompletionTriggerKind.Invoked && + context.matchPathWithLogicalId(TopLevelSection.Resources, 'Properties') + ) { const resource = context.entity as Resource; if (resource.Type) { const stateCompletionPromise = this.resourceProviders diff --git a/tst/e2e/autocomplete/Autocomplete.test.ts b/tst/e2e/autocomplete/Autocomplete.test.ts index 672d02f8..7c7a8a03 100644 --- a/tst/e2e/autocomplete/Autocomplete.test.ts +++ b/tst/e2e/autocomplete/Autocomplete.test.ts @@ -916,9 +916,41 @@ Resources: { action: 'type', content: `reationPolicy: - ResourceSignal: - Count: !Ref InstanceCount - Timeout: PT10M + R`, + position: { line: 258, character: 5 }, + description: 'Test CreationPolicy suggests ResourceSignal', + verification: { + position: { line: 259, character: 7 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['ResourceSignal']) + .build(), + }, + }, + { + action: 'type', + content: `esourceSignal: + C`, + position: { line: 259, character: 7 }, + description: 'Test CreationPolicy suggests Count', + verification: { + position: { line: 260, character: 9 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['Count']).build(), + }, + }, + { + action: 'type', + content: `ount: !Ref InstanceCount + T`, + position: { line: 260, character: 9 }, + description: 'Test CreationPolicy suggests Timeout', + verification: { + position: { line: 261, character: 9 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['Timeout']).build(), + }, + }, + { + action: 'type', + content: `imeout: PT10M # Test RDS with complex conditional properties Database: @@ -929,7 +961,7 @@ Resources: Engine: mysql EngineVersion: "8.0" AllocatedStorage: !If [Is]`, - position: { line: 258, character: 5 }, + position: { line: 261, character: 9 }, description: 'Suggest condition in first argument of !If', verification: { position: { line: 271, character: 31 }, diff --git a/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts b/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts index 927131aa..bb6e2b20 100644 --- a/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts +++ b/tst/unit/autocomplete/ResourcePropertyCompletionProvider.test.ts @@ -1077,4 +1077,99 @@ describe('ResourcePropertyCompletionProvider', () => { const keyItem = result?.find((item) => item.label === 'Key'); expect(keyItem).toBeDefined(); }); + + // Resource Attribute Property Completion Tests + describe('Resource Attribute Property Completions', () => { + test('should return CreationPolicy properties for supported resource type', () => { + const mockContext = createResourceContext('MyInstance', { + text: '', + propertyPath: ['Resources', 'MyInstance', 'CreationPolicy', ''], + data: { + Type: 'AWS::EC2::Instance', + CreationPolicy: {}, + }, + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBe(2); // ResourceSignal and AutoScalingCreationPolicy + + const resourceSignalItem = result!.find((item) => item.label === 'ResourceSignal'); + expect(resourceSignalItem).toBeDefined(); + expect(resourceSignalItem!.kind).toBe(CompletionItemKind.Property); + + const autoScalingItem = result!.find((item) => item.label === 'AutoScalingCreationPolicy'); + expect(autoScalingItem).toBeDefined(); + expect(autoScalingItem!.kind).toBe(CompletionItemKind.Property); + }); + + test('should return different CreationPolicy properties based on resource type', () => { + const mockContext = createResourceContext('MyFleet', { + text: '', + propertyPath: ['Resources', 'MyFleet', 'CreationPolicy', ''], + data: { + Type: 'AWS::AppStream::Fleet', + CreationPolicy: {}, + }, + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBe(2); // ResourceSignal and StartFleet + + const resourceSignalItem = result!.find((item) => item.label === 'ResourceSignal'); + expect(resourceSignalItem).toBeDefined(); + + const startFleetItem = result!.find((item) => item.label === 'StartFleet'); + expect(startFleetItem).toBeDefined(); + + // Should NOT include AutoScalingCreationPolicy for AppStream Fleet + const autoScalingItem = result!.find((item) => item.label === 'AutoScalingCreationPolicy'); + expect(autoScalingItem).toBeUndefined(); + }); + + test('should return nested properties for ResourceSignal', () => { + const mockContext = createResourceContext('MyInstance', { + text: '', + propertyPath: ['Resources', 'MyInstance', 'CreationPolicy', 'ResourceSignal', ''], + data: { + Type: 'AWS::EC2::Instance', + CreationPolicy: { + ResourceSignal: {}, + }, + }, + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBe(2); // Count and Timeout + + const countItem = result!.find((item) => item.label === 'Count'); + expect(countItem).toBeDefined(); + expect(countItem!.kind).toBe(CompletionItemKind.Property); + + const timeoutItem = result!.find((item) => item.label === 'Timeout'); + expect(timeoutItem).toBeDefined(); + expect(timeoutItem!.kind).toBe(CompletionItemKind.Property); + }); + + test('should return empty for unsupported resource type', () => { + const mockContext = createResourceContext('MyBucket', { + text: '', + propertyPath: ['Resources', 'MyBucket', 'CreationPolicy', ''], + data: { + Type: 'AWS::S3::Bucket', // S3 buckets don't support CreationPolicy + CreationPolicy: {}, + }, + }); + + const result = provider.getCompletions(mockContext, mockParams); + + expect(result).toBeDefined(); + expect(result!.length).toBe(0); + }); + }); }); diff --git a/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts index 1ceeb65b..d839b9d0 100644 --- a/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts +++ b/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts @@ -194,6 +194,33 @@ describe('ResourceSectionCompletionProvider', () => { spy.mockRestore(); }); + test('should delegate to property provider when entitySection is a resource attribute (CreationPolicy)', async () => { + const mockContext = createResourceContext('MyInstance', { + text: '', + propertyPath: ['Resources', 'MyInstance', 'CreationPolicy', ''], + data: { + Type: 'AWS::EC2::Instance', + CreationPolicy: {}, + }, + }); + + const mockSchemas = createMockResourceSchemas(); + setupMockSchemas(mockSchemas); + + const propertyProvider = resourceProviders.get('Property' as any)!; + const mockCompletions = [ + { label: 'ResourceSignal', kind: CompletionItemKind.Property }, + { label: 'AutoScalingCreationPolicy', kind: CompletionItemKind.Property }, + ]; + const spy = vi.spyOn(propertyProvider, 'getCompletions').mockReturnValue(mockCompletions); + + const result = await provider.getCompletions(mockContext, mockParams); + + expect(spy).toHaveBeenCalledWith(mockContext, mockParams); + expect(result).toEqual(mockCompletions); + spy.mockRestore(); + }); + test('should return empty array when no provider matches', async () => { const mockContext = createResourceContext('MyResource', { text: '',