Skip to content

Commit 51440ba

Browse files
deepfuriyaDeep Furiya
andauthored
Fn::ForEach Autocomplete support (#194)
* autocomplete support for Fn::ForEach resource type * autocomplete support for Fn::ForEach resource properties --------- Co-authored-by: Deep Furiya <[email protected]>
1 parent eb215f8 commit 51440ba

9 files changed

+543
-39
lines changed

src/autocomplete/EntityFieldCompletionProvider.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ import { createCompletionItem } from './CompletionUtils';
99
/* eslint-disable no-restricted-syntax -- Entire class depends on Entity */
1010
export class EntityFieldCompletionProvider<T extends Entity> implements CompletionProvider {
1111
public getCompletions(context: Context, _: CompletionParams): CompletionItem[] {
12-
const entity = context.entity as T;
12+
// Extract the actual entity (handle both regular and ForEach resources)
13+
let entity;
14+
if (context.getEntityType() === EntityType.ForEachResource) {
15+
entity = context.getResourceEntity() as unknown as T;
16+
} else {
17+
entity = context.entity as T;
18+
}
19+
20+
if (!entity) {
21+
return [];
22+
}
1323

1424
const items = this.getFieldsAsCompletionItems(entity);
1525
if (context.text.length > 0) {

src/autocomplete/ResourceEntityCompletionProvider.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,24 @@ export class ResourceEntityCompletionProvider implements CompletionProvider {
2626
getCompletions(context: Context, params: CompletionParams): CompletionItem[] | undefined {
2727
const entityCompletions = this.entityFieldProvider.getCompletions(context, params);
2828

29+
// Extract the actual resource entity (handle both regular and ForEach resources)
30+
const resource = context.getResourceEntity();
31+
if (!resource) {
32+
return entityCompletions;
33+
}
34+
2935
// Enhance the "Properties" completion with a snippet
3036
if (entityCompletions) {
3137
const propertiesIndex = entityCompletions.findIndex((item) => item.label === 'Properties');
3238

33-
if (propertiesIndex !== -1) {
34-
const resource = context.entity as Resource;
35-
if (resource.Type) {
36-
const schema = this.schemaRetriever.getDefault().schemas.get(resource.Type);
37-
if (schema) {
38-
entityCompletions[propertiesIndex] = this.createPropertiesSnippetCompletion(
39-
schema,
40-
context,
41-
params,
42-
);
43-
}
39+
if (propertiesIndex !== -1 && resource.Type) {
40+
const schema = this.schemaRetriever.getDefault().schemas.get(resource.Type);
41+
if (schema) {
42+
entityCompletions[propertiesIndex] = this.createPropertiesSnippetCompletion(
43+
schema,
44+
context,
45+
params,
46+
);
4447
}
4548
}
4649
}

src/autocomplete/ResourcePropertyCompletionProvider.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { Context } from '../context/Context';
2323
import { ResourceAttribute, TopLevelSection, ResourceAttributesSet } from '../context/ContextType';
2424
import { Resource } from '../context/semantic/Entity';
25-
import { CfnValue } from '../context/semantic/SemanticTypes';
25+
import { CfnValue, EntityType } from '../context/semantic/SemanticTypes';
2626
import { NodeType } from '../context/syntaxtree/utils/NodeType';
2727
import { CommonNodeTypes } from '../context/syntaxtree/utils/TreeSitterTypes';
2828
import { propertyTypesToMarkdown } from '../hover/HoverFormatter';
@@ -66,7 +66,10 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
6666
* Uses robust schema resolution approach from hover provider
6767
*/
6868
private getPropertyCompletions(context: Context): PropertyCompletionsResult {
69-
const resource = context.entity as Resource;
69+
const resource = context.getResourceEntity();
70+
if (!resource) {
71+
return { completions: [], skipFuzzySearch: false };
72+
}
7073
let completions: CompletionItem[] = [];
7174
let skipFuzzySearch = false;
7275

@@ -138,7 +141,12 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
138141
}
139142

140143
private getSchemaPath(context: Context): string {
141-
let segments = context.propertyPath.slice(3);
144+
const propertiesIndex = context.propertyPath.indexOf('Properties');
145+
if (propertiesIndex === -1) {
146+
return '/properties';
147+
}
148+
149+
let segments = context.propertyPath.slice(propertiesIndex + 1);
142150

143151
// For key completions (except SYNTHETIC_KEY_OR_VALUE), remove last segment
144152
if (
@@ -271,11 +279,16 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
271279
const lastSegment = propertyPath[propertyPath.length - 1];
272280
const isArrayItemContext = typeof lastSegment === 'number' || lastSegment === '';
273281

274-
if (propertyPath.length > 3 && isArrayItemContext) {
275-
const entity = context.entity as Resource;
276-
if (entity?.Properties) {
277-
const pathSegments = propertyPath.slice(3); // Remove ['Resources', 'LogicalId', 'Properties']
278-
let current: Record<string, CfnValue> | CfnValue | undefined = entity.Properties;
282+
// Find the Properties index dynamically
283+
const startIndex = context.getEntityType() === EntityType.ForEachResource ? 4 : 2;
284+
const propertiesIndex = propertyPath.indexOf('Properties', startIndex);
285+
286+
if (propertiesIndex !== -1 && isArrayItemContext) {
287+
const resource = context.getResourceEntity();
288+
289+
if (resource?.Properties) {
290+
const pathSegments = propertyPath.slice(propertiesIndex + 1);
291+
let current: Record<string, CfnValue> | CfnValue | undefined = resource.Properties;
279292

280293
for (let i = 0; i < pathSegments.length - 1; i++) {
281294
if (current && typeof current === 'object' && pathSegments[i] in current) {

src/autocomplete/ResourceSectionCompletionProvider.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CompletionItem, CompletionParams, CompletionTriggerKind } from 'vscode-languageserver';
22
import { Context } from '../context/Context';
3-
import { ResourceAttributesSet, TopLevelSection } from '../context/ContextType';
4-
import { Resource } from '../context/semantic/Entity';
3+
import { ResourceAttributesSet } from '../context/ContextType';
4+
import { EntityType } from '../context/semantic/SemanticTypes';
55
import { CfnExternal } from '../server/CfnExternal';
66
import { CfnInfraCore } from '../server/CfnInfraCore';
77
import { CfnLspProviders } from '../server/CfnLspProviders';
@@ -39,25 +39,23 @@ export class ResourceSectionCompletionProvider implements CompletionProvider {
3939
return this.resourceProviders
4040
.get(ResourceCompletionType.Entity)
4141
?.getCompletions(context, params) as CompletionItem[];
42-
} else if (context.entitySection === 'Type') {
42+
} else if (context.entitySection === 'Type' || this.isAtResourceTypeField(context)) {
4343
return this.resourceProviders
4444
.get(ResourceCompletionType.Type)
4545
?.getCompletions(context, params) as CompletionItem[];
4646
} else if (
4747
context.entitySection === 'Properties' ||
4848
ResourceAttributesSet.has(context.entitySection as string) ||
49-
(context.matchPathWithLogicalId(TopLevelSection.Resources, 'Properties') && context.propertyPath.length > 3)
49+
this.isInPropertiesSection(context)
5050
) {
5151
const schemaPropertyCompletions = this.resourceProviders
5252
.get(ResourceCompletionType.Property)
5353
?.getCompletions(context, params) as CompletionItem[];
5454

55-
if (
56-
params.context?.triggerKind === CompletionTriggerKind.Invoked &&
57-
context.matchPathWithLogicalId(TopLevelSection.Resources, 'Properties')
58-
) {
59-
const resource = context.entity as Resource;
60-
if (resource.Type) {
55+
if (params.context?.triggerKind === CompletionTriggerKind.Invoked && this.isInPropertiesSection(context)) {
56+
const resource = context.getResourceEntity();
57+
58+
if (resource?.Type) {
6159
const stateCompletionPromise = this.resourceProviders
6260
.get(ResourceCompletionType.State)
6361
?.getCompletions(context, params) as Promise<CompletionItem[]>;
@@ -77,6 +75,22 @@ export class ResourceSectionCompletionProvider implements CompletionProvider {
7775
}
7876
return [];
7977
}
78+
79+
private isInPropertiesSection(context: Context): boolean {
80+
// Find 'Properties' starting after the resource structure
81+
const startIndex = context.getEntityType() === EntityType.ForEachResource ? 4 : 2;
82+
const propertiesIndex = context.propertyPath.indexOf('Properties', startIndex);
83+
return propertiesIndex !== -1 && context.propertyPath.length >= propertiesIndex + 1;
84+
}
85+
86+
private isAtResourceTypeField(context: Context): boolean {
87+
const propertyPathLength = context.getEntityType() === EntityType.ForEachResource ? 5 : 3;
88+
89+
return (
90+
context.propertyPath.length === propertyPathLength &&
91+
context.propertyPath[context.propertyPath.length - 1] === 'Type'
92+
);
93+
}
8094
}
8195

8296
export function createResourceCompletionProviders(

src/context/Context.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
TopLevelSectionsWithLogicalIdsSet,
1414
} from './ContextType';
1515
import { IntrinsicContext } from './IntrinsicContext';
16-
import { Entity } from './semantic/Entity';
16+
import { Entity, ForEachResource, Resource } from './semantic/Entity';
1717
import { entityTypeFromSection, nodeToEntity } from './semantic/EntityBuilder';
1818
import { normalizeIntrinsicFunction } from './semantic/Intrinsics';
1919
import { EntityType } from './semantic/SemanticTypes';
@@ -76,6 +76,18 @@ export class Context {
7676
return entityTypeFromSection(this.section, this.logicalId);
7777
}
7878

79+
public getResourceEntity(): Resource | undefined {
80+
const entityType = this.getEntityType();
81+
if (entityType === EntityType.Resource) {
82+
return this.entity as Resource;
83+
}
84+
if (entityType === EntityType.ForEachResource) {
85+
const forEachResource = this.entity as ForEachResource;
86+
return forEachResource.resource;
87+
}
88+
return undefined;
89+
}
90+
7991
public get intrinsicContext(): IntrinsicContext {
8092
this._intrinsicContext ??= this.telemetry.measure(
8193
'create.intrinsicContext',
@@ -260,19 +272,24 @@ export class Context {
260272
return false;
261273
}
262274

263-
// Case 1: If we are over 3 we know for sure we are beyond the entity level
264-
if (this.propertyPath.length > 3) {
275+
// Determine the entity key level based on entity type
276+
// Regular: ['Resources', 'LogicalId', 'Key'] - level 3
277+
// ForEachResource: ['Resources', 'Fn::ForEach::Name', 2, 'ResourceKey', 'Key'] - level 5
278+
const entityKeyLevel = this.getEntityType() === EntityType.ForEachResource ? 5 : 3;
279+
280+
// Case 1: If we are beyond the entity key level
281+
if (this.propertyPath.length > entityKeyLevel) {
265282
return false;
266283
}
267284

268285
// Case 2: Two situations exist that we need to account for:
269286
// isKey and isValue can be True when at the first key inside a value
270287
// when we are at level 2 this means we are at Entity/LogicalId as the first key
271-
// when we are at level 3 this means we are at Entity/LogicalId/Properties as the first key
288+
// when we are at level 3 (or 5 for ForEach) this means we are at Entity/LogicalId/Properties as the first key
272289
if (this.isKey() && this.isValue()) {
273290
if (this.propertyPath.length === 2) {
274291
return true;
275-
} else if (this.propertyPath.length === 3) {
292+
} else if (this.propertyPath.length === entityKeyLevel) {
276293
return false;
277294
}
278295
}

tst/unit/autocomplete/EntityFieldCompletionProvider.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest';
22
import { CompletionParams } from 'vscode-languageserver';
33
import { EntityFieldCompletionProvider } from '../../../src/autocomplete/EntityFieldCompletionProvider';
44
import { Output, Parameter } from '../../../src/context/semantic/Entity';
5-
import { createOutputContext, createParameterContext } from '../../utils/MockContext';
5+
import { createForEachResourceContext, createOutputContext, createParameterContext } from '../../utils/MockContext';
66

77
describe('EntityFieldCompletionProvider', () => {
88
const parameterFieldCompletionProvider = new EntityFieldCompletionProvider<Parameter>();
@@ -153,4 +153,92 @@ describe('EntityFieldCompletionProvider', () => {
153153
expect(result?.at(0)?.label).equal('Export');
154154
});
155155
});
156+
157+
describe('Fn::ForEach Resource', () => {
158+
const resourceFieldCompletionProvider = new EntityFieldCompletionProvider();
159+
160+
test('should suggest resource fields for ForEach resource', () => {
161+
const mockContext = createForEachResourceContext('Fn::ForEach::Buckets', 'S3Bucket${BucketName}', {
162+
text: '',
163+
propertyPath: ['Resources', 'Fn::ForEach::Buckets', 2, 'S3Bucket${BucketName}', ''],
164+
data: {
165+
Type: 'AWS::S3::Bucket',
166+
},
167+
});
168+
169+
const result = resourceFieldCompletionProvider.getCompletions(mockContext, mockParams);
170+
expect(result).toBeDefined();
171+
expect(result.length).toBeGreaterThan(0);
172+
173+
const resultLabels = result?.map((item) => item.label);
174+
expect(resultLabels).toContain('Properties');
175+
expect(resultLabels).toContain('DependsOn');
176+
expect(resultLabels).toContain('Metadata');
177+
});
178+
179+
test('should filter resource fields with partial text for ForEach resource', () => {
180+
const mockContext = createForEachResourceContext('Fn::ForEach::Instances', 'Instance${Name}', {
181+
text: 'Prop',
182+
propertyPath: ['Resources', 'Fn::ForEach::Instances', 2, 'Instance${Name}', 'Prop'],
183+
data: {
184+
Type: 'AWS::EC2::Instance',
185+
},
186+
});
187+
188+
const result = resourceFieldCompletionProvider.getCompletions(mockContext, mockParams);
189+
expect(result).toBeDefined();
190+
expect(result.length).toBeGreaterThan(0);
191+
192+
const propertiesItem = result?.find((item) => item.label === 'Properties');
193+
expect(propertiesItem).toBeDefined();
194+
});
195+
196+
test('should not suggest already defined fields for ForEach resource', () => {
197+
const mockContext = createForEachResourceContext('Fn::ForEach::Buckets', 'S3Bucket${BucketName}', {
198+
text: '',
199+
propertyPath: ['Resources', 'Fn::ForEach::Buckets', 2, 'S3Bucket${BucketName}', ''],
200+
data: {
201+
Type: 'AWS::S3::Bucket',
202+
Properties: {},
203+
DependsOn: 'SomeResource',
204+
},
205+
});
206+
207+
const result = resourceFieldCompletionProvider.getCompletions(mockContext, mockParams);
208+
expect(result).toBeDefined();
209+
210+
const resultLabels = result?.map((item) => item.label);
211+
expect(resultLabels).not.toContain('Type');
212+
expect(resultLabels).not.toContain('Properties');
213+
expect(resultLabels).not.toContain('DependsOn');
214+
});
215+
216+
test('should suggest all fields when ForEach resource has no fields defined', () => {
217+
const mockContext = createForEachResourceContext('Fn::ForEach::Buckets', 'S3Bucket${BucketName}', {
218+
text: '',
219+
propertyPath: ['Resources', 'Fn::ForEach::Buckets', 2, 'S3Bucket${BucketName}', ''],
220+
data: {},
221+
});
222+
223+
const result = resourceFieldCompletionProvider.getCompletions(mockContext, mockParams);
224+
expect(result).toBeDefined();
225+
expect(result.length).toBeGreaterThan(0);
226+
227+
const resultLabels = result?.map((item) => item.label);
228+
expect(resultLabels).toContain('Type');
229+
expect(resultLabels).toContain('Properties');
230+
});
231+
232+
test('should return empty when ForEach resource has no resource property', () => {
233+
const mockContext = createForEachResourceContext('Fn::ForEach::Buckets', 'S3Bucket${BucketName}', {
234+
text: '',
235+
propertyPath: ['Resources', 'Fn::ForEach::Buckets', 2, 'S3Bucket${BucketName}', ''],
236+
data: undefined,
237+
});
238+
239+
const result = resourceFieldCompletionProvider.getCompletions(mockContext, mockParams);
240+
expect(result).toBeDefined();
241+
expect(result.length).toBe(0);
242+
});
243+
});
156244
});

0 commit comments

Comments
 (0)