Private/Compiler.ps1

using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Collections.Specialized

# Variable resolution and task compilation (templating of every field of a task for one call).
#
# Resolution order (later wins), same as go-task:
# OS environment -> special variables -> Taskfile env -> Taskfile vars -> include vars ->
# vars of the included Taskfile -> call vars -> task vars

[NoRunspaceAffinity()]
class TaskCompiler {
    # ---- templating with go-task's error latch ---------------------------------------------
    # After the first template error nothing else is replaced; the error is raised only when
    # dynamic variables are being evaluated (a real run), not while listing tasks.

    static [hashtable] NewCache([OrderedDictionary] $vars) { return @{ Vars = $vars; Error = $null } }

    hidden static [object] Data([hashtable] $cache, [IDictionary] $extra) {
        if ($null -eq $extra -or $extra.get_Count() -eq 0) { return $cache.Vars }
        $data = [OrderedDictionary]::new()
        foreach ($key in $cache.Vars.get_Keys()) { $data[[string]$key] = $cache.Vars[$key] }
        foreach ($key in $extra.get_Keys()) { $data[[string]$key] = $extra[$key] }
        return $data
    }

    static [string] Replace([hashtable] $cache, [string] $text, [IDictionary] $extra) {
        if ($null -ne $cache.Error -or [string]::IsNullOrEmpty($text)) { return $text }
        try { return [TplEngine]::Expand($text, [TaskCompiler]::Data($cache, $extra)) }
        catch { $cache.Error = [TaskError]::Unwrap($_.Exception); return $text }
    }

    static [object] ReplaceValue([hashtable] $cache, [object] $value, [IDictionary] $extra) {
        if ($null -ne $cache.Error -or $null -eq $value) { return $value }
        try { return [TplEngine]::ExpandValue($value, [TaskCompiler]::Data($cache, $extra)) }
        catch { $cache.Error = [TaskError]::Unwrap($_.Exception); return $value }
    }

    static [string[]] ReplaceStrings([hashtable] $cache, [string[]] $items) {
        $result = [List[string]]::new()
        foreach ($item in $items) { $result.Add([TaskCompiler]::Replace($cache, $item, $null)) }
        return $result.ToArray()
    }

    static [List[object]] ReplaceGlobs([hashtable] $cache, [object] $globs) {
        $result = [List[object]]::new()
        foreach ($glob in $globs) {
            $result.Add([pscustomobject]@{ Glob = [TaskCompiler]::Replace($cache, $glob.Glob, $null); Negate = $glob.Negate })
        }
        return $result
    }

    static [object] ResolveRef([hashtable] $cache, [string] $ref, [IDictionary] $extra) {
        if ($null -ne $cache.Error) { return $null }
        try { return [TplEngine]::Resolve($ref, [TaskCompiler]::Data($cache, $extra)) }
        catch { $cache.Error = [TaskError]::Unwrap($_.Exception); return $null }
    }

    # Templated copy of a set of variables (refs become static values, sh stays dynamic).
    static [OrderedDictionary] ReplaceVars([hashtable] $cache, [OrderedDictionary] $vars, [IDictionary] $extra) {
        $result = [OrderedDictionary]::new()
        if ($null -eq $vars) { return $result }
        foreach ($key in $vars.get_Keys()) {
            $var = $vars[$key]
            $new = $null
            if ($var.Kind -eq 'ref') { $new = [TaskfileReader]::NewVar('static', [TaskCompiler]::ResolveRef($cache, $var.Ref, $extra)) }
            elseif ($var.Kind -eq 'sh') { $new = [TaskfileReader]::NewVar('sh', [TaskCompiler]::Replace($cache, $var.Sh, $extra)) }
            else { $new = [TaskfileReader]::NewVar('static', [TaskCompiler]::ReplaceValue($cache, $var.Value, $extra)) }
            $new.Dir = $var.Dir
            $result[[string]$key] = $new
        }
        return $result
    }

    hidden static [void] ThrowIfError([hashtable] $cache) {
        if ($null -ne $cache.Error) { throw [TaskError]::Create($cache.Error.Message, [TaskError]::Unknown, $cache.Error) }
    }

    # ---- variables -------------------------------------------------------------------------

