Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/artifacts/resourceAttributes/CreationPolicyPropertyDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
properties?: Record<string, CreationPolicyPropertySchema>;
}

export const CREATION_POLICY_SCHEMA: Record<string, CreationPolicyPropertySchema> = {
[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,
},
};
157 changes: 157 additions & 0 deletions src/autocomplete/ResourcePropertyCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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<string> {
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<string>,
resourceType: string,
context: Context,
existingProperties: Set<string>,
): CompletionItem[] {
if (!supportsCreationPolicy(resourceType)) {
return [];
}

return this.getSchemaBasedCompletions(
CREATION_POLICY_SCHEMA,
propertyPath,
resourceType,
context,
existingProperties,
);
}

private getSchemaBasedCompletions(
schema: Record<string, CreationPolicyPropertySchema>,
propertyPath: ReadonlyArray<string>,
resourceType: string,
context: Context,
existingProperties: Set<string>,
): 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;
}
}
15 changes: 13 additions & 2 deletions src/autocomplete/ResourceSectionCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
);
Expand All @@ -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
Expand Down
40 changes: 36 additions & 4 deletions tst/e2e/autocomplete/Autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading
Loading