MustachePlaceholders/private/Expand-PlaceholdersRec.ps1

function Expand-PlaceholdersRec {
    [cmdletbinding()]
    param (
        $node,
        [System.Collections.IDictionary] $ht,
        [System.Collections.IDictionary] $override,
        [string[]] $sourcePaths,
        [switch] $treatMissingValueAsFalse,
        [switch] $ignoreMissingPlaceholders,
        [switch] $dontThrow,
        [string] $path,
        [int] $maxDepth = 10
    )
    # Write-Debug "Expanding placeholders in '$path' with max depth of $maxDepth. Node type = $($node.GetType())"

    $commonPSBoundParameters = Get-CommonParameters -BoundParameters $PSBoundParameters
    if ($maxDepth -le 0) {
        # throw "Max depth of recursion reached. Will not expand placeholders in '$path'"
        return $node
    }

    $commonSettings = @{
        treatMissingValueAsFalse = $treatMissingValueAsFalse
        ignoreMissingPlaceholders   = $ignoreMissingPlaceholders
        dontThrow                = $dontThrow
    }

    $maxDepth = $maxDepth - 1

    if ($node -is [string]) {
        # in string case there might be substitutions
        $stringValue = $node.ToString()
        $depthCounter = 0
        $RECURSION_MAX_DEPTH = 50
        $regex = [regex]$PLACEHOLDER_REGEX
        $regexOnly = [regex]$PLACEHOLDER_ONLY_REGEX

        # Handle escaped substitutions ~!{{ ... }} by protecting them from expansion
        $escapedSubstitutionsMap = @{}
        $escapedSubstitutionCounter = 0
        $escapedSubstitutionRegex = [regex]"~!\{\{[^\}]*\}\}"
        $escapedMatches = $escapedSubstitutionRegex.Matches($stringValue)

        foreach ($escapedMatch in $escapedMatches) {
            $placeholder = "__ESCAPED_SUB_${escapedSubstitutionCounter}__"
            $escapedSubstitutionsMap[$placeholder] = $escapedMatch.Value
            $stringValue = $stringValue.Replace($escapedMatch.Value, $placeholder)
            $escapedSubstitutionCounter++
        }

        do {
            $beforeSubstitution = $stringValue

            $patternToBlockYamlConversion = "~!" # like to avoid "`!2022-10-24" to be converted to dateTime
            # also used in Identify-ParamsFromBobFileForRunEntity.ps1
            #! if pattern change change there as well

            # Date escaping: only strip ~! at start if NOT followed by {{
            if (($stringValue -is [string]) -and $stringValue.StartsWith($patternToBlockYamlConversion) -and -not $stringValue.StartsWith("~!{{")) {
                #remove from start
                $stringValue = $stringValue.Substring($patternToBlockYamlConversion.Length)
            }

            $matchList = $regex.Matches($stringValue)
            $isPureSubstitution = $regexOnly.IsMatch($stringValue)
            $hasMultipleSubs = ($matchList.Count -gt 1)

            if ($isPureSubstitution -and -not $hasMultipleSubs) {
                # pure substitution case: "{{ some.var }}"
                $varName = $regexOnly.Match($stringValue).Groups[1].Value

                $varValue = Get-PathFromHashtable $ht $varName -override $override -dontThrow
                $varValueOrYes = Test-PlaceholderInContext $override $ht $varName # returns yes when non primitive or intermediate value
                # detect pipeline presence for pure substitution
                $mPure = $regex.Match($stringValue)
                if ($mPure.Success) {
                    $pipelineGroupPure = $mPure.Groups[$pipeLineGroupName]
                    if ($pipelineGroupPure.Success -and -not [string]::IsNullOrWhiteSpace($pipelineGroupPure.Value)) {
                        return (Expand-PlaceholderPipelineRec -value $varValue -pipeline $pipelineGroupPure.Value @commonPSBoundParameters)
                    }
                }

                if ($varValueOrYes -eq "yes" -and $varValue -is [System.Collections.IDictionary]) {
                    # (pipeline for pure substitution handled above)
                    # case where the substituted value is a hashtable
                    # Write-Debug "recursive call Expand-PlaceholdersRec, case where the substituted value is a hashtable"
                    return Expand-PlaceholdersRec $varValue $ht $override -sourcePaths $sourcePaths -path $path -maxDepth $maxDepth @commonSettings @commonPSBoundParameters  # it's a final substitution, return the expanded tree
                }

                if ($varValueOrYes -eq "yes" -and ($varValue -is [array] -or $varValue -is [System.Collections.IList])) {
                    # (pipeline for pure substitution handled above)
                    $result = @(
                        foreach ($item in $varValue) {
                            # Write-Debug "recursive call Expand-PlaceholdersRec, case where the substituted value is an array"
                            $expandedValue = Expand-PlaceholdersRec $item $ht $override -sourcePaths $sourcePaths -path $path -maxDepth $maxDepth @commonSettings @commonPSBoundParameters
                            $expandedValue # emit to array
                        }
                    )
                    # note: WE DO NOT YET handle(or tested) case if array is not a primitive result.
                    # note: merging arrays is not yet supported as well. You'd probably ended to add function to that
                    return $result
                }


                # Here we have some variable 'a.b.c...z' which was not found
                # Let's try finding such a tree node 'a' or 'a.b' until 'a.b...y' which will contain a pure substitution which in turn will be a hashtable
                if ($PATH_NOT_FOUND_IN_HASHTABLE -eq $varValue) {
                    $splitVarName = $varName.Split(".")

                    if ($splitVarName.Length -lt 2) {
                        # if it already was a simple variable, no subtree is possible
                        # Write-Debug "Recursive call Expand-PlaceholdersRec case1, with params: varName='$varName', path='$path', commonSettings=$($commonSettings |ConvertTo-Json -Depth 10)"
                        $stringValue = Resolve-PlaceholderOrThrow -varName $varName -path $path -sourcePaths $sourcePaths @commonSettings @commonPSBoundParameters
                    } else {
                        $currentNode = $ht

                        $variableHandledSomeSubtree = $false
                        # we descend until the (N-1)-th var name component (in case of `a.b.c...y.z` -> `a.b...y`)
                        # we look for the first tree node which contains some hashtable (when expanded)
                        for ($i = 0; $i -lt $splitVarName.Count - 1; $i++) {
                            $currentVarNameElement = $splitVarName[$i]
                            $nextElement = $currentNode.$currentVarNameElement

                            if ($nextElement -is [string] -and $regex.IsMatch($nextElement)) {
                                # is `a.b` a pure substitution ?
                                # Write-Debug "recursive call Expand-PlaceholdersRec, case where the substituted value is a string and it is a pure substitution"
                                $expandedValue = Expand-PlaceholdersRec $nextElement $ht $override -sourcePaths $sourcePaths -path "$($path).$($currentVarNameElement)" -maxDepth $maxDepth @commonSettings @commonPSBoundParameters

                                if ($expandedValue -is [System.Collections.IDictionary]) {
                                    # if the value is hashtable, there's still chance to find a variable
                                    $remainingVarName = $splitVarName[($i + 1)..($splitVarName.Count - 1)] -join '.' # the path to the variable in the found subtree
                                    $varValue = Get-PathFromHashtable $expandedValue $remainingVarName -override $override -dontThrow

                                    if ($PATH_NOT_FOUND_IN_HASHTABLE -eq $varValue) {
                                        $variableHandledSomeSubtree = $true
                                        # Write-Debug "Recursive call Expand-PlaceholdersRec case 2, with params: varName='$varName', path='$path', commonSettings=$($commonSettings | ConvertTo-Json -Depth 10)"
                                        $stringValue = Resolve-PlaceholderOrThrow -varName $varName -path $path -sourcePaths $sourcePaths @commonSettings @commonPSBoundParameters
                                    } elseif ($varValue -is [string]) {
                                        # if it's a string, it can be another substitution, so we leave it for next `do` cycle pass
                                        $variableHandledSomeSubtree = $true
                                        $stringValue = $varValue
                                    } else {
                                        # otherwise it's what we looked for
                                        return $varValue
                                    }
                                }

                                break # the resulted substitution is not a hashtable and we drop it
                            }

                            $currentNode = $nextElement
                        }

                        if (!$variableHandledSomeSubtree) {
                            # Write-Debug "Recursive call Expand-PlaceholdersRec case 3 -> with params: varName='$varName', path='$path', commonSettings=$($commonSettings | ConvertTo-Json -Depth 10)"
                            $stringValue = Resolve-PlaceholderOrThrow -varName $varName -path $path -sourcePaths $sourcePaths @commonSettings @commonPSBoundParameters
                            # Write-Debug "relutive value for case 3: $stringValue"
                        }
                    }

                } else {
                    # if there was some value
                    $pipelineGroups = $matchList.Groups | Where-Object { $_.Name -eq $pipeLineGroupName }
                    $hasPipeline = (@($pipelineGroups).Count -eq 1) -and ($null -ne $pipelineGroups[0]) -and (-not [string]::IsNullOrWhiteSpace($pipelineGroups[0].Value))

                    if ($hasPipeline) {
                        # Apply pipeline regardless of the underlying type (null, bool, number, string, etc.)
                        $pipelineGroup = $pipelineGroups[0]
                        return (Expand-PlaceholderPipelineRec -value $varValue -pipeline $pipelineGroup.Value @commonPSBoundParameters)
                    }

                    if ($varValueOrYes -is [string]) {
                        # no pipeline; continue resolving nested substitutions if the value is string
                        $stringValue = $varValue
                    } else {
                        # primitive value (including $null) without pipeline: return as-is
                        return $varValueOrYes
                    }
                }
            } else {
                # string interpolation case: e.g. `This is {{ params.name? | toLower() }}`
                foreach ($match in $matchList) {
                    $expr = $match.Groups[0].Value
                    # Write-Debug "recursive call Expand-PlaceholdersRec, case where the substituted value is a string and it is not a pure substitution"
                    $varValue = Expand-PlaceholdersRec $expr $ht $override -sourcePaths $sourcePaths -path $path -maxDepth $maxDepth @commonSettings @commonPSBoundParameters

                    if ($null -eq $varValue) {
                        $varValue = ""
                    }

                    $varValue = $varValue.ToString() # we never leave the string space with string interpolation

                    $stringValue = $stringValue.Replace($match.Value, $varValue)
                }
            }

            $depthCounter += 1
        } while ($beforeSubstitution -ne $stringValue -and $depthCounter -le $RECURSION_MAX_DEPTH)

        if ($depthCounter -ge $RECURSION_MAX_DEPTH) {
            throw "Recursion in placeholders declaration in one of the placeholders."
        }

        # Restore escaped substitutions from placeholders
        foreach ($placeholder in $escapedSubstitutionsMap.Keys) {
            $stringValue = $stringValue.Replace($placeholder, $escapedSubstitutionsMap[$placeholder])
        }

        return $stringValue
    } elseif (Test-ValuePrimitive $node) {
        return $node
    } elseif (Test-IsOrderedDictionary $node) { # [System.Collections.Specialized.OrderedDictionary] === [ordered] ordered dictionary
        # Preserve ordering (important for YAML maps like scenario steps).

        $res = [ordered]@{}

        foreach ($key in $node.Keys) {
            $childNode = $node[$key]
            if ($null -ne $childNode) { # case where ' "myProperty": null,' is ok, nothing should be expanded
                # Write-Debug "Expanding key '$key' in path '$path'"

                # Write-Debug "recursive call Expand-PlaceholdersRec, case where the substituted value is an ordered dictionary"
                $expandedValue = Expand-PlaceholdersRec -node $childNode -ht $ht -override $override -sourcePaths $sourcePaths -path "$path.$key" -maxDepth $maxDepth @commonSettings @commonPSBoundParameters

                $res[$key] = $expandedValue
            } else {
                $res[$key] = $null
            }
        }

        return $res

    } elseif ($node -is [System.Collections.IDictionary] ) {
        # Preserve ordering (important for YAML maps like scenario steps).
        $res = [ordered]@{}
        $localKeys = $node.Keys | ForEach-Object { $_ }
        foreach ($key in $localKeys) {
            $value = $node[$key]
            # Write-Debug "recursive call Expand-PlaceholdersRec, case where the substituted value is a hashtable"
            $expandResult = Expand-PlaceholdersRec $value $ht $override -sourcePaths $sourcePaths -path "$($path).$key" -maxDepth $maxDepth @commonSettings @commonPSBoundParameters
            $res[$key] = $expandResult
        }

        return $res

    } elseif ($node -is [System.Collections.IEnumerable]) {
        $res = @()

        foreach ($item in $node) {
            $res += @(, (Expand-PlaceholdersRec $item $ht $override -sourcePaths $sourcePaths -path "$($path).$i" -maxDepth $maxDepth @commonSettings @commonPSBoundParameters))
        }

        if ($res.Length -eq 1) {
            $res = @(, $res)
        }
        return $res
    } else {
        throw "Unsupported value '$node' (type: $($node.GetType()))"
    }

    return $res
}