diff --git a/src/context/semantic/LogicalIdReferenceFinder.ts b/src/context/semantic/LogicalIdReferenceFinder.ts index 6bd4d343..81b83fc0 100644 --- a/src/context/semantic/LogicalIdReferenceFinder.ts +++ b/src/context/semantic/LogicalIdReferenceFinder.ts @@ -2,6 +2,7 @@ import { SyntaxNode } from 'tree-sitter'; import { DocumentType } from '../../document/Document'; import { PseudoParametersSet, ResourceAttributes } from '../ContextType'; +/* eslint-disable no-restricted-syntax, security/detect-unsafe-regex */ export function selectText(specificNode: SyntaxNode, fullEntitySearch: boolean, rootNode?: SyntaxNode): string { let text: string | undefined; if (fullEntitySearch) { @@ -35,44 +36,33 @@ export function referencedLogicalIds( } function findJsonIntrinsicReferences(text: string, logicalIds: Set): void { - // Single pass through text with combined regex for better performance - const refIndex = text.indexOf('"Ref"'); - const getAttIndex = text.indexOf('"Fn::GetAtt"'); - const findMapIndex = text.indexOf('"Fn::FindInMap"'); - const subIndex = text.indexOf('"Fn::Sub"'); - const ifIndex = text.indexOf('"Fn::If"'); - const conditionIndex = text.indexOf('"Condition"'); - const dependsIndex = text.indexOf('"DependsOn"'); - const subVarIndex = text.indexOf('${'); - - if (refIndex !== -1) { + // Early exit checks - only run regex if marker exists + if (text.includes('"Ref"')) { extractMatches(text, JsonRef, logicalIds); } - if (getAttIndex !== -1) { + if (text.includes('"Fn::GetAtt"')) { extractMatches(text, JsonGetAtt, logicalIds); + extractMatches(text, JsonGetAttString, logicalIds); } - if (findMapIndex !== -1) { + if (text.includes('"Fn::FindInMap"')) { extractMatches(text, JsonFindInMap, logicalIds); } - if (subIndex !== -1) { - let subMatch: RegExpExecArray | null; - while ((subMatch = JsonSub.exec(text)) !== null) { - const templateString = subMatch[1]; - extractMatches(templateString, JsonSubVariables, logicalIds); - } - } - if (ifIndex !== -1) { + if (text.includes('"Fn::If"')) { extractMatches(text, JsonIf, logicalIds); } - if (conditionIndex !== -1) { + if (text.includes('"Condition"')) { extractMatches(text, JsonCondition, logicalIds); } - if (subVarIndex !== -1) { - extractMatches(text, JsonSubVariables, logicalIds); - } - if (dependsIndex !== -1) { + if (text.includes('"DependsOn"')) { extractJsonDependsOnReferences(text, logicalIds); } + if (text.includes('"Fn::ValueOf"')) { + extractMatches(text, JsonValueOf, logicalIds); + } + // Extract all ${} variables in one pass - covers Fn::Sub and standalone + if (text.includes('${')) { + extractMatches(text, SubVariables, logicalIds); + } } function findYamlIntrinsicReferences(text: string, logicalIds: Set): void { @@ -86,44 +76,43 @@ function findYamlIntrinsicReferences(text: string, logicalIds: Set): voi if (text.includes('!FindInMap')) { extractMatches(text, YamlFindInMap, logicalIds); } - - // Extract template strings from !Sub and find variables within them - if (text.includes('!Sub')) { - let subMatch: RegExpExecArray | null; - while ((subMatch = YamlSub.exec(text)) !== null) { - const templateString = subMatch[1]; - extractMatches(templateString, YamlSubVariables, logicalIds); - } + if (text.includes('!If')) { + extractMatches(text, YamlIf, logicalIds); } - if (text.includes('Ref:')) { + if (text.includes('!Condition')) { + extractMatches(text, YamlConditionShort, logicalIds); + } + if (text.includes('Ref')) { extractMatches(text, YamlRefColon, logicalIds); } - if (text.includes('Fn::GetAtt:')) { + if (text.includes('Fn::GetAtt')) { extractMatches(text, YamlGetAttColon, logicalIds); + extractMatches(text, YamlGetAttColonString, logicalIds); } - if (text.includes('Fn::FindInMap:')) { + if (text.includes('Fn::FindInMap')) { extractMatches(text, YamlFindInMapColon, logicalIds); } - - // Extract template strings from Fn::Sub and find variables within them - if (text.includes('Fn::Sub:')) { - let subMatch: RegExpExecArray | null; - while ((subMatch = YamlSubColon.exec(text)) !== null) { - const templateString = subMatch[1]; - extractMatches(templateString, YamlSubVariables, logicalIds); - } + if (text.includes('Fn::If')) { + extractMatches(text, YamlIfColon, logicalIds); } - if (text.includes('Condition:')) { + if (text.includes('Condition')) { extractMatches(text, YamlCondition, logicalIds); } + if (text.includes('!ValueOf')) { + extractMatches(text, YamlValueOfShort, logicalIds); + } + if (text.includes('Fn::ValueOf')) { + extractMatches(text, YamlValueOf, logicalIds); + } + // Extract all ${} variables in one pass - covers !Sub, Fn::Sub:, and standalone if (text.includes('${')) { - extractMatches(text, YamlSubVariables, logicalIds); + extractMatches(text, SubVariables, logicalIds); } + // Handle YAML list items (for Fn::GetAtt list syntax, DependsOn lists, etc.) if (text.includes('- ')) { - extractMatches(text, YamlInlineListItem, logicalIds); + extractMatches(text, YamlListItem, logicalIds); } - - if (text.includes('DependsOn:')) { + if (text.includes('DependsOn')) { extractYamlDependsOnReferences(text, logicalIds); } } @@ -173,6 +162,7 @@ function extractYamlDependsOnReferences(text: string, logicalIds: Set): const CommonProperties = new Set( [ + 'AWS', 'Type', 'Properties', ...ResourceAttributes, @@ -203,38 +193,42 @@ const CommonProperties = new Set( // Pre-compiled for performance const JsonRef = /"Ref"\s*:\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches {"Ref": "LogicalId"} - references to parameters, resources, etc. const JsonGetAtt = /"Fn::GetAtt"\s*:\s*\[\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches {"Fn::GetAtt": ["LogicalId", "Attribute"]} - gets attributes from resources +const JsonGetAttString = /"Fn::GetAtt"\s*:\s*"([A-Za-z][A-Za-z0-9]*)\./g; // Matches {"Fn::GetAtt": "LogicalId.Attribute"} - string syntax const JsonFindInMap = /"Fn::FindInMap"\s*:\s*\[\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches {"Fn::FindInMap": ["MappingName", "Key1", "Key2"]} - lookups in mappings -const JsonSub = /"Fn::Sub"\s*:\s*"([^"]+)"/g; // Matches {"Fn::Sub": "template string"} - string substitution with variables const JsonIf = /"Fn::If"\s*:\s*\[\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches {"Fn::If": ["ConditionName", "TrueValue", "FalseValue"]} - conditional logic const JsonCondition = /"Condition"\s*:\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches "Condition": "ConditionName" - resource condition property -const JsonSubVariables = /\$\{([A-Za-z][A-Za-z0-9:]*)\}/g; // Matches ${LogicalId} or ${AWS::Region} - variables in Fn::Sub templates const JsonSingleDep = /"DependsOn"\s*:\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches "DependsOn": "LogicalId" - single resource dependency const JsonArrayDep = /"DependsOn"\s*:\s*\[([^\]]+)]/g; // Matches "DependsOn": ["Id1", "Id2"] - array of resource dependencies const JsonArrayItem = /"([A-Za-z][A-Za-z0-9]*)"/g; // Matches "LogicalId" within the DependsOn array +const JsonValueOf = /"Fn::ValueOf"\s*:\s*\[\s*"([A-Za-z][A-Za-z0-9]*)"/g; // Matches {"Fn::ValueOf": ["ParamName", "Attr"]} - gets parameter attribute const YamlRef = /!Ref\s+([A-Za-z][A-Za-z0-9]*)/g; // Matches !Ref LogicalId - YAML short form reference -const YamlGetAtt = /!GetAtt\s+([A-Za-z][A-Za-z0-9]*)/g; // Matches !GetAtt LogicalId.Attribute - YAML short form get attribute -const YamlGetAttArray = /!GetAtt\s+\[\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches !GetAtt [LogicalId, Attribute] - YAML short form get attribute with array syntax +const YamlGetAtt = /!GetAtt\s+['"]?([A-Za-z][A-Za-z0-9]*)/g; // Matches !GetAtt LogicalId.Attribute - YAML short form get attribute with optional quotes +const YamlGetAttArray = /!GetAtt\s+\[\s*['"]?([A-Za-z][A-Za-z0-9]*)['"]?/g; // Matches !GetAtt [LogicalId, Attribute] - YAML short form get attribute with array syntax const YamlFindInMap = /!FindInMap\s+\[\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches !FindInMap [MappingName, Key1, Key2] - YAML short form mapping lookup -const YamlSub = /!Sub\s+["']?([^"'\n]+)["']?/g; // Matches !Sub "template string" - YAML short form string substitution -const YamlRefColon = /Ref:\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches Ref: LogicalId - YAML long form reference -const YamlGetAttColon = /Fn::GetAtt:\s*\[\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches Fn::GetAtt: [LogicalId, Attribute] - YAML long form get attribute -const YamlFindInMapColon = /Fn::FindInMap:\s*\[\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches Fn::FindInMap: [MappingName, Key1, Key2] - YAML long form mapping lookup -const YamlSubColon = /Fn::Sub:\s*["']?([^"'\n]+)["']?/g; // Matches Fn::Sub: "template string" - YAML long form string substitution -const YamlCondition = /Condition:\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches Condition: ConditionName - resource condition property in YAML -const YamlSubVariables = /\$\{([A-Za-z][A-Za-z0-9:]*)\}/g; // Matches ${LogicalId} or ${AWS::Region} - variables in Fn::Sub templates -const YamlSingleDep = /DependsOn:\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches DependsOn: LogicalId - single resource dependency in YAML -const YamlInlineDeps = /DependsOn:\s*\[([^\]]+)]/g; // Matches DependsOn: [Id1, Id2] - inline array format in YAML +const YamlIf = /!If\s+\[\s*([A-Za-z][A-Za-z0-9]*)/g; // Matches !If [ConditionName, TrueValue, FalseValue] - YAML short form conditional +const YamlConditionShort = /!Condition\s+([A-Za-z][A-Za-z0-9]*)/g; // Matches !Condition ConditionName - YAML short form condition reference +const YamlRefColon = /(? 0 || this.type === DocumentType.JSON) { + // If we got a valid result with a non-empty property path, return it + if (result.propertyPath.length > 0) { return result; } // If normal traversal failed (likely due to malformed tree), try position-based fallback - return this.pathAndEntityYamlFallback(node); + if (this.type === DocumentType.YAML) { + return this.pathAndEntityYamlFallback(node); + } + + // JSON fallback for malformed documents + return this.pathAndEntityJsonFallback(node); } // Normal tree traversal approach for well-formed documents @@ -469,6 +478,48 @@ export abstract class SyntaxTree { const parent = current.parent; if (!parent) break; + // Handle YAML tags like !If, !Sub, !Ref, etc. + // This check must run independently (not in an else-if chain) because: + // When a YAML intrinsic function like !Sub is used, the tree structure varies: + // + // Array form (e.g., !Sub ['template', {var: value}]): + // block_mapping_pair (key: "BucketName") + // └── block_node (contains the tag) + // ├── tag ("!Sub") + // └── block_sequence (the function arguments) + // + // Simple form (e.g., !Sub 'template'): + // block_mapping_pair (key: "BucketName") + // └── flow_node (contains the tag) + // ├── tag ("!Sub") + // └── single_quote_scalar (the string value) + // + // When walking up from content inside the function, we reach the node with the tag. + // At this point, the parent is block_mapping_pair. If we used else-if, the pair condition + // would match first (adding "BucketName" to path) and skip the tag condition entirely. + // By checking the tag independently, we ensure both "Fn::Sub" AND "BucketName" are added. + // + // Skip when parent is ERROR node - tree is malformed, let fallback handle it. + const parentIsError = NodeType.isNodeType(parent, CommonNodeTypes.ERROR); + if ( + !parentIsError && + this.type === DocumentType.YAML && + (NodeType.isNodeType(current, YamlNodeTypes.BLOCK_NODE) || + NodeType.isNodeType(current, YamlNodeTypes.FLOW_NODE)) && + current.children?.length > 0 && + current.children.some((child) => NodeType.isNodeType(child, YamlNodeTypes.TAG)) + ) { + const tagNode = current.children.find((child) => NodeType.isNodeType(child, YamlNodeTypes.TAG)); + const tagText = tagNode?.text; + if (tagText) { + const normalizedTag = normalizeIntrinsicFunction(tagText); + if (IntrinsicsSet.has(normalizedTag)) { + propertyPath.push(normalizedTag); + } + } + entityPath.push(current); + } + // Handle key-value pairs (like "Parameters: {...}") if (NodeType.isPairNode(parent, this.type)) { // This is a key-value pair. Add the key to our semantic path. @@ -487,22 +538,6 @@ export abstract class SyntaxTree { } propertyPath.push(index); entityPath.push(current); - } else if ( - this.type === DocumentType.YAML && - NodeType.isNodeType(current, YamlNodeTypes.BLOCK_NODE) && - current.children?.length > 0 && - NodeType.isNodeType(current.children[0], YamlNodeTypes.TAG) - ) { - // Handle YAML tags like !If, !Ref, etc. - const tagNode = current.children[0]; - const tagText = tagNode.text; - if (tagText) { - const normalizedTag = normalizeIntrinsicFunction(tagText); - if (IntrinsicsSet.has(normalizedTag)) { - propertyPath.push(normalizedTag); - } - } - entityPath.push(current); } else if (NodeType.isNodeType(parent, JsonNodeTypes.ARRAY)) { // Handle JSON array items - calculate index by counting non-punctuation siblings const index = parent.namedChildren.findIndex((child) => child.id === current?.id); @@ -634,6 +669,181 @@ export abstract class SyntaxTree { return { path, propertyPath, entityRootNode }; } + /** + * Fallback for JSON documents when the tree has errors. + * Uses text-based parsing to infer context from the document structure. + */ + private pathAndEntityJsonFallback(node: SyntaxNode): PathAndEntity { + const path: SyntaxNode[] = [node]; + const propertyPath: (string | number)[] = []; + let entityRootNode: SyntaxNode | undefined; + + // For JSON, we need to parse the text content to find the context + // Walk up to find the ERROR node and extract context from it + let errorNode: SyntaxNode | null = node; + while (errorNode && !NodeType.isNodeType(errorNode, CommonNodeTypes.ERROR)) { + errorNode = errorNode.parent; + } + + if (!errorNode) { + return { path, propertyPath, entityRootNode }; + } + + const text = errorNode.text; + const nodeText = node.text; + + // Find the node's position in the error text + const nodeIndex = text.lastIndexOf(nodeText); + if (nodeIndex === -1) { + return { path, propertyPath, entityRootNode }; + } + + // Parse the JSON structure up to the node position + // Track the path using a stack-based approach + const pathStack: (string | number)[] = []; + let currentKey: string | undefined; + let inString = false; + let stringStart = -1; + const arrayIndexStack: number[] = []; + + for (let i = 0; i < nodeIndex; i++) { + const char = text[i]; + + if (inString) { + if (char === '"' && text[i - 1] !== '\\') { + inString = false; + const stringContent = text.slice(stringStart + 1, i); + // Check if this string is followed by a colon (making it a key) + let j = i + 1; + while (j < text.length && /\s/.test(text[j])) j++; + if (text[j] === ':') { + currentKey = stringContent; + } + } + } else { + switch (char) { + case '"': { + inString = true; + stringStart = i; + + break; + } + case '{': { + if (currentKey !== undefined) { + pathStack.push(currentKey); + currentKey = undefined; + } + arrayIndexStack.push(-1); // -1 indicates we're in an object, not array + + break; + } + case '[': { + if (currentKey !== undefined) { + pathStack.push(currentKey); + currentKey = undefined; + } + arrayIndexStack.push(0); // Start array index at 0 + + break; + } + case '}': { + if ( + pathStack.length > 0 && + arrayIndexStack.length > 0 && + arrayIndexStack[arrayIndexStack.length - 1] === -1 + ) { + pathStack.pop(); + } + arrayIndexStack.pop(); + currentKey = undefined; + + break; + } + case ']': { + if ( + pathStack.length > 0 && + arrayIndexStack.length > 0 && + arrayIndexStack[arrayIndexStack.length - 1] >= 0 + ) { + pathStack.pop(); + } + arrayIndexStack.pop(); + currentKey = undefined; + + break; + } + case ',': { + // Increment array index if we're in an array + if (arrayIndexStack.length > 0 && arrayIndexStack[arrayIndexStack.length - 1] >= 0) { + arrayIndexStack[arrayIndexStack.length - 1]++; + } + currentKey = undefined; + + break; + } + // No default + } + } + } + + // Add the current array index if we're in an array + if (arrayIndexStack.length > 0 && arrayIndexStack[arrayIndexStack.length - 1] >= 0) { + pathStack.push(arrayIndexStack[arrayIndexStack.length - 1]); + } + + // Add the current key if we have one + if (currentKey !== undefined) { + pathStack.push(currentKey); + } + + // Add the node's text if it looks like a key + const cleanNodeText = nodeText.replaceAll(/^"|"$/g, ''); + if (cleanNodeText && !pathStack.includes(cleanNodeText)) { + // Check if this is followed by a colon + const afterNode = text.slice(nodeIndex + nodeText.length).trim(); + if (afterNode.startsWith(':') || afterNode === '') { + pathStack.push(cleanNodeText); + } + } + + propertyPath.push(...pathStack); + + // Try to find entity root from the path + if (propertyPath.length >= 2 && propertyPath[0] === TopLevelSection.Resources) { + const resourceKey = propertyPath[1] as string; + // Try to extract the resource definition using a more robust regex + // eslint-disable-next-line security/detect-non-literal-regexp + const resourceStartPattern = new RegExp(`"${resourceKey}"\\s*:\\s*\\{`); + const resourceStartMatch = resourceStartPattern.exec(text); + if (resourceStartMatch) { + const startIdx = resourceStartMatch.index; + // Find the matching closing brace + let braceCount = 0; + let endIdx = startIdx; + for (let i = startIdx; i < text.length; i++) { + if (text[i] === '{') braceCount++; + else if (text[i] === '}') { + braceCount--; + if (braceCount === 0) { + endIdx = i + 1; + break; + } + } + } + if (endIdx > startIdx) { + entityRootNode = createSyntheticNode( + text.slice(startIdx, endIdx), + node.startPosition, + node.endPosition, + errorNode, + ); + } + } + } + + return { path, propertyPath, entityRootNode }; + } + /** * Finds a node by its CloudFormation path * @param pathSegments Array like ["Resources", "MyBucket", "Properties", "BucketName"] or ["Resources", "MyBucket", "Properties", 0] diff --git a/tst/integration/goto/Goto.test.ts b/tst/integration/goto/Goto.test.ts index 93931ac7..9916ed64 100644 --- a/tst/integration/goto/Goto.test.ts +++ b/tst/integration/goto/Goto.test.ts @@ -90,7 +90,6 @@ Metadata: position: { line: 51, character: 28 }, expectation: GotoExpectationBuilder.create() .expectDefinition('IsNotProduction') - .todo('Goto failing in !And') .expectDefinitionPosition({ line: 44, character: 2 }) .build(), }, diff --git a/tst/integration/hover/Hover.test.ts b/tst/integration/hover/Hover.test.ts index 0ec3126f..d1b21cb3 100644 --- a/tst/integration/hover/Hover.test.ts +++ b/tst/integration/hover/Hover.test.ts @@ -1118,7 +1118,6 @@ Resources:`, expectation: HoverExpectationBuilder.create() .expectStartsWith('**Condition:** HasMultipleAZs') .expectContainsText(['HasMultipleAZs', '!Not', '!Equals', '!Select']) - .todo(`Returns nothing`) .build(), }, }, diff --git a/tst/unit/context/semantic/IntrinsicsCoverage.test.ts b/tst/unit/context/semantic/IntrinsicsCoverage.test.ts new file mode 100644 index 00000000..125b194b --- /dev/null +++ b/tst/unit/context/semantic/IntrinsicsCoverage.test.ts @@ -0,0 +1,870 @@ +import { describe, it, expect } from 'vitest'; +import { referencedLogicalIds } from '../../../../src/context/semantic/LogicalIdReferenceFinder'; +import { DocumentType } from '../../../../src/document/Document'; + +describe('Intrinsic Function Coverage', () => { + describe('YAML', () => { + // Ref - references parameters, resources, pseudo-parameters + describe('Ref', () => { + it('!Ref short form', () => { + const result = referencedLogicalIds('!Ref MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('Ref: long form', () => { + const result = referencedLogicalIds('Ref: MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + + // Fn::GetAtt - gets attributes from resources + describe('Fn::GetAtt', () => { + it('!GetAtt dot notation', () => { + const result = referencedLogicalIds('!GetAtt MyResource.Arn', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('!GetAtt array notation', () => { + const result = referencedLogicalIds('!GetAtt [MyResource, Arn]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('Fn::GetAtt: array', () => { + const result = referencedLogicalIds('Fn::GetAtt: [MyResource, Arn]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('Fn::GetAtt: string', () => { + const result = referencedLogicalIds('Fn::GetAtt: MyResource.Arn', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('!GetAtt quoted dot notation', () => { + const result = referencedLogicalIds('!GetAtt "MyResource.Arn"', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('!GetAtt quoted array notation', () => { + const result = referencedLogicalIds('!GetAtt ["MyResource", "Arn"]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + + // Fn::Sub - substitutes variables in strings + describe('Fn::Sub', () => { + it('!Sub simple', () => { + const result = referencedLogicalIds('!Sub "${MyVar}-suffix"', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyVar'])); + }); + + it('!Sub with mapping', () => { + const result = referencedLogicalIds( + '!Sub ["${A}-${B}", {A: !Ref ResA, B: !Ref ResB}]', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['ResA', 'ResB'])); + }); + + it('Fn::Sub: long form', () => { + const result = referencedLogicalIds('Fn::Sub: "${MyVar}"', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyVar'])); + }); + + it('${Resource.Attr} syntax', () => { + const result = referencedLogicalIds('!Sub "${MyBucket.Arn}"', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyBucket'])); + }); + }); + + // Fn::FindInMap - looks up values in mappings + describe('Fn::FindInMap', () => { + it('!FindInMap', () => { + const result = referencedLogicalIds('!FindInMap [MyMapping, Key1, Key2]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyMapping'])); + }); + + it('Fn::FindInMap:', () => { + const result = referencedLogicalIds('Fn::FindInMap: [MyMapping, Key1, Key2]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyMapping'])); + }); + }); + + // Fn::If - conditional values + describe('Fn::If', () => { + it('!If', () => { + const result = referencedLogicalIds('!If [MyCondition, yes, no]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + + it('Fn::If:', () => { + const result = referencedLogicalIds('Fn::If: [MyCondition, yes, no]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + + it('Fn::If with space before colon', () => { + const result = referencedLogicalIds('Fn::If : [MyCondition, yes, no]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + // Condition - resource condition attribute + describe('Condition', () => { + it('!Condition', () => { + const result = referencedLogicalIds('!Condition MyCondition', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + + it('Condition:', () => { + const result = referencedLogicalIds('Condition: MyCondition', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + // DependsOn - resource dependencies + describe('DependsOn', () => { + it('DependsOn: single', () => { + const result = referencedLogicalIds('DependsOn: MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('DependsOn: inline array', () => { + const result = referencedLogicalIds('DependsOn: [Res1, Res2]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['Res1', 'Res2'])); + }); + + it('DependsOn: list', () => { + const result = referencedLogicalIds('DependsOn:\n - Res1\n - Res2', '', DocumentType.YAML); + expect(result).toEqual(new Set(['Res1', 'Res2'])); + }); + }); + + // Fn::Base64 - encodes to base64 (no direct refs, only nested) + describe('Fn::Base64', () => { + it('!Base64 with nested !Ref', () => { + const result = referencedLogicalIds('!Base64 !Ref MyParam', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyParam'])); + }); + }); + + // Fn::Cidr - generates CIDR blocks (no direct refs, only nested) + describe('Fn::Cidr', () => { + it('!Cidr with nested !GetAtt', () => { + const result = referencedLogicalIds('!Cidr [!GetAtt MyVpc.CidrBlock, 6, 8]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyVpc'])); + }); + }); + + // Fn::GetAZs - gets availability zones (no logical ID refs) + describe('Fn::GetAZs', () => { + it('!GetAZs with !Ref', () => { + const result = referencedLogicalIds('!GetAZs !Ref MyRegion', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyRegion'])); + }); + }); + + // Fn::ImportValue - imports from other stacks (no local refs) + describe('Fn::ImportValue', () => { + it('!ImportValue with !Sub', () => { + const result = referencedLogicalIds('!ImportValue !Sub "${MyStack}-VpcId"', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyStack'])); + }); + }); + + // Fn::Join - joins strings (no direct refs, only nested) + describe('Fn::Join', () => { + it('!Join with !Ref', () => { + const result = referencedLogicalIds('!Join ["-", [!Ref Prefix, !Ref Suffix]]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['Prefix', 'Suffix'])); + }); + }); + + // Fn::Length - gets length (no direct refs, only nested) + describe('Fn::Length', () => { + it('!Length with !Ref', () => { + const result = referencedLogicalIds('!Length !Ref MyList', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyList'])); + }); + }); + + // Fn::Select - selects from list (no direct refs, only nested) + describe('Fn::Select', () => { + it('!Select with !Ref', () => { + const result = referencedLogicalIds('!Select [0, !Ref MyList]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyList'])); + }); + }); + + // Fn::Split - splits string (no direct refs, only nested) + describe('Fn::Split', () => { + it('!Split with !Ref', () => { + const result = referencedLogicalIds('!Split [",", !Ref MyString]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyString'])); + }); + }); + + // Fn::ToJsonString - converts to JSON (no direct refs, only nested) + describe('Fn::ToJsonString', () => { + it('!ToJsonString with !Ref', () => { + const result = referencedLogicalIds('!ToJsonString {Key: !Ref MyValue}', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyValue'])); + }); + }); + + // Fn::Transform - invokes macros (no logical ID refs) + describe('Fn::Transform', () => { + it('!Transform with nested !Ref', () => { + const result = referencedLogicalIds( + '!Transform {Name: MyMacro, Parameters: {Param: !Ref MyParam}}', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['MyParam'])); + }); + }); + + // Fn::And - logical AND (no direct refs, only nested) + describe('Fn::And', () => { + it('!And with !Condition', () => { + const result = referencedLogicalIds('!And [!Condition Cond1, !Condition Cond2]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['Cond1', 'Cond2'])); + }); + }); + + // Fn::Equals - equality check (no direct refs, only nested) + describe('Fn::Equals', () => { + it('!Equals with !Ref', () => { + const result = referencedLogicalIds('!Equals [!Ref MyParam, "value"]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyParam'])); + }); + }); + + // Fn::Not - logical NOT (no direct refs, only nested) + describe('Fn::Not', () => { + it('!Not with !Condition', () => { + const result = referencedLogicalIds('!Not [!Condition MyCondition]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + // Fn::Or - logical OR (no direct refs, only nested) + describe('Fn::Or', () => { + it('!Or with !Condition', () => { + const result = referencedLogicalIds('!Or [!Condition Cond1, !Condition Cond2]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['Cond1', 'Cond2'])); + }); + }); + + // Fn::ForEach - loop construct + describe('Fn::ForEach', () => { + it('Fn::ForEach with !Sub', () => { + const result = referencedLogicalIds( + 'Fn::ForEach::Loop: [Id, [A, B], {"${Id}": {Prop: !Ref MyRef}}]', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['Id', 'MyRef'])); + }); + }); + + // Rules-only intrinsics + describe('Rules intrinsics', () => { + it('Fn::Contains with !Ref', () => { + const result = referencedLogicalIds('Fn::Contains: [[a, b], !Ref MyParam]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyParam'])); + }); + + it('Fn::EachMemberEquals with !Ref', () => { + const result = referencedLogicalIds( + 'Fn::EachMemberEquals: [!Ref MyList, "value"]', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['MyList'])); + }); + + it('Fn::EachMemberIn with !Ref', () => { + const result = referencedLogicalIds('Fn::EachMemberIn: [!Ref MyList, [a, b]]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyList'])); + }); + + it('Fn::ValueOf', () => { + const result = referencedLogicalIds('Fn::ValueOf: [MyParam, Attr]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyParam'])); + }); + + it('!ValueOf short form', () => { + const result = referencedLogicalIds('!ValueOf [MyParam, Attr]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyParam'])); + }); + + it('Fn::RefAll with nested !If', () => { + // Fn::RefAll takes a parameter type, but can be nested in conditions + const result = referencedLogicalIds( + '!If [UseVpc, Fn::RefAll: AWS::EC2::VPC::Id, !Ref DefaultVpc]', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['UseVpc', 'DefaultVpc'])); + }); + + it('Fn::ValueOfAll with nested !If', () => { + // Fn::ValueOfAll takes a parameter type, but can be nested in conditions + const result = referencedLogicalIds( + '!If [UseTags, Fn::ValueOfAll: [AWS::EC2::VPC::Id, Tags], !Ref DefaultTags]', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['UseTags', 'DefaultTags'])); + }); + + it('Fn::Implies with nested conditions', () => { + const result = referencedLogicalIds( + 'Fn::Implies: [!Condition Cond1, !Condition Cond2]', + '', + DocumentType.YAML, + ); + expect(result).toEqual(new Set(['Cond1', 'Cond2'])); + }); + }); + + // Multi-line YAML templates + describe('Multi-line templates', () => { + it('resource with nested properties', () => { + const text = `MyBucket: + Type: AWS::S3::Bucket + Condition: IsProduction + DependsOn: + - MyRole + - MyPolicy + Properties: + BucketName: !Ref BucketName + Tags: + - Key: Env + Value: !Ref Environment + - Key: Team + Value: !Ref Owner`; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual( + new Set(['IsProduction', 'MyRole', 'MyPolicy', 'BucketName', 'Environment', 'Owner']), + ); + }); + + it('conditions block', () => { + const text = `Conditions: + IsProd: !Equals [!Ref Env, production] + IsNotDev: !Not [!Condition IsDev] + IsUsEast: !And + - !Condition IsProd + - !Equals [!Ref Region, east] + HasFlag: !Or + - !Condition IsProd + - !Equals [!Ref Flag, yes]`; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set(['Env', 'IsDev', 'IsProd', 'Region', 'Flag'])); + }); + + it('outputs block', () => { + const text = `Outputs: + VpcId: + Value: !Ref MyVpc + Export: + Name: !Sub "\${StackName}-VpcId" + SubnetId: + Value: !GetAtt MySubnet.SubnetId + Condition: HasSubnet`; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyVpc', 'StackName', 'MySubnet', 'HasSubnet'])); + }); + + it('Fn::Sub with multi-line mapping', () => { + const text = `!Sub + - "arn:aws:s3:::\${BucketName}/*" + - BucketName: !Ref MyBucket + AccountId: !Ref AWS::AccountId`; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set(['BucketName', 'MyBucket'])); + }); + + it('nested Fn::If', () => { + const text = `Value: !If + - IsProduction + - !If + - HasBackup + - !Ref ProdBackupBucket + - !Ref ProdBucket + - !Ref DevBucket`; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual( + new Set(['IsProduction', 'HasBackup', 'ProdBackupBucket', 'ProdBucket', 'DevBucket']), + ); + }); + }); + }); + + describe('JSON', () => { + // Ref + describe('Ref', () => { + it('"Ref"', () => { + const result = referencedLogicalIds('{"Ref": "MyResource"}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + + // Fn::GetAtt + describe('Fn::GetAtt', () => { + it('"Fn::GetAtt" array', () => { + const result = referencedLogicalIds('{"Fn::GetAtt": ["MyResource", "Arn"]}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('"Fn::GetAtt" string', () => { + const result = referencedLogicalIds('{"Fn::GetAtt": "MyResource.Arn"}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + + // Fn::Sub + describe('Fn::Sub', () => { + it('"Fn::Sub" simple', () => { + const result = referencedLogicalIds('{"Fn::Sub": "${MyVar}"}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyVar'])); + }); + + it('"Fn::Sub" with mapping', () => { + const result = referencedLogicalIds( + '{"Fn::Sub": ["${A}", {"A": {"Ref": "ResA"}}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['ResA'])); + }); + }); + + // Fn::FindInMap + describe('Fn::FindInMap', () => { + it('"Fn::FindInMap"', () => { + const result = referencedLogicalIds( + '{"Fn::FindInMap": ["MyMapping", "K1", "K2"]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyMapping'])); + }); + }); + + // Fn::If + describe('Fn::If', () => { + it('"Fn::If"', () => { + const result = referencedLogicalIds('{"Fn::If": ["MyCondition", "yes", "no"]}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + // Condition + describe('Condition', () => { + it('"Condition"', () => { + const result = referencedLogicalIds('"Condition": "MyCondition"', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + // DependsOn + describe('DependsOn', () => { + it('"DependsOn" single', () => { + const result = referencedLogicalIds('"DependsOn": "MyResource"', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('"DependsOn" array', () => { + const result = referencedLogicalIds('"DependsOn": ["Res1", "Res2"]', '', DocumentType.JSON); + expect(result).toEqual(new Set(['Res1', 'Res2'])); + }); + }); + + // Fn::Base64 + describe('Fn::Base64', () => { + it('"Fn::Base64" with nested "Ref"', () => { + const result = referencedLogicalIds('{"Fn::Base64": {"Ref": "MyParam"}}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyParam'])); + }); + }); + + // Fn::Cidr + describe('Fn::Cidr', () => { + it('"Fn::Cidr" with nested "Fn::GetAtt"', () => { + const result = referencedLogicalIds( + '{"Fn::Cidr": [{"Fn::GetAtt": ["MyVpc", "CidrBlock"]}, 6, 8]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyVpc'])); + }); + }); + + // Fn::GetAZs + describe('Fn::GetAZs', () => { + it('"Fn::GetAZs" with "Ref"', () => { + const result = referencedLogicalIds('{"Fn::GetAZs": {"Ref": "MyRegion"}}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyRegion'])); + }); + }); + + // Fn::ImportValue + describe('Fn::ImportValue', () => { + it('"Fn::ImportValue" with "Fn::Sub"', () => { + const result = referencedLogicalIds( + '{"Fn::ImportValue": {"Fn::Sub": "${MyStack}-VpcId"}}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyStack'])); + }); + }); + + // Fn::Join + describe('Fn::Join', () => { + it('"Fn::Join" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::Join": ["-", [{"Ref": "Prefix"}, {"Ref": "Suffix"}]]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['Prefix', 'Suffix'])); + }); + }); + + // Fn::Length + describe('Fn::Length', () => { + it('"Fn::Length" with "Ref"', () => { + const result = referencedLogicalIds('{"Fn::Length": {"Ref": "MyList"}}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyList'])); + }); + }); + + // Fn::Select + describe('Fn::Select', () => { + it('"Fn::Select" with "Ref"', () => { + const result = referencedLogicalIds('{"Fn::Select": [0, {"Ref": "MyList"}]}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyList'])); + }); + }); + + // Fn::Split + describe('Fn::Split', () => { + it('"Fn::Split" with "Ref"', () => { + const result = referencedLogicalIds('{"Fn::Split": [",", {"Ref": "MyString"}]}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyString'])); + }); + }); + + // Fn::ToJsonString + describe('Fn::ToJsonString', () => { + it('"Fn::ToJsonString" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::ToJsonString": {"Key": {"Ref": "MyValue"}}}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyValue'])); + }); + }); + + // Fn::Transform + describe('Fn::Transform', () => { + it('"Fn::Transform" with nested "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::Transform": {"Name": "Macro", "Parameters": {"P": {"Ref": "MyParam"}}}}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyParam'])); + }); + }); + + // Fn::And + describe('Fn::And', () => { + it('"Fn::And" with "Condition"', () => { + const result = referencedLogicalIds( + '{"Fn::And": [{"Condition": "Cond1"}, {"Condition": "Cond2"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['Cond1', 'Cond2'])); + }); + }); + + // Fn::Equals + describe('Fn::Equals', () => { + it('"Fn::Equals" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::Equals": [{"Ref": "MyParam"}, "value"]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyParam'])); + }); + }); + + // Fn::Not + describe('Fn::Not', () => { + it('"Fn::Not" with "Condition"', () => { + const result = referencedLogicalIds( + '{"Fn::Not": [{"Condition": "MyCondition"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + // Fn::Or + describe('Fn::Or', () => { + it('"Fn::Or" with "Condition"', () => { + const result = referencedLogicalIds( + '{"Fn::Or": [{"Condition": "Cond1"}, {"Condition": "Cond2"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['Cond1', 'Cond2'])); + }); + }); + + // Fn::ForEach + describe('Fn::ForEach', () => { + it('"Fn::ForEach" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::ForEach::Loop": ["Id", ["A"], {"${Id}": {"Ref": "MyRef"}}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['Id', 'MyRef'])); + }); + }); + + // Rules intrinsics + describe('Rules intrinsics', () => { + it('"Fn::Contains" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::Contains": [["a", "b"], {"Ref": "MyParam"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyParam'])); + }); + + it('"Fn::EachMemberEquals" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::EachMemberEquals": [{"Ref": "MyList"}, "value"]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyList'])); + }); + + it('"Fn::EachMemberIn" with "Ref"', () => { + const result = referencedLogicalIds( + '{"Fn::EachMemberIn": [{"Ref": "MyList"}, ["a", "b"]]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['MyList'])); + }); + + it('"Fn::RefAll" with nested "Fn::If"', () => { + // Fn::RefAll takes a parameter type, but can be nested in conditions + const result = referencedLogicalIds( + '{"Fn::If": ["UseVpc", {"Fn::RefAll": "AWS::EC2::VPC::Id"}, {"Ref": "DefaultVpc"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['UseVpc', 'DefaultVpc'])); + }); + + it('"Fn::ValueOf"', () => { + const result = referencedLogicalIds('{"Fn::ValueOf": ["MyParam", "Attr"]}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyParam'])); + }); + + it('"Fn::ValueOfAll" with nested "Fn::If"', () => { + // Fn::ValueOfAll takes a parameter type, but can be nested in conditions + const result = referencedLogicalIds( + '{"Fn::If": ["UseTags", {"Fn::ValueOfAll": ["AWS::EC2::VPC::Id", "Tags"]}, {"Ref": "DefaultTags"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['UseTags', 'DefaultTags'])); + }); + + it('"Fn::Implies" with nested conditions', () => { + const result = referencedLogicalIds( + '{"Fn::Implies": [{"Condition": "Cond1"}, {"Condition": "Cond2"}]}', + '', + DocumentType.JSON, + ); + expect(result).toEqual(new Set(['Cond1', 'Cond2'])); + }); + }); + + // Multi-line JSON templates + describe('Multi-line templates', () => { + it('resource with nested properties', () => { + const text = `{ + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Condition": "IsProduction", + "DependsOn": ["MyRole", "MyPolicy"], + "Properties": { + "BucketName": {"Fn::Sub": "\${Environment}-bucket"}, + "Tags": [ + {"Key": "Environment", "Value": {"Ref": "Environment"}}, + {"Key": "Owner", "Value": {"Ref": "Owner"}} + ] + } + } +}`; + const result = referencedLogicalIds(text, '', DocumentType.JSON); + expect(result).toEqual(new Set(['IsProduction', 'MyRole', 'MyPolicy', 'Environment', 'Owner'])); + }); + + it('conditions block', () => { + const text = `{ + "Conditions": { + "IsProduction": {"Fn::Equals": [{"Ref": "Environment"}, "production"]}, + "IsNotDev": {"Fn::Not": [{"Condition": "IsDevelopment"}]}, + "IsUsEast": { + "Fn::And": [ + {"Condition": "IsProduction"}, + {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]} + ] + }, + "HasFeature": { + "Fn::Or": [ + {"Condition": "IsProduction"}, + {"Fn::Equals": [{"Ref": "EnableFeature"}, "true"]} + ] + } + } +}`; + const result = referencedLogicalIds(text, '', DocumentType.JSON); + expect(result).toEqual( + new Set(['Environment', 'IsDevelopment', 'IsProduction', 'Region', 'EnableFeature']), + ); + }); + + it('outputs block', () => { + const text = `{ + "Outputs": { + "VpcId": { + "Value": {"Ref": "MyVpc"}, + "Export": {"Name": {"Fn::Sub": "\${StackName}-VpcId"}} + }, + "SubnetId": { + "Value": {"Fn::GetAtt": ["MySubnet", "SubnetId"]}, + "Condition": "HasSubnet" + } + } +}`; + const result = referencedLogicalIds(text, '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyVpc', 'StackName', 'MySubnet', 'HasSubnet'])); + }); + + it('Fn::Sub with mapping', () => { + const text = `{ + "Fn::Sub": [ + "arn:aws:s3:::\${BucketName}/*", + { + "BucketName": {"Ref": "MyBucket"} + } + ] +}`; + const result = referencedLogicalIds(text, '', DocumentType.JSON); + expect(result).toEqual(new Set(['BucketName', 'MyBucket'])); + }); + + it('nested Fn::If', () => { + const text = `{ + "Value": { + "Fn::If": [ + "IsProduction", + {"Fn::If": ["HasBackup", {"Ref": "ProdBackupBucket"}, {"Ref": "ProdBucket"}]}, + {"Ref": "DevBucket"} + ] + } +}`; + const result = referencedLogicalIds(text, '', DocumentType.JSON); + expect(result).toEqual( + new Set(['IsProduction', 'HasBackup', 'ProdBackupBucket', 'ProdBucket', 'DevBucket']), + ); + }); + }); + }); + + describe('Quoted YAML keys', () => { + it('single-quoted Ref', () => { + const result = referencedLogicalIds("'Ref': MyResource", '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('double-quoted Ref', () => { + const result = referencedLogicalIds('"Ref": MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('single-quoted Condition', () => { + const result = referencedLogicalIds("'Condition': MyCondition", '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + + it('double-quoted Fn::If', () => { + const result = referencedLogicalIds('"Fn::If": [MyCondition, yes, no]', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + + it('single-quoted Fn::GetAtt', () => { + const result = referencedLogicalIds("'Fn::GetAtt': [MyResource, Arn]", '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('double-quoted DependsOn', () => { + const result = referencedLogicalIds('"DependsOn": MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + + describe('Whitespace handling', () => { + describe('YAML', () => { + it('space before colon', () => { + const result = referencedLogicalIds('Ref : MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('multiple spaces around colon', () => { + const result = referencedLogicalIds('Condition : MyCondition', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + + it('no space after colon', () => { + const result = referencedLogicalIds('Ref:MyResource', '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + + describe('JSON', () => { + it('space before colon', () => { + const result = referencedLogicalIds('{"Ref" : "MyResource"}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('multiple spaces around colon', () => { + const result = referencedLogicalIds('{"Ref" : "MyResource"}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('no space after colon', () => { + const result = referencedLogicalIds('{"Ref":"MyResource"}', '', DocumentType.JSON); + expect(result).toEqual(new Set(['MyResource'])); + }); + }); + }); +}); diff --git a/tst/unit/context/semantic/LogicalIdReferenceFinder.test.ts b/tst/unit/context/semantic/LogicalIdReferenceFinder.test.ts index 78ea0ace..085fdb59 100644 --- a/tst/unit/context/semantic/LogicalIdReferenceFinder.test.ts +++ b/tst/unit/context/semantic/LogicalIdReferenceFinder.test.ts @@ -1,6 +1,10 @@ import { SyntaxNode } from 'tree-sitter'; import { describe, it, expect } from 'vitest'; -import { referencedLogicalIds, selectText } from '../../../../src/context/semantic/LogicalIdReferenceFinder'; +import { + referencedLogicalIds, + selectText, + isLogicalIdCandidate, +} from '../../../../src/context/semantic/LogicalIdReferenceFinder'; import { DocumentType } from '../../../../src/document/Document'; const createMockSyntaxNode = (text: string) => @@ -80,7 +84,7 @@ describe('LogicalIdReferenceFinder', () => { }); }); - describe('findLogicalIds', () => { + describe('referencedLogicalIds', () => { describe('JSON format', () => { describe('Ref pattern', () => { it('should find single Ref reference', () => { @@ -94,12 +98,6 @@ describe('LogicalIdReferenceFinder', () => { const result = referencedLogicalIds(text, '', DocumentType.JSON); expect(result).toEqual(new Set(['Resource1', 'Resource2'])); }); - - it('should handle Ref with whitespace', () => { - const text = '{ "Ref" : "MyResource" }'; - const result = referencedLogicalIds(text, '', DocumentType.JSON); - expect(result).toEqual(new Set(['MyResource'])); - }); }); describe('Fn::GetAtt pattern', () => { @@ -108,12 +106,6 @@ describe('LogicalIdReferenceFinder', () => { const result = referencedLogicalIds(text, '', DocumentType.JSON); expect(result).toEqual(new Set(['MyResource'])); }); - - it('should handle GetAtt with whitespace', () => { - const text = '{ "Fn::GetAtt" : [ "MyResource" , "Arn" ] }'; - const result = referencedLogicalIds(text, '', DocumentType.JSON); - expect(result).toEqual(new Set(['MyResource'])); - }); }); describe('Fn::FindInMap pattern', () => { @@ -373,4 +365,132 @@ describe('LogicalIdReferenceFinder', () => { expect(result).toEqual(new Set(['BucketName', 'LogPrefix'])); }); }); + + describe('False positive prevention', () => { + it('should not match SomeRef as Ref', () => { + const text = 'SomeRef: MyValue'; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set()); + }); + + it('should not match MyCondition as Condition', () => { + const text = 'MyCondition: SomeValue'; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set()); + }); + + it('should not match PreDependsOn as DependsOn', () => { + const text = 'PreDependsOn: MyResource'; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set()); + }); + + it('should match standalone Ref', () => { + const text = 'Ref: MyResource'; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyResource'])); + }); + + it('should match Condition at start of line', () => { + const text = ' Condition: MyCondition'; + const result = referencedLogicalIds(text, '', DocumentType.YAML); + expect(result).toEqual(new Set(['MyCondition'])); + }); + }); + + describe('isLogicalIdCandidate', () => { + describe('valid logical IDs', () => { + it('should accept simple alphanumeric IDs', () => { + expect(isLogicalIdCandidate('MyResource')).toBe(true); + expect(isLogicalIdCandidate('MyBucket123')).toBe(true); + expect(isLogicalIdCandidate('Resource1')).toBe(true); + }); + + it('should accept IDs with dots', () => { + expect(isLogicalIdCandidate('My.Resource')).toBe(true); + expect(isLogicalIdCandidate('Resource.Arn')).toBe(true); + }); + + it('should accept IDs starting with uppercase or lowercase', () => { + expect(isLogicalIdCandidate('myResource')).toBe(true); + expect(isLogicalIdCandidate('MyResource')).toBe(true); + }); + }); + + describe('invalid inputs', () => { + it('should reject null and undefined', () => { + expect(isLogicalIdCandidate(null)).toBe(false); + expect(isLogicalIdCandidate(undefined)).toBe(false); + }); + + it('should reject non-string types', () => { + expect(isLogicalIdCandidate(123)).toBe(false); + expect(isLogicalIdCandidate({})).toBe(false); + expect(isLogicalIdCandidate([])).toBe(false); + expect(isLogicalIdCandidate(true)).toBe(false); + }); + + it('should reject empty string', () => { + expect(isLogicalIdCandidate('')).toBe(false); + }); + + it('should reject single character strings', () => { + expect(isLogicalIdCandidate('A')).toBe(false); + expect(isLogicalIdCandidate('x')).toBe(false); + }); + + it('should reject IDs starting with numbers', () => { + expect(isLogicalIdCandidate('123Resource')).toBe(false); + expect(isLogicalIdCandidate('1Bucket')).toBe(false); + }); + + it('should reject special characters', () => { + expect(isLogicalIdCandidate('-')).toBe(false); + expect(isLogicalIdCandidate('.')).toBe(false); + expect(isLogicalIdCandidate('_')).toBe(false); + }); + + it('should reject strings with substitution patterns', () => { + expect(isLogicalIdCandidate('${MyVar}')).toBe(false); + expect(isLogicalIdCandidate('prefix${Var}suffix')).toBe(false); + }); + }); + + describe('common properties exclusion', () => { + it('should reject CloudFormation common properties', () => { + expect(isLogicalIdCandidate('Type')).toBe(false); + expect(isLogicalIdCandidate('Properties')).toBe(false); + expect(isLogicalIdCandidate('Condition')).toBe(false); + expect(isLogicalIdCandidate('DependsOn')).toBe(false); + expect(isLogicalIdCandidate('Metadata')).toBe(false); + }); + + it('should reject common properties in different cases', () => { + expect(isLogicalIdCandidate('type')).toBe(false); + expect(isLogicalIdCandidate('TYPE')).toBe(false); + expect(isLogicalIdCandidate('properties')).toBe(false); + expect(isLogicalIdCandidate('PROPERTIES')).toBe(false); + }); + + it('should reject other common property names', () => { + expect(isLogicalIdCandidate('Description')).toBe(false); + expect(isLogicalIdCandidate('Value')).toBe(false); + expect(isLogicalIdCandidate('Export')).toBe(false); + expect(isLogicalIdCandidate('Name')).toBe(false); + }); + }); + + describe('pseudo parameters exclusion', () => { + it('should reject AWS pseudo parameters', () => { + expect(isLogicalIdCandidate('AWS::AccountId')).toBe(false); + expect(isLogicalIdCandidate('AWS::Region')).toBe(false); + expect(isLogicalIdCandidate('AWS::StackId')).toBe(false); + expect(isLogicalIdCandidate('AWS::StackName')).toBe(false); + expect(isLogicalIdCandidate('AWS::NotificationARNs')).toBe(false); + expect(isLogicalIdCandidate('AWS::NoValue')).toBe(false); + expect(isLogicalIdCandidate('AWS::Partition')).toBe(false); + expect(isLogicalIdCandidate('AWS::URLSuffix')).toBe(false); + }); + }); + }); }); diff --git a/tst/unit/context/syntaxtree/IntrinsicFunctionPath.test.ts b/tst/unit/context/syntaxtree/IntrinsicFunctionPath.test.ts index fd599134..7f84082c 100644 --- a/tst/unit/context/syntaxtree/IntrinsicFunctionPath.test.ts +++ b/tst/unit/context/syntaxtree/IntrinsicFunctionPath.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { JsonSyntaxTree } from '../../../../src/context/syntaxtree/JsonSyntaxTree'; import { YamlSyntaxTree } from '../../../../src/context/syntaxtree/YamlSyntaxTree'; describe('Intrinsic Function Path Preservation', () => { @@ -20,46 +21,17 @@ Resources: Image: alpine `; - const syntaxTree = new YamlSyntaxTree(yamlContent); - - // Find the position of "conditional-container" (inside the Fn::If) - const lines = yamlContent.split('\n'); - let targetLine = -1; - let targetCol = -1; - - for (const [i, line] of lines.entries()) { - if (line.includes('conditional-container')) { - targetLine = i; - targetCol = line.indexOf('conditional-container'); - break; - } - } - - expect(targetLine).toBeGreaterThan(-1); - - const position = { line: targetLine, character: targetCol }; - const node = syntaxTree.getNodeAtPosition(position); - - expect(node).toBeDefined(); - - const pathInfo = syntaxTree.getPathAndEntityInfo(node); - - // Check if the propertyPath contains Fn::If - const hasIntrinsic = pathInfo.propertyPath.some( - (segment) => typeof segment === 'string' && segment.startsWith('Fn::'), - ); - - expect(hasIntrinsic).toBe(true); - expect(pathInfo.propertyPath).toContain('Fn::If'); - - // The path should be something like: - // ['Resources', 'TestResource', 'Properties', 'ContainerDefinitions', 1, 'Fn::If', 1, 'Name'] - // instead of the broken: - // ['Resources', 'TestResource', 'Properties', 'ContainerDefinitions', 1, 1, 'Name'] - - const pathStr = JSON.stringify(pathInfo.propertyPath); - expect(pathStr).toContain('Fn::If'); - expect(pathStr).not.toMatch(/\[1,\s*1,\s*"Name"\]/); // Should not have consecutive numeric indices + const path = getYamlPath(yamlContent, 11, 18); + expect(path).toEqual([ + 'Resources', + 'TestResource', + 'Properties', + 'ContainerDefinitions', + 1, + 'Fn::If', + 1, + 'Name', + ]); }); it('should preserve nested Fn::If functions in complex structures', () => { @@ -84,40 +56,24 @@ Resources: Value: dev-value `; - const syntaxTree = new YamlSyntaxTree(yamlContent); - - // Find the position of "STAGING_VAR" (inside nested Fn::If) - const lines = yamlContent.split('\n'); - let targetLine = -1; - let targetCol = -1; - - for (const [i, line] of lines.entries()) { - if (line.includes('STAGING_VAR')) { - targetLine = i; - targetCol = line.indexOf('STAGING_VAR'); - break; - } - } - - expect(targetLine).toBeGreaterThan(-1); - - const position = { line: targetLine, character: targetCol }; - const node = syntaxTree.getNodeAtPosition(position); - - expect(node).toBeDefined(); - - const pathInfo = syntaxTree.getPathAndEntityInfo(node); - - // Should contain multiple Fn::If entries for nested conditions - const intrinsicCount = pathInfo.propertyPath.filter( - (segment) => typeof segment === 'string' && segment.startsWith('Fn::'), - ).length; - - expect(intrinsicCount).toBeGreaterThan(0); - expect(pathInfo.propertyPath).toContain('Fn::If'); + const path = getYamlPath(yamlContent, 15, 24); + expect(path).toEqual([ + 'Resources', + 'ComplexResource', + 'Properties', + 'ContainerDefinitions', + 0, + 'Environment', + 0, + 'Fn::If', + 2, + 'Fn::If', + 1, + 'Name', + ]); }); - it.todo('should handle other intrinsic functions like Fn::Sub', () => { + it('should handle other intrinsic functions like Fn::Sub', () => { const yamlContent = ` AWSTemplateFormatVersion: '2010-09-09' Resources: @@ -130,35 +86,344 @@ Resources: id: !Ref UniqueId `; - const syntaxTree = new YamlSyntaxTree(yamlContent); - - // Find the position of "my-bucket" (inside Fn::Sub) - const lines = yamlContent.split('\n'); - let targetLine = -1; - let targetCol = -1; - - for (const [i, line] of lines.entries()) { - if (line.includes('my-bucket')) { - targetLine = i; - targetCol = line.indexOf('my-bucket'); - break; - } - } + const path = getYamlPath(yamlContent, 8, 11); + expect(path).toEqual(['Resources', 'TestResource', 'Properties', 'BucketName', 'Fn::Sub', 1, 'env']); + }); - expect(targetLine).toBeGreaterThan(-1); + describe('YAML shorthand', () => { + it('!If in array returning complex objects', () => { + // Line 18: " - Name: conditional-container" + const path = getYamlPath(YAML_TEMPLATE, 18, 20); + expect(path).toEqual([ + 'Resources', + 'TaskDefinition', + 'Properties', + 'ContainerDefinitions', + 1, + 'Fn::If', + 1, + 'Name', + ]); + }); + + it('nested !If > !If in complex structures', () => { + // Line 27: " - Name: STAGING_VAR" + const path = getYamlPath(YAML_TEMPLATE, 27, 26); + expect(path).toEqual([ + 'Resources', + 'TaskDefinition', + 'Properties', + 'ContainerDefinitions', + 1, + 'Fn::If', + 1, + 'Environment', + 0, + 'Fn::If', + 2, + 'Fn::If', + 1, + 'Name', + ]); + }); + + it('!Sub array form with variable mapping containing nested !Ref', () => { + // Line 37: " - 'my-bucket-${env}-${id}'" + const templateStringPath = getYamlPath(YAML_TEMPLATE, 37, 12); + expect(templateStringPath).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Fn::Sub', 0]); + + // Line 38: " - env: !Ref Environment" + const varMappingPath = getYamlPath(YAML_TEMPLATE, 38, 20); + expect(varMappingPath).toEqual([ + 'Resources', + 'Bucket', + 'Properties', + 'BucketName', + 'Fn::Sub', + 1, + 'env', + 'Ref', + ]); + }); + + it('!Equals with nested !Ref in Conditions', () => { + // Line 7: " - !Ref Environment" + const path = getYamlPath(YAML_TEMPLATE, 7, 10); + expect(path).toEqual(['Conditions', 'IsProduction', 'Fn::Equals', 0, 'Ref']); + }); + + it('!Select with nested !GetAZs', () => { + // Line 44: " - 0" + const path = getYamlPath(YAML_TEMPLATE, 44, 10); + expect(path).toEqual(['Resources', 'Instance', 'Properties', 'AvailabilityZone', 'Fn::Select', 0]); + }); + + it('!Join inline array form', () => { + // Line 47: " - Key: !Join ['-', [prefix, suffix]]" + const path = getYamlPath(YAML_TEMPLATE, 47, 24); + expect(path).toEqual(['Resources', 'Instance', 'Properties', 'Tags', 0, 'Key', 'Fn::Join']); + }); + }); - const position = { line: targetLine, character: targetCol }; - const node = syntaxTree.getNodeAtPosition(position); + describe('JSON', () => { + it('Fn::If in array - position on Fn::If key', () => { + // Line 10: Fn::If inside ContainerDefinitions array + const path = getJsonPath(JSON_TEMPLATE, 10, 16); + expect(path).toEqual(['Resources', 'TaskDefinition', 'Properties', 'ContainerDefinitions', 1, 'Fn::If']); + }); + + it('Fn::If in array - position on condition name (index 0)', () => { + // Line 10: position on "IsProduction" string + const path = getJsonPath(JSON_TEMPLATE, 10, 24); + expect(path).toEqual(['Resources', 'TaskDefinition', 'Properties', 'ContainerDefinitions', 1, 'Fn::If', 0]); + }); + + it('Fn::Sub array form - position on template string (index 0)', () => { + // Line 17: Fn::Sub template string + const path = getJsonPath(JSON_TEMPLATE, 17, 40); + expect(path).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Fn::Sub', 0]); + }); + + it('Fn::Equals with nested Ref', () => { + // Line 2: Fn::Equals containing Ref + const path = getJsonPath(JSON_TEMPLATE, 2, 40); + expect(path).toEqual(['Conditions', 'IsProduction', 'Fn::Equals', 0, 'Ref']); + }); + }); - expect(node).toBeDefined(); + describe('YAML flow-style', () => { + it('flow-style Fn::Sub', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: { Fn::Sub: "my-bucket-\${AWS::Region}" }`; + // Position on the string value inside Fn::Sub + const path = getYamlPath(template, 4, 30); + expect(path).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Fn::Sub']); + }); + + it('flow-style nested intrinsics - position on inner Ref value', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: { Fn::If: [IsProd, { Ref: ProdName }, { Ref: DevName }] }`; + // ^col 44 (ProdName value) + const path = getYamlPath(template, 4, 44); + expect(path).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Fn::If', 'Ref']); + }); + }); - const pathInfo = syntaxTree.getPathAndEntityInfo(node); + describe('YAML edge cases', () => { + it('!GetAtt dot notation', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket +Outputs: + BucketArn: + Value: !GetAtt Bucket.Arn`; + // Position on the value "Bucket.Arn" + const path = getYamlPath(template, 5, 18); + expect(path).toEqual(['Outputs', 'BucketArn', 'Value', 'Fn::GetAtt']); + }); + + it('!GetAtt array form', () => { + const template = `Outputs: + Arn: + Value: !GetAtt [MyBucket, Arn]`; + // Position on "MyBucket" inside the array + const path = getYamlPath(template, 2, 20); + expect(path).toEqual(['Outputs', 'Arn', 'Value', 'Fn::GetAtt']); + }); + + it('simple !Sub string form (not array)', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "my-bucket-\${AWS::Region}"`; + // Position on the string value + const path = getYamlPath(template, 4, 24); + expect(path).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Fn::Sub']); + }); + + it('standalone !Ref as direct value', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketNameParam`; + // Position on the parameter name value + const path = getYamlPath(template, 4, 20); + expect(path).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Ref']); + }); + + it('sibling intrinsics at same level', () => { + const template = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + Tags: + - Key: !Sub "\${Env}-key" + Value: !Ref SomeParam`; + // Position on Key's Sub value + const keyPath = getYamlPath(template, 5, 18); + expect(keyPath).toEqual(['Resources', 'Bucket', 'Properties', 'Tags', 0, 'Key', 'Fn::Sub']); + + // Position on Value's Ref value + const valuePath = getYamlPath(template, 6, 20); + expect(valuePath).toEqual(['Resources', 'Bucket', 'Properties', 'Tags', 0, 'Value', 'Ref']); + }); + + it('intrinsics in Outputs section', () => { + const template = `Outputs: + BucketArn: + Value: !GetAtt MyBucket.Arn + Export: + Name: !Sub "\${AWS::StackName}-BucketArn"`; + const getAttPath = getYamlPath(template, 2, 18); + expect(getAttPath).toEqual(['Outputs', 'BucketArn', 'Value', 'Fn::GetAtt']); + + const subPath = getYamlPath(template, 4, 18); + expect(subPath).toEqual(['Outputs', 'BucketArn', 'Export', 'Name', 'Fn::Sub']); + }); + }); - // Should contain Fn::Sub in the propertyPath - const hasSubFunction = pathInfo.propertyPath.some( - (segment) => typeof segment === 'string' && segment === 'Fn::Sub', - ); + describe('JSON edge cases', () => { + it('deeply nested intrinsics (3+ levels)', () => { + const template = `{ + "Resources": { + "Bucket": { + "Properties": { + "BucketName": { "Fn::If": ["Cond", { "Fn::Sub": ["x-{y}", { "y": { "Ref": "P" } }] }, ""] } + } + } + } +}`; + // Position on "P" value inside the deepest Ref + const path = getJsonPath(template, 4, 82); + expect(path).toEqual([ + 'Resources', + 'Bucket', + 'Properties', + 'BucketName', + 'Fn::If', + 1, + 'Fn::Sub', + 1, + 'y', + 'Ref', + ]); + }); + }); - expect(hasSubFunction).toBe(true); + describe('malformed templates (fallback)', () => { + it('incomplete template triggers fallback for context path', () => { + const incomplete = `AWSTemplateFormatVersion: '2010-09-09' +Conditions: + IsProd: !Equals [!Ref`; + // When parent is ERROR node, fallback provides context + const path = getYamlPath(incomplete, 2, 20); + expect(path).toEqual(['Conditions', 'IsProd']); + }); + + it('incomplete !Sub with no value', () => { + const incomplete = `Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub + -`; + // Position on the dash (first array item) + const path = getYamlPath(incomplete, 5, 9); + expect(path).toEqual(['Resources', 'Bucket', 'Properties', 'BucketName', 'Fn::Sub', 0]); + }); }); }); + +function getYamlPath(content: string, line: number, character: number): (string | number)[] { + const tree = new YamlSyntaxTree(content); + const node = tree.getNodeAtPosition({ line, character }); + return [...tree.getPathAndEntityInfo(node).propertyPath]; +} + +function getJsonPath(content: string, line: number, character: number): (string | number)[] { + const tree = new JsonSyntaxTree(content); + const node = tree.getNodeAtPosition({ line, character }); + return [...tree.getPathAndEntityInfo(node).propertyPath]; +} + +const YAML_TEMPLATE = ` +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Environment: + Type: String +Conditions: + IsProduction: !Equals + - !Ref Environment + - production +Resources: + TaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + ContainerDefinitions: + - Name: container1 + Image: nginx + - !If + - IsProduction + - Name: conditional-container + Image: redis + Environment: + - !If + - IsProduction + - Name: PROD_VAR + Value: prod-value + - !If + - IsProduction + - Name: STAGING_VAR + Value: staging-value + - Name: DEV_VAR + Value: dev-value + - Name: fallback-container + Image: alpine + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub + - 'my-bucket-\${env}-\${id}' + - env: !Ref Environment + id: !GetAtt TaskDefinition.Arn + Instance: + Type: AWS::EC2::Instance + Properties: + AvailabilityZone: !Select + - 0 + - !GetAZs '' + Tags: + - Key: !Join ['-', [prefix, suffix]] + Value: test +`; + +const JSON_TEMPLATE = `{ + "Conditions": { + "IsProduction": { "Fn::Equals": [{ "Ref": "Environment" }, "production"] } + }, + "Resources": { + "TaskDefinition": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { "Name": "container1", "Image": "nginx" }, + { "Fn::If": ["IsProduction", { "Name": "conditional-container" }, { "Name": "fallback" }] } + ] + } + }, + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Fn::Sub": ["my-bucket-\${env}", { "env": { "Ref": "Environment" } }] } + } + } + } +}`; diff --git a/tst/unit/context/syntaxtree/JsonFallback.test.ts b/tst/unit/context/syntaxtree/JsonFallback.test.ts new file mode 100644 index 00000000..d7bccfe8 --- /dev/null +++ b/tst/unit/context/syntaxtree/JsonFallback.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { JsonSyntaxTree } from '../../../../src/context/syntaxtree/JsonSyntaxTree'; + +describe('JSON Fallback for Malformed Documents', () => { + describe('Incomplete Keys', () => { + it('should resolve path for incomplete key in Properties', () => { + const content = `{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "Buck`; + const tree = new JsonSyntaxTree(content); + const node = tree.getNodeAtPosition({ line: 5, character: 13 }); + const pathInfo = tree.getPathAndEntityInfo(node); + + expect(pathInfo.propertyPath).toEqual(['Resources', 'MyBucket', 'Properties', 'Buck']); + tree.cleanup(); + }); + + it('should resolve path for key without value', () => { + const content = `{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName":`; + const tree = new JsonSyntaxTree(content); + const node = tree.getNodeAtPosition({ line: 5, character: 21 }); + const pathInfo = tree.getPathAndEntityInfo(node); + + expect(pathInfo.propertyPath).toEqual(['Resources', 'MyBucket', 'Properties', 'BucketName']); + tree.cleanup(); + }); + }); + + describe('Array Items', () => { + it('should resolve path for incomplete array item', () => { + const content = `{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "Tags": [ + { "Key":`; + const tree = new JsonSyntaxTree(content); + const node = tree.getNodeAtPosition({ line: 6, character: 18 }); + const pathInfo = tree.getPathAndEntityInfo(node); + + expect(pathInfo.propertyPath).toEqual(['Resources', 'MyBucket', 'Properties', 'Tags', 'Key']); + tree.cleanup(); + }); + }); + + describe('Intrinsic Functions', () => { + it('should resolve path for incomplete Fn::Sub', () => { + const content = `{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Fn::Sub":`; + const tree = new JsonSyntaxTree(content); + const node = tree.getNodeAtPosition({ line: 5, character: 34 }); + const pathInfo = tree.getPathAndEntityInfo(node); + + expect(pathInfo.propertyPath).toEqual(['Resources', 'MyBucket', 'Properties', 'BucketName', 'Fn::Sub']); + + tree.cleanup(); + }); + }); +});