Skip to content

Commit 5e793e6

Browse files
authored
Fn::ForEach Hover Support (#159)
1 parent 182b2b2 commit 5e793e6

File tree

3 files changed

+120
-23
lines changed

3 files changed

+120
-23
lines changed

src/context/semantic/EntityBuilder.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,20 @@ export function createEntityFromObject(logicalId: string, entityObject: any, sec
6666
const collection = Array.isArray(entityObject) ? entityObject[1] : undefined;
6767
const outputMap = Array.isArray(entityObject) ? entityObject[2] : {};
6868
const [key, value]: [string, any] = Object.entries(outputMap ?? {})[0] || [undefined, {}];
69-
const resourceInsideForEach = new Resource(
70-
key,
71-
value?.Type,
72-
value?.Properties,
73-
value?.DependsOn,
74-
value?.Condition,
75-
value?.Metadata,
76-
value?.CreationPolicy,
77-
value?.DeletionPolicy,
78-
value?.UpdatePolicy,
79-
value?.UpdateReplacePolicy,
80-
);
69+
const resourceInsideForEach = key
70+
? new Resource(
71+
key,
72+
value?.Type,
73+
value?.Properties,
74+
value?.DependsOn,
75+
value?.Condition,
76+
value?.Metadata,
77+
value?.CreationPolicy,
78+
value?.DeletionPolicy,
79+
value?.UpdatePolicy,
80+
value?.UpdateReplacePolicy,
81+
)
82+
: undefined;
8183
return new ForEachResource(loopName, identifier, collection, resourceInsideForEach);
8284
}
8385
return new Resource(

src/hover/ResourceSectionHoverProvider.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { deletionPolicyValueDocsMap } from '../artifacts/resourceAttributes/Dele
44
import { updatePolicyPropertyDocsMap } from '../artifacts/resourceAttributes/UpdatePolicyPropertyDocs';
55
import { updateReplacePolicyValueDocsMap } from '../artifacts/resourceAttributes/UpdateReplacePolicyPropertyDocs-1';
66
import { Context } from '../context/Context';
7-
import { ResourceAttribute, TopLevelSection } from '../context/ContextType';
7+
import { ResourceAttribute } from '../context/ContextType';
88
import { Resource } from '../context/semantic/Entity';
9+
import { EntityType } from '../context/semantic/SemanticTypes';
910
import { ResourceSchema } from '../schema/ResourceSchema';
1011
import { SchemaRetriever } from '../schema/SchemaRetriever';
1112
import { Measure } from '../telemetry/TelemetryDecorator';
@@ -18,7 +19,10 @@ export class ResourceSectionHoverProvider implements HoverProvider {
1819

1920
@Measure({ name: 'getInformation' })
2021
getInformation(context: Context) {
21-
const resource = context.entity as Resource;
22+
const resource = context.getResourceEntity();
23+
if (!resource) {
24+
return;
25+
}
2226

2327
if (context.text === context.logicalId) {
2428
return formatResourceHover(resource);
@@ -44,11 +48,12 @@ export class ResourceSectionHoverProvider implements HoverProvider {
4448
if (context.isResourceAttribute && resource[context.text] !== undefined) {
4549
return this.getResourceAttributeDoc(context.text);
4650
}
47-
if (
48-
context.matchPathWithLogicalId(TopLevelSection.Resources, 'Properties') &&
49-
context.propertyPath.length >= 3
50-
) {
51-
return this.getPropertyDefinitionDoc(schema, context);
51+
52+
// Find 'Properties' starting after the resource structure
53+
const startIndex = context.getEntityType() === EntityType.ForEachResource ? 4 : 2;
54+
const propertiesIndex = context.propertyPath.indexOf('Properties', startIndex);
55+
if (propertiesIndex !== -1 && context.propertyPath.length >= propertiesIndex + 1) {
56+
return this.getPropertyDefinitionDoc(schema, context, propertiesIndex);
5257
}
5358
}
5459

@@ -75,14 +80,18 @@ export class ResourceSectionHoverProvider implements HoverProvider {
7580
return doc.join('\n');
7681
}
7782

78-
private getPropertyDefinitionDoc(schema: ResourceSchema, context: Context): string | undefined {
83+
private getPropertyDefinitionDoc(
84+
schema: ResourceSchema,
85+
context: Context,
86+
propertiesIndex: number,
87+
): string | undefined {
7988
if (!context.isKey()) {
8089
return undefined;
8190
}
8291

8392
// Extract the property path from the context, starting after "Properties"
84-
// Expected path: ['Resources', 'LogicalId', 'Properties', ...propertySegments]
85-
const propertyPathSegments = context.propertyPath.slice(3);
93+
// Expected path: ['Resources', 'LogicalId', 'Properties', ...propertySegments] OR ['Resources', 'Fn::ForEach::LogicalName', 2, 'S3Bucket${BucketName}', 'Properties', ...propertySegments]
94+
const propertyPathSegments = context.propertyPath.slice(propertiesIndex + 1);
8695

8796
// Convert template path to JSON Pointer path and resolve schema
8897
const jsonPointerPath = templatePathToJsonPointerPath(propertyPathSegments);

tst/unit/hover/ResourceSectionHoverProvider.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import {
1313
UpdatePolicyProperty,
1414
AutoScalingRollingUpdateProperty,
1515
} from '../../../src/context/ContextType';
16+
import { ForEachResource, Resource } from '../../../src/context/semantic/Entity';
1617
import { SyntaxTreeManager } from '../../../src/context/syntaxtree/SyntaxTreeManager';
1718
import { ResourceSectionHoverProvider } from '../../../src/hover/ResourceSectionHoverProvider';
1819
import { ResourceSchema } from '../../../src/schema/ResourceSchema';
19-
import { createResourceContext } from '../../utils/MockContext';
20+
import { createMockContext, createResourceContext } from '../../utils/MockContext';
2021
import { createMockSchemaRetriever } from '../../utils/MockServerComponents';
2122
import { combinedSchemas, combineSchema, Schemas } from '../../utils/SchemaUtils';
2223
import { docPosition, Templates } from '../../utils/TemplateUtils';
@@ -963,4 +964,89 @@ describe('ResourceSectionHoverProvider', () => {
963964
});
964965
});
965966
});
967+
968+
describe('ForEach Resource Hover', () => {
969+
function createForEachResourceContext(
970+
text: string,
971+
resourceType: string,
972+
properties?: Record<string, any>,
973+
propertyPath?: any[],
974+
): Context {
975+
const nestedResource = new Resource('S3Bucket${BucketName}', resourceType, properties);
976+
977+
const forEachEntity = new ForEachResource('Buckets', 'BucketName', { Ref: 'BucketNames' }, nestedResource);
978+
979+
return createMockContext(TopLevelSection.Resources, 'Fn::ForEach::Buckets', {
980+
text,
981+
entity: forEachEntity,
982+
propertyPath: propertyPath ?? ['Resources', 'Fn::ForEach::Buckets', 2, 'S3Bucket${BucketName}'],
983+
});
984+
}
985+
986+
it('should return documentation for resource type in ForEach', () => {
987+
const mockContext = createForEachResourceContext('AWS::S3::Bucket', 'AWS::S3::Bucket', undefined, [
988+
'Resources',
989+
'Fn::ForEach::Buckets',
990+
2,
991+
'S3Bucket${BucketName}',
992+
'Type',
993+
]);
994+
995+
const result = hoverProvider.getInformation(mockContext);
996+
997+
expect(result).toContain('### AWS::S3::Bucket');
998+
expect(result).toContain('The ``AWS::S3::Bucket`` resource creates an Amazon S3 bucket');
999+
});
1000+
1001+
it('should return property documentation for ForEach resource property', () => {
1002+
const properties = { BucketName: 'my-bucket' };
1003+
const mockContext = createForEachResourceContext('BucketName', 'AWS::S3::Bucket', properties, [
1004+
'Resources',
1005+
'Fn::ForEach::Buckets',
1006+
2,
1007+
'S3Bucket${BucketName}',
1008+
'Properties',
1009+
'BucketName',
1010+
]);
1011+
1012+
const result = hoverProvider.getInformation(mockContext);
1013+
1014+
expect(result).toContain('```typescript');
1015+
expect(result).toContain('string');
1016+
expect(result).toContain('A name for the bucket');
1017+
});
1018+
1019+
it('should return nested property documentation for ForEach resource', () => {
1020+
const properties = {
1021+
VersioningConfiguration: { Status: 'Enabled' },
1022+
};
1023+
const mockContext = createForEachResourceContext('Status', 'AWS::S3::Bucket', properties, [
1024+
'Resources',
1025+
'Fn::ForEach::Buckets',
1026+
2,
1027+
'S3Bucket${BucketName}',
1028+
'Properties',
1029+
'VersioningConfiguration',
1030+
'Status',
1031+
]);
1032+
1033+
const result = hoverProvider.getInformation(mockContext);
1034+
1035+
expect(result).toBeDefined();
1036+
expect(result).toContain('Status');
1037+
});
1038+
1039+
it('should return undefined when ForEach resource has no nested resource', () => {
1040+
const forEachEntity = new ForEachResource('Buckets', 'BucketName', { Ref: 'BucketNames' }, undefined);
1041+
1042+
const mockContext = createMockContext(TopLevelSection.Resources, 'Fn::ForEach::Buckets', {
1043+
text: 'AWS::S3::Bucket',
1044+
entity: forEachEntity,
1045+
});
1046+
1047+
const result = hoverProvider.getInformation(mockContext);
1048+
1049+
expect(result).toBeUndefined();
1050+
});
1051+
});
9661052
});

0 commit comments

Comments
 (0)