Private/Expand-HDTVariableToken.ps1

function Expand-HDTVariableToken {
    <#
        .SYNOPSIS
            Expands %Var% tokens in a value against the resolution scope.
 
        .DESCRIPTION
            The rule "%Var% expands against already-resolved variables",
            with the three behaviours that make it safe to hand to an
            administrator:
 
            RECURSIVE. A token names a variable whose RAW value may itself hold a
            token, so expansion recurses rather than running a single pass.
            `HDTComputerName: LT-%HDTSitePrefix%` works when HDTSitePrefix is
            itself `%HDTRegion%-01`.
 
            CYCLE-SAFE. Recursion is bounded by the CHAIN of variable names
            currently being expanded, not by a depth counter. A token already on
            the chain is a cycle and raises a terminating HDTConfigurationError
            whose message names the whole cycle - the "cyclic %Var%
            expansion detected and reported, not hang". A depth counter would
            report "too deep" for both a cycle and a legitimately long chain, and
            would name neither.
 
            HONEST ABOUT WHAT IT COULD NOT RESOLVE. A token naming nothing in the
            scope, or naming a $null, is left LITERALLY in the output and its name
            is added to -Unresolved. MDT leaves such a token alone; emptying it
            silently would turn an authoring mistake into a machine named 'PC-'.
            -Unresolved is what makes it visible without making it fatal.
 
            The token grammar is %Name% where Name matches [A-Za-z_][A-Za-z0-9_]*,
            and %% is a literal %. Requiring a letter or underscore first means a
            batch file's %1 and a '50% done' message pass through untouched.
 
        .PARAMETER Value
            The text to expand. An empty string is returned unchanged.
 
        .PARAMETER Scope
            Name -> RAW value. Raw, not expanded: that is what makes a cycle
            detectable at all, because two variables that reference each other
            only look cyclic before expansion.
 
        .PARAMETER Unresolved
            An ArrayList to append the name of every token that resolved to
            nothing. Names are added once each. Optional - omit it where the
            caller does not report unresolved tokens.
 
        .PARAMETER Chain
            The variable names already being expanded, outermost first. Callers
            seed it with the name of the variable whose value this is; the
            function appends to it as it recurses. A token already on the chain
            is the cycle.
 
        .PARAMETER Path
            The file this value came from, for the error message. Optional.
 
        .OUTPUTS
            System.String
 
        .EXAMPLE
            Expand-HDTVariableToken -Value 'LT-%HDTSerialNumber%' -Scope $scope
 
            Returns 'LT-FIXTURE-SERIAL-0001' when the scope holds that serial.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyString()]
        [AllowNull()]
        [string] $Value,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNull()]
        [System.Collections.IDictionary] $Scope,

        [Parameter()]
        [AllowNull()]
        [System.Collections.ArrayList] $Unresolved,

        [Parameter()]
        [AllowNull()]
        [string[]] $Chain,

        [Parameter()]
        [string] $Path
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ([string]::IsNullOrEmpty($Value)) {
        return $Value
    }

    $chainName = @()
    if ($null -ne $Chain) {
        $chainName = @($Chain)
    }

    $builder = New-Object -TypeName System.Text.StringBuilder
    $position = 0

    foreach ($match in [regex]::Matches($Value, '%%|%([A-Za-z_][A-Za-z0-9_]*)%')) {

        [void] $builder.Append($Value.Substring($position, $match.Index - $position))
        $position = $match.Index + $match.Length

        # %% is a literal per cent, and never a token.
        if ($match.Value -eq '%%') {
            [void] $builder.Append('%')
            continue
        }

        $name = $match.Groups[1].Value

        # -contains on a string array is case-insensitive, which is what the
        # scope's own lookup is, so HDTa and HDTA are one variable and one cycle.
        if ($chainName -contains $name) {
            $cycle = @($chainName) + $name
            $rendered = @($cycle | ForEach-Object { '%{0}%' -f $_ }) -join ' -> '

            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("the value of {0} cannot be expanded: {1} is cyclic. A variable cannot reference itself, directly or through another variable." -f $cycle[0], $rendered)))
        }

        $raw = $null
        if ($Scope.Contains($name)) {
            $raw = $Scope[$name]
        }

        # Absent, or present and $null: left literally in the output and reported.
        if ($null -eq $raw) {
            [void] $builder.Append($match.Value)

            if ($null -ne $Unresolved -and -not ($Unresolved -contains $name)) {
                [void] $Unresolved.Add($name)
            }

            continue
        }

        if (($raw -is [System.Collections.IList]) -and -not ($raw -is [string])) {
            $text = @(@($raw) | ForEach-Object { ConvertTo-HDTComparableString -Value $_ }) -join ','
        } else {
            $text = ConvertTo-HDTComparableString -Value $raw
        }

        [void] $builder.Append((Expand-HDTVariableToken -Value $text -Scope $Scope `
                    -Unresolved $Unresolved -Chain (@($chainName) + $name) -Path $Path))
    }

    [void] $builder.Append($Value.Substring($position))

    return $builder.ToString()
}