Skip to content

Commit ebd4029

Browse files
authored
added new ForEach resource entity for context (#146)
1 parent a0b8a9e commit ebd4029

7 files changed

Lines changed: 246 additions & 2 deletions

File tree

src/context/semantic/Entity.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,14 @@ export class Unknown extends Entity {
177177
super(EntityType.Unknown);
178178
}
179179
}
180+
181+
export class ForEachResource extends Entity {
182+
constructor(
183+
public readonly name: string,
184+
public readonly identifier?: string,
185+
public readonly collection?: CfnValue,
186+
public readonly resource?: Resource,
187+
) {
188+
super(EntityType.ForEachResource);
189+
}
190+
}

src/context/semantic/EntityBuilder.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,18 @@ import { TopLevelSection, TopLevelSectionsWithLogicalIdsSet } from '../ContextTy
77
import { nodeToObject, parseSyntheticNode } from '../syntaxtree/utils/NodeParse';
88
import { NodeType } from '../syntaxtree/utils/NodeType';
99
import { CommonNodeTypes } from '../syntaxtree/utils/TreeSitterTypes';
10-
import { Condition, Mapping, Metadata, Output, Parameter, Resource, Rule, Transform, Unknown } from './Entity';
10+
import {
11+
Condition,
12+
ForEachResource,
13+
Mapping,
14+
Metadata,
15+
Output,
16+
Parameter,
17+
Resource,
18+
Rule,
19+
Transform,
20+
Unknown,
21+
} from './Entity';
1122

1223
const log = LoggerFactory.getLogger('EntityBuilder');
1324

@@ -49,6 +60,26 @@ export function createEntityFromObject(logicalId: string, entityObject: any, sec
4960
return Parameter.from(logicalId, entityObject);
5061
}
5162
case TopLevelSection.Resources: {
63+
if (logicalId.startsWith('Fn::ForEach')) {
64+
const loopName = logicalId.replace('Fn::ForEach::', '');
65+
const identifier = Array.isArray(entityObject) ? entityObject[0] : undefined;
66+
const collection = Array.isArray(entityObject) ? entityObject[1] : undefined;
67+
const outputMap = Array.isArray(entityObject) ? entityObject[2] : {};
68+
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+
);
81+
return new ForEachResource(loopName, identifier, collection, resourceInsideForEach);
82+
}
5283
return new Resource(
5384
logicalId,
5485
entityObject?.Type,

src/context/semantic/SemanticTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export enum EntityType {
1010
Mapping = 'Mapping',
1111
Parameter = 'Parameter',
1212
Unknown = 'Unknown',
13+
ForEachResource = 'ForEachResource',
1314
}
1415

1516
type Ref = { Ref: string };
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"AWSTemplateFormatVersion": "2010-09-09",
3+
"Description": "Template with Fn::ForEach examples",
4+
"Transform": "AWS::LanguageExtensions",
5+
"Parameters": {
6+
"BucketNames": {
7+
"Type": "CommaDelimitedList",
8+
"Default": "bucket1,bucket2,bucket3",
9+
"Description": "List of bucket names for ForEach"
10+
}
11+
},
12+
"Resources": {
13+
"Fn::ForEach::Buckets": [
14+
"BucketName",
15+
{ "Ref": "BucketNames" },
16+
{
17+
"S3Bucket${BucketName}": {
18+
"Type": "AWS::S3::Bucket",
19+
"Properties": {
20+
"BucketName": { "Fn::Sub": "${BucketName}-${AWS::AccountId}-${AWS::Region}" },
21+
"VersioningConfiguration": {
22+
"Status": "Enabled"
23+
},
24+
"BucketEncryption": {
25+
"ServerSideEncryptionConfiguration": [
26+
{
27+
"ServerSideEncryptionByDefault": {
28+
"SSEAlgorithm": "AES256"
29+
}
30+
}
31+
]
32+
}
33+
}
34+
}
35+
}
36+
],
37+
"RegularResource": {
38+
"Type": "AWS::S3::Bucket",
39+
"Properties": {
40+
"BucketName": "regular-bucket"
41+
}
42+
}
43+
}
44+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
AWSTemplateFormatVersion: "2010-09-09"
2+
Description: "Template with Fn::ForEach examples"
3+
Transform: AWS::LanguageExtensions
4+
5+
Parameters:
6+
BucketNames:
7+
Type: CommaDelimitedList
8+
Default: "bucket1,bucket2,bucket3"
9+
Description: "List of bucket names for ForEach"
10+
11+
Resources:
12+
Fn::ForEach::Buckets:
13+
- BucketName
14+
- !Ref BucketNames
15+
- S3Bucket${BucketName}:
16+
Type: AWS::S3::Bucket
17+
Properties:
18+
BucketName: !Sub "${BucketName}-${AWS::AccountId}-${AWS::Region}"
19+
VersioningConfiguration:
20+
Status: Enabled
21+
BucketEncryption:
22+
ServerSideEncryptionConfiguration:
23+
- ServerSideEncryptionByDefault:
24+
SSEAlgorithm: AES256
25+
26+
RegularResource:
27+
Type: AWS::S3::Bucket
28+
Properties:
29+
BucketName: regular-bucket

tst/unit/context/Context.test.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22
import { Context } from '../../../src/context/Context';
33
import { ContextManager } from '../../../src/context/ContextManager';
44
import { TopLevelSection } from '../../../src/context/ContextType';
5-
import { Parameter, Resource, Condition, Mapping, Unknown } from '../../../src/context/semantic/Entity';
5+
import {
6+
Parameter,
7+
Resource,
8+
Condition,
9+
Mapping,
10+
Unknown,
11+
ForEachResource,
12+
} from '../../../src/context/semantic/Entity';
613
import { SyntaxTreeManager } from '../../../src/context/syntaxtree/SyntaxTreeManager';
714
import { docPosition, Templates } from '../../utils/TemplateUtils';
815

@@ -679,4 +686,111 @@ Resources:
679686
expect(context!.textInQuotes()).toBeUndefined();
680687
});
681688
});
689+
690+
describe('ForEachResource Entity Parsing', () => {
691+
const foreachYamlUri = Templates.foreach.yaml.fileName;
692+
const foreachJsonUri = Templates.foreach.json.fileName;
693+
const foreachYaml = Templates.foreach.yaml.contents;
694+
const foreachJson = Templates.foreach.json.contents;
695+
696+
beforeAll(() => {
697+
syntaxTreeManager.add(foreachYamlUri, foreachYaml);
698+
syntaxTreeManager.add(foreachJsonUri, foreachJson);
699+
});
700+
701+
describe('YAML ForEach', () => {
702+
it('should parse ForEach resource name', () => {
703+
const context = getContextAt(11, 4, foreachYamlUri); // Position at "Fn::ForEach::Buckets:"
704+
705+
expect(context).toBeDefined();
706+
expect(context!.section).toBe(TopLevelSection.Resources);
707+
expect(context!.logicalId).toBe('Fn::ForEach::Buckets');
708+
expect(context!.text).toBe('Fn::ForEach::Buckets');
709+
});
710+
711+
it('should create ForEachResource entity with correct properties', () => {
712+
const context = getContextAt(11, 4, foreachYamlUri); // Fn::ForEach::Buckets
713+
714+
expect(context).toBeDefined();
715+
const entity = context!.entity;
716+
expect(entity).toBeInstanceOf(ForEachResource);
717+
718+
const forEachResource = entity as ForEachResource;
719+
expect(forEachResource.name).toBe('Buckets');
720+
expect(forEachResource.identifier).toBe('BucketName');
721+
expect(forEachResource.collection).toBeDefined();
722+
expect(forEachResource.collection).toHaveProperty('!Ref', 'BucketNames');
723+
expect(forEachResource.resource).toBeInstanceOf(Resource);
724+
});
725+
726+
it('should parse nested resource in ForEach', () => {
727+
const context = getContextAt(11, 4, foreachYamlUri);
728+
729+
expect(context).toBeDefined();
730+
const entity = context!.entity as ForEachResource;
731+
const nestedResource = entity.resource;
732+
733+
expect(nestedResource).toBeInstanceOf(Resource);
734+
expect(nestedResource?.name).toBe('S3Bucket${BucketName}');
735+
expect(nestedResource?.Type).toBe('AWS::S3::Bucket');
736+
expect(nestedResource?.Properties).toBeDefined();
737+
expect(nestedResource?.Properties).toHaveProperty('BucketName');
738+
expect(nestedResource?.Properties).toHaveProperty('VersioningConfiguration');
739+
expect(nestedResource?.Properties?.VersioningConfiguration).toHaveProperty('Status', 'Enabled');
740+
expect(nestedResource?.Properties).toHaveProperty('BucketEncryption');
741+
});
742+
743+
it('should parse regular resource after ForEach', () => {
744+
const context = getContextAt(26, 4, foreachYamlUri); // Position at "RegularResource:"
745+
746+
expect(context).toBeDefined();
747+
expect(context!.section).toBe(TopLevelSection.Resources);
748+
expect(context!.logicalId).toBe('RegularResource');
749+
expect(context!.entity).toBeInstanceOf(Resource);
750+
expect(context!.entity).not.toBeInstanceOf(ForEachResource);
751+
});
752+
});
753+
754+
describe('JSON ForEach', () => {
755+
it('should parse ForEach resource name in JSON', () => {
756+
const context = getContextAt(12, 8, foreachJsonUri); // Position at "Fn::ForEach::Buckets"
757+
758+
expect(context).toBeDefined();
759+
expect(context!.section).toBe(TopLevelSection.Resources);
760+
expect(context!.logicalId).toBe('Fn::ForEach::Buckets');
761+
});
762+
763+
it('should create ForEachResource entity from JSON', () => {
764+
const context = getContextAt(12, 8, foreachJsonUri);
765+
766+
expect(context).toBeDefined();
767+
const entity = context!.entity;
768+
expect(entity).toBeInstanceOf(ForEachResource);
769+
770+
const forEachResource = entity as ForEachResource;
771+
expect(forEachResource.name).toBe('Buckets');
772+
expect(forEachResource.identifier).toBe('BucketName');
773+
expect(forEachResource.collection).toBeDefined();
774+
expect(forEachResource.collection).toHaveProperty('Ref', 'BucketNames');
775+
expect(forEachResource.resource).toBeInstanceOf(Resource);
776+
});
777+
778+
it('should parse nested resource in JSON ForEach', () => {
779+
const context = getContextAt(12, 8, foreachJsonUri);
780+
781+
expect(context).toBeDefined();
782+
const entity = context!.entity as ForEachResource;
783+
const nestedResource = entity.resource;
784+
785+
expect(nestedResource).toBeInstanceOf(Resource);
786+
expect(nestedResource?.name).toBe('S3Bucket${BucketName}');
787+
expect(nestedResource?.Type).toBe('AWS::S3::Bucket');
788+
expect(nestedResource?.Properties).toBeDefined();
789+
expect(nestedResource?.Properties).toHaveProperty('BucketName');
790+
expect(nestedResource?.Properties?.BucketName).toHaveProperty('Fn::Sub');
791+
expect(nestedResource?.Properties).toHaveProperty('VersioningConfiguration');
792+
expect(nestedResource?.Properties?.VersioningConfiguration).toHaveProperty('Status', 'Enabled');
793+
});
794+
});
795+
});
682796
});

tst/utils/TemplateUtils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ export const Templates: Record<string, Record<'json' | 'yaml', { fileName: strin
8989
},
9090
},
9191
},
92+
foreach: {
93+
json: {
94+
fileName: 'file://foreach_template.json',
95+
get contents() {
96+
return readFileSync(join(__dirname, '..', 'resources', 'templates', 'foreach_template.json'), 'utf8');
97+
},
98+
},
99+
yaml: {
100+
fileName: 'file://foreach_template.yaml',
101+
get contents() {
102+
return readFileSync(join(__dirname, '..', 'resources', 'templates', 'foreach_template.yaml'), 'utf8');
103+
},
104+
},
105+
},
92106
};
93107

94108
export function point(row: number, column: number): Point {

0 commit comments

Comments
 (0)