    hidden static [OrderedDictionary] SpecialVars([hashtable] $ctx, [object] $task, [object] $call, [string] $taskDir) {
        $vars = [OrderedDictionary]::new()
        $vars['TASK_EXE'] = 'Invoke-Task'
        $vars['ROOT_TASKFILE'] = $ctx.Entrypoint
        $vars['ROOT_DIR'] = $ctx.Dir
        $vars['USER_WORKING_DIR'] = $ctx.UserWorkingDir
        $vars['TASK_VERSION'] = $ctx.Version
        if ($null -ne $task) {
            $vars['TASK'] = $task.Task
            $vars['TASK_DIR'] = $taskDir
            $vars['TASKFILE'] = $task.Location.Taskfile
            $vars['TASKFILE_DIR'] = [System.IO.Path]::GetDirectoryName($task.Location.Taskfile)
        }
        else {
            $vars['TASK'] = ''
            $vars['TASK_DIR'] = ''
            $vars['TASKFILE'] = ''
            $vars['TASKFILE_DIR'] = ''
        }
        $vars['ALIAS'] = if ($null -ne $call) { $call.Task } else { '' }
        return $vars
    }

    hidden static [bool] IsScalar([object] $value) {
        return $value -is [string] -or $value -is [bool] -or [TplFn]::IsNumber($value)
    }

    # Environment overrides for a set of name/value pairs. By default a variable that is
    # already set in the OS environment wins (go-task v3). TASK_X_ENV_PRECEDENCE=1 flips that.
    static [OrderedDictionary] EnvOverrides([hashtable] $ctx, [IDictionary] $values) {
        $overrides = [OrderedDictionary]::new()
        if ($null -eq $values) { return $overrides }
        foreach ($key in $values.get_Keys()) {
            $name = [string]$key
            $value = $values[$key]
            if ($name.Length -eq 0 -or $name.Contains('=') -or -not [TaskCompiler]::IsScalar($value)) { continue }
            if (-not $ctx.EnvPrecedence -and $ctx.BaseEnv.ContainsKey($name)) { continue }
            $overrides[$name] = [TplFn]::Format($value, $false)
        }
        return $overrides
    }

    # The variables that are also PowerShell variables of the code of a task: $declared holds
    # their names (everything but the variables that come from the environment).
    static [OrderedDictionary] ScriptVars([OrderedDictionary] $vars, [OrderedDictionary] $declared) {
        $result = [OrderedDictionary]::new()
        foreach ($key in $declared.get_Keys()) {
            $name = [string]$key
            if ($vars.Contains($name)) { $result[$name] = $vars[$name] }
        }
        return $result
    }

    # The values of the PowerShell variables $text refers to, as a cache key: the same command
    # gives another result when such a variable has another value.
    hidden static [string] ReferencedVars([string] $text, [IDictionary] $scriptVars) {
        if ($null -eq $scriptVars -or $scriptVars.get_Count() -eq 0) { return '' }
        $used = [OrderedDictionary]::new()
        foreach ($name in [TaskScript]::VariableNames($text)) {
            foreach ($key in $scriptVars.get_Keys()) {
                if ([string]::Equals([string]$key, $name, [StringComparison]::OrdinalIgnoreCase)) { $used[[string]$key] = $scriptVars[$key] }
            }
        }
        if ($used.get_Count() -eq 0) { return '' }
        return [TplFn]::ToJson($used, $false, '', '')
    }

    # Runs a dynamic variable (`sh:`) as PowerShell and returns its output as a string. $vars
    # are exported as environment variables, $scriptVars defined as PowerShell variables.
    static [string] HandleDynamicVar([hashtable] $ctx, [string] $text, [string] $directory, [IDictionary] $vars, [IDictionary] $scriptVars) {
        $key = $directory + [char]0 + $text + [char]0 + [TaskCompiler]::ReferencedVars($text, $scriptVars)
        $cached = $null
        if ($ctx.DynamicCache.TryGetValue($key, [ref] $cached)) { return [string]$cached }
        $result = $null
        [TaskEnv]::Enter($ctx.EnvGate, [TaskCompiler]::EnvOverrides($ctx, $vars), $ctx.Cancel.Token)
        try { $result = [TaskScript]::Invoke($text, [TaskScript]::NewScope($scriptVars), $directory, 'capture', $null) }
        finally { [TaskEnv]::Leave($ctx.EnvGate) }
        if ($result.ExitCode -ne 0) {
            throw [TaskError]::Create("task: Command `"$text`" failed: $($result.Message)", [TaskError]::Unknown)
        }
        $value = [TaskScript]::OutputToString($result.Output)
        $ctx.DynamicCache[$key] = $value
        [TaskLog]::Verbose($ctx, "task: dynamic variable: `"$text`" result: `"$value`"")
        return $value
    }

    # Resolves $vars in order into $result. $declared ($null for `env:`) collects their names.
    hidden static [void] Range([hashtable] $ctx, [OrderedDictionary] $result, [OrderedDictionary] $vars, [string] $directory, [bool] $evaluateSh, [OrderedDictionary] $declared) {
        if ($null -eq $vars) { return }
        foreach ($key in @($vars.get_Keys())) {
            $name = [string]$key
            $var = $vars[$key]
            if ($null -ne $declared) { $declared[$name] = $true }
            $cache = [TaskCompiler]::NewCache($result)
            $value = $null
            $sh = ''
            if ($var.Kind -eq 'ref') { $value = [TaskCompiler]::ResolveRef($cache, $var.Ref, $null) }
            elseif ($var.Kind -eq 'sh') { $sh = [TaskCompiler]::Replace($cache, $var.Sh, $null) }
            else { $value = [TaskCompiler]::ReplaceValue($cache, $var.Value, $null) }

            if (-not $evaluateSh) {
                if ($null -eq $value) { $result[$name] = '' } else { $result[$name] = $value }
                continue
            }
            [TaskCompiler]::ThrowIfError($cache)
            if ($var.Kind -ne 'sh') { $result[$name] = $value; continue }
            $varDirectory = if ([string]::IsNullOrEmpty($var.Dir)) { $directory } else { $var.Dir }
            $scriptVars = $null
            if ($null -ne $declared) { $scriptVars = [TaskCompiler]::ScriptVars($result, $declared) }
            $result[$name] = [TaskCompiler]::HandleDynamicVar($ctx, $sh, $varDirectory, $result, $scriptVars)
        }
    }

    # All variables visible to $task for $call ($task and $call may be $null for global scope).
    static [OrderedDictionary] GetVariables([hashtable] $ctx, [object] $task, [object] $call, [bool] $evaluateSh) {
        return [TaskCompiler]::GetVariables($ctx, $task, $call, $evaluateSh, [OrderedDictionary]::new())
    }

    # $declared receives the names of the variables that do not come from the environment (the
    # OS environment and `env:`): those are the ones user code also sees as PowerShell variables.
    static [OrderedDictionary] GetVariables([hashtable] $ctx, [object] $task, [object] $call, [bool] $evaluateSh, [OrderedDictionary] $declared) {
        $result = [OrderedDictionary]::new()
        $environment = [Environment]::GetEnvironmentVariables()
        foreach ($key in $environment.get_Keys()) { $result[[string]$key] = [string]$environment[$key] }

        $rootDir = [string]$ctx.Dir
        $taskDir = $rootDir
        $special = [TaskCompiler]::SpecialVars($ctx, $task, $call, '')
        foreach ($key in $special.get_Keys()) {
            $result[[string]$key] = $special[$key]
            $declared[[string]$key] = $true
        }
        if ($null -ne $task) {
            $cache = [TaskCompiler]::NewCache($result)
            $dir = [TaskCompiler]::Replace($cache, $task.Dir, $null)
            if ($evaluateSh) { [TaskCompiler]::ThrowIfError($cache) }
            $taskDir = [TaskPath]::SmartJoin($rootDir, [TaskPath]::ExpandHome($dir))
            $result['TASK_DIR'] = $taskDir
        }

        [TaskCompiler]::Range($ctx, $result, $ctx.Taskfile.Env, $rootDir, $evaluateSh, $null)
        [TaskCompiler]::Range($ctx, $result, $ctx.Taskfile.Vars, $rootDir, $evaluateSh, $declared)
        if ($null -ne $task) {
            [TaskCompiler]::Range($ctx, $result, $task.IncludeVars, $rootDir, $evaluateSh, $declared)
            [TaskCompiler]::Range($ctx, $result, $task.IncludedTaskfileVars, $taskDir, $evaluateSh, $declared)
        }
        if ($null -eq $task -or $null -eq $call) { return $result }
        [TaskCompiler]::Range($ctx, $result, $call.Vars, $rootDir, $evaluateSh, $declared)
        [TaskCompiler]::Range($ctx, $result, $task.Vars, $taskDir, $evaluateSh, $declared)
        return $result
    }

    # ---- for loops -----------------------------------------------------------------------

    hidden static [List[object]] MatrixProduct([OrderedDictionary] $rows) {
        $result = [List[object]]::new()
        $result.Add([OrderedDictionary]::new())
        foreach ($key in $rows.get_Keys()) {
            $next = [List[object]]::new()
            foreach ($partial in $result) {
                foreach ($value in $rows[$key]) {
                    $item = [OrderedDictionary]::new()
                    foreach ($existing in $partial.get_Keys()) { $item[[string]$existing] = $partial[$existing] }
                    $item[[string]$key] = $value
                    $next.Add($item)
                }
            }
            $result = $next
        }
        return $result
    }

    # Returns @{ Values = <List>; Keys = <List or $null> } for a `for:` definition.
    hidden static [hashtable] ForItems([object] $for, [object] $task, [hashtable] $cache) {
        $values = [List[object]]::new()
        $keys = $null
        if ($null -ne $for.Matrix) {
            $rows = [OrderedDictionary]::new()
            foreach ($key in $for.Matrix.get_Keys()) {
                $row = $for.Matrix[$key]
                if (-not [string]::IsNullOrEmpty($row.Ref)) {
                    $resolved = [TaskCompiler]::ResolveRef($cache, $row.Ref, $null)
                    if (-not [TplFn]::IsList($resolved)) {
                        throw [TaskError]::Create("task: matrix reference ($($row.Ref)) must resolve to a list", [TaskError]::TaskfileInvalid)
                    }
                    $rows[[string]$key] = [TplFn]::ToList($resolved)
                }
                else { $rows[[string]$key] = [TplFn]::ToList([TaskCompiler]::ReplaceValue($cache, $row.Value, $null)) }
            }
            return @{ Values = [TaskCompiler]::MatrixProduct($rows); Keys = $null }
        }
        if ($null -ne $for.List) {
            foreach ($item in $for.List) { $values.Add([TaskCompiler]::ReplaceValue($cache, $item, $null)) }
            return @{ Values = $values; Keys = $null }
        }
        if ($for.From -eq 'sources' -or $for.From -eq 'generates') {
            $globs = $task.Generates
            if ($for.From -eq 'sources') { $globs = $task.Sources }
            foreach ($file in [TaskGlob]::Expand($task.Dir, $globs)) { $values.Add([System.IO.Path]::GetRelativePath($task.Dir, $file)) }
            return @{ Values = $values; Keys = $null }
        }
        if (-not [string]::IsNullOrEmpty($for.Var) -and $cache.Vars.Contains($for.Var)) {
            $value = [TplFn]::Unwrap($cache.Vars[$for.Var])
            if ($value -is [string]) {
                $parts = if ([string]::IsNullOrEmpty($for.Split)) { $value.Split([char[]]$null, [StringSplitOptions]::RemoveEmptyEntries) }
                else { $value.Split([string[]]@($for.Split), [StringSplitOptions]::None) }
                foreach ($part in $parts) { $values.Add($part) }
            }
            elseif ($value -is [IDictionary]) {
                $keys = [List[object]]::new()
                foreach ($key in $value.get_Keys()) { $keys.Add([string]$key); $values.Add($value[$key]) }
            }
            elseif ([TplFn]::IsList($value)) { foreach ($item in $value) { $values.Add($item) } }
            elseif ($null -ne $value) {
                throw [TaskError]::Create('task: loop var must be a delimiter-separated string, list or a map', [TaskError]::TaskfileInvalid)
            }
        }
        return @{ Values = $values; Keys = $keys }
    }

    hidden static [object] CopyCmd([object] $cmd) {
        return [pscustomobject]@{
            Cmd = $cmd.Cmd; Task = $cmd.Task; Vars = $cmd.Vars; Silent = $cmd.Silent; IgnoreError = $cmd.IgnoreError; Defer = $cmd.Defer
            Platforms = $cmd.Platforms; Set = $cmd.Set; Shopt = $cmd.Shopt; For = $null; If = $cmd.If; ScriptVars = $null
        }
    }

    # Templates one command. $extra carries loop variables (ITEM, KEY) or EXIT_CODE; the
    # command keeps it as ScriptVars, its own PowerShell variables.
    static [object] ReplaceCmd([hashtable] $cache, [object] $cmd, [IDictionary] $extra) {
        $new = [TaskCompiler]::CopyCmd($cmd)
        $new.Cmd = [TaskCompiler]::Replace($cache, $cmd.Cmd, $extra)
        $new.Task = [TaskCompiler]::Replace($cache, $cmd.Task, $extra)
        $new.If = [TaskCompiler]::Replace($cache, $cmd.If, $extra)
        $new.Vars = [TaskCompiler]::ReplaceVars($cache, $cmd.Vars, $extra)
        $new.ScriptVars = $extra
        return $new
    }

    hidden static [List[object]] LoopExtras([object] $for, [object] $task, [hashtable] $cache) {
        $items = [TaskCompiler]::ForItems($for, $task, $cache)
        $name = if ([string]::IsNullOrEmpty($for.As)) { 'ITEM' } else { $for.As }
        $extras = [List[object]]::new()
        for ($i = 0; $i -lt $items.Values.Count; $i++) {
            $extra = [OrderedDictionary]::new()
            $extra[$name] = $items.Values[$i]
            if ($null -ne $items.Keys) { $extra['KEY'] = $items.Keys[$i] }
            $extras.Add($extra)
        }
        return $extras
    }

    # ---- task compilation ------------------------------------------------------------------

    # Returns a copy of $task with every field templated for $call. With $evaluateSh = $false
    # dynamic variables are not executed (used for listing and for the pre-run checks).
    static [object] Compile([hashtable] $ctx, [object] $task, [object] $call, [bool] $evaluateSh) {
        $declared = [OrderedDictionary]::new()
        $vars = [TaskCompiler]::GetVariables($ctx, $task, $call, $evaluateSh, $declared)
        $scriptVars = [TaskCompiler]::ScriptVars($vars, $declared)
        $cache = [TaskCompiler]::NewCache($vars)
        $dir = [TaskPath]::SmartJoin([string]$ctx.Dir, [TaskPath]::ExpandHome([TaskCompiler]::Replace($cache, $task.Dir, $null)))
        $prefix = [TaskCompiler]::Replace($cache, $task.Prefix, $null)
        if ([string]::IsNullOrEmpty($prefix)) { $prefix = $task.Task }

        $new = [pscustomobject]@{
            Task          = $task.Task
            Label         = [TaskCompiler]::Replace($cache, $task.Label, $null)
            Desc          = [TaskCompiler]::Replace($cache, $task.Desc, $null)
            Prompt        = [TaskCompiler]::ReplaceStrings($cache, $task.Prompt)
            Summary       = [TaskCompiler]::Replace($cache, $task.Summary, $null)
            Aliases       = $task.Aliases
            Sources       = [TaskCompiler]::ReplaceGlobs($cache, $task.Sources)
            Generates     = [TaskCompiler]::ReplaceGlobs($cache, $task.Generates)
            Dir           = $dir
            Set           = $task.Set
            Shopt         = $task.Shopt
            Vars          = $vars
            ScriptVars    = $scriptVars
            Env           = [OrderedDictionary]::new()
            Dotenv        = [TaskCompiler]::ReplaceStrings($cache, $task.Dotenv)
            Silent        = $task.Silent
            Interactive   = $task.Interactive
            Internal      = $task.Internal
            Method        = [TaskCompiler]::Replace($cache, $task.Method, $null)
            Prefix        = $prefix
            IgnoreError   = $task.IgnoreError
            Run           = [TaskCompiler]::Replace($cache, $task.Run, $null)
            Platforms     = $task.Platforms
            If            = [TaskCompiler]::Replace($cache, $task.If, $null)
            Watch         = $task.Watch
            Failfast      = $task.Failfast
            Namespace     = $task.Namespace
            Requires      = $task.Requires
            Location      = $task.Location
            Cmds          = [List[object]]::new()
            Deps          = [List[object]]::new()
            Preconditions = [List[object]]::new()
            Status        = [string[]]@()
        }

        # Environment: Taskfile env, then dotenv files of the task, then task env.
        $envVars = [OrderedDictionary]::new()
        $taskfileEnv = [TaskCompiler]::ReplaceVars($cache, $ctx.Taskfile.Env, $null)
        foreach ($key in $taskfileEnv.get_Keys()) { $envVars[[string]$key] = $taskfileEnv[$key] }
        $dotenv = [OrderedDictionary]::new()
        foreach ($path in $new.Dotenv) {
            $file = [TaskPath]::SmartJoin($dir, $path)
            if (-not [System.IO.File]::Exists($file)) { continue }
            $values = [TaskDotenv]::Read($file)
            foreach ($key in $values.get_Keys()) { if (-not $dotenv.Contains($key)) { $dotenv[[string]$key] = $values[$key] } }
        }
        foreach ($key in $dotenv.get_Keys()) {
            $envVars[[string]$key] = [TaskfileReader]::NewVar('static', [TaskCompiler]::Replace($cache, [string]$dotenv[$key], $null))
        }
        $taskEnv = [TaskCompiler]::ReplaceVars($cache, $task.Env, $null)
        foreach ($key in $taskEnv.get_Keys()) { $envVars[[string]$key] = $taskEnv[$key] }
        foreach ($key in $envVars.get_Keys()) {
            $var = $envVars[$key]
            if ($var.Kind -eq 'sh') {
                if ($evaluateSh) {
                    [TaskCompiler]::ThrowIfError($cache)
                    $varDirectory = if ([string]::IsNullOrEmpty($var.Dir)) { $dir } else { $var.Dir }
                    $new.Env[[string]$key] = [TaskCompiler]::HandleDynamicVar($ctx, $var.Sh, $varDirectory, $new.Env, $scriptVars)
                }
                else { $new.Env[[string]$key] = '' }
            }
            elseif ([TaskCompiler]::IsScalar($var.Value)) { $new.Env[[string]$key] = [TplFn]::Format($var.Value, $false) }
        }

        foreach ($cmd in $task.Cmds) {
            if ($null -ne $cmd.For) {
                foreach ($extra in [TaskCompiler]::LoopExtras($cmd.For, $new, $cache)) { $new.Cmds.Add([TaskCompiler]::ReplaceCmd($cache, $cmd, $extra)) }
                continue
            }
            # Deferred commands are templated when they run: EXIT_CODE is only known then.
            if ($cmd.Defer) { $new.Cmds.Add([TaskCompiler]::CopyCmd($cmd)); continue }
            $new.Cmds.Add([TaskCompiler]::ReplaceCmd($cache, $cmd, $null))
        }

        foreach ($dep in $task.Deps) {
            $extras = [List[object]]::new()
            if ($null -ne $dep.For) { $extras = [TaskCompiler]::LoopExtras($dep.For, $new, $cache) } else { $extras.Add($null) }
            foreach ($extra in $extras) {
                $new.Deps.Add([pscustomobject]@{
                        Task   = [TaskCompiler]::Replace($cache, $dep.Task, $extra)
                        Vars   = [TaskCompiler]::ReplaceVars($cache, $dep.Vars, $extra)
                        Silent = $dep.Silent
                        For    = $null
                    })
            }
        }

        foreach ($precondition in $task.Preconditions) {
            $new.Preconditions.Add([pscustomobject]@{
                    Sh  = [TaskCompiler]::Replace($cache, $precondition.Sh, $null)
                    Msg = [TaskCompiler]::Replace($cache, $precondition.Msg, $null)
                })
        }

        if ($task.Status.Count -gt 0) {
            # CHECKSUM and TIMESTAMP are available to status commands.
            if ($new.Sources.Count -gt 0) {
                try {
                    $vars['CHECKSUM'] = [TaskFingerprint]::Checksum($new)
                    $vars['TIMESTAMP'] = [TaskFingerprint]::Timestamp($new)
                }
                catch { $vars['CHECKSUM'] = '' }
                foreach ($name in 'CHECKSUM', 'TIMESTAMP') { if ($vars.Contains($name)) { $scriptVars[$name] = $vars[$name] } }
            }
            $new.Status = [TaskCompiler]::ReplaceStrings($cache, $task.Status)
        }

        # A task matched through a wildcard is shown under the name it was called with
        # ("start:*" called as "start:web" -> "start:web"), as go-task does.
        if ([string]::IsNullOrEmpty($new.Label) -and $task.Task.Contains('*') -and $null -ne $call -and $call.Vars.Contains('MATCH')) {
            $captured = [TplFn]::ToList($call.Vars['MATCH'].Value)
            $name = [System.Text.StringBuilder]::new()
            $next = 0
            foreach ($character in $task.Task.ToCharArray()) {
                if ($character -eq '*' -and $next -lt $captured.Count) { [void]$name.Append([string]$captured[$next]); $next++ }
                else { [void]$name.Append($character) }
            }
            $new.Label = $name.ToString()
        }

        if ($evaluateSh) { [TaskCompiler]::ThrowIfError($cache) }
        return $new
    }
}