Private/Runner.ps1

using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Management.Automation

# Runs the PowerShell source of commands, dynamic variables and conditions.
#
# Execution model
# ---------------
# * Every task run owns a scope (a dynamic module). Its commands are dot-sourced into that
# scope, so variables and functions defined by one command are visible to the next one,
# but never to other tasks.
# * User code always runs in a *nested* pipeline of the engine runspace. `exit N` inside a
# nested pipeline only ends that pipeline and sets $LASTEXITCODE - it cannot close the
# session of the caller.
# * In 'host' mode the pipeline ends in Out-Default with errors merged, exactly like a
# command typed at the prompt. Native commands therefore keep the real console handles
# (colors, progress bars and interactive prompts work).
# * The variables of the Taskfile are defined as PowerShell variables of the scope, so user
# code can write `$NAME` as well as `{{.NAME}}`. Environment variables are not: they are
# `$env:NAME` already.

[NoRunspaceAffinity()]
class TaskEnv {
    # A snapshot of the process environment (name -> value).
    static [hashtable] Snapshot() {
        $comparer = if ($global:IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal }
        $snapshot = [hashtable]::new($comparer)
        $current = [Environment]::GetEnvironmentVariables()
        foreach ($key in $current.get_Keys()) { $snapshot[[string]$key] = [string]$current[$key] }
        return $snapshot
    }

    # Sets or removes ($null) a process environment variable. PowerShell turns $null into an
    # empty string for [string] parameters, which would create an empty variable instead of
    # removing it - hence [NullString].
    static [void] Set([string] $name, [object] $value) {
        if ($null -eq $value) { [Environment]::SetEnvironmentVariable($name, [NullString]::Value) }
        else { [Environment]::SetEnvironmentVariable($name, [string]$value) }
    }

    # Makes the process environment equal to the snapshot again.
    static [void] Restore([hashtable] $snapshot) {
        $current = [TaskEnv]::Snapshot()
        foreach ($key in $current.get_Keys()) {
            if (-not $snapshot.ContainsKey($key)) { [TaskEnv]::Set($key, $null) }
        }
        foreach ($key in $snapshot.get_Keys()) {
            if (-not $current.ContainsKey($key) -or [string]$current[$key] -cne [string]$snapshot[$key]) {
                [TaskEnv]::Set($key, $snapshot[$key])
            }
        }
    }

    # The gate serializes commands whose declared environments differ. The environment is
    # process-wide, so two commands may only overlap when they need exactly the same overrides.
    static [hashtable] NewGate() {
        return @{ Lock = [object]::new(); Active = 0; Signature = ''; Saved = $null }
    }

    hidden static [string] Signature([IDictionary] $overrides) {
        if ($null -eq $overrides -or $overrides.get_Count() -eq 0) { return '' }
        $keys = [List[string]]::new()
        foreach ($key in $overrides.get_Keys()) { $keys.Add([string]$key) }
        $keys.Sort([StringComparer]::Ordinal)
        $sb = [System.Text.StringBuilder]::new()
        foreach ($key in $keys) { [void]$sb.Append($key).Append('=').Append([string]$overrides[$key]).Append([char]0) }
        return $sb.ToString()
    }

    static [void] Enter([hashtable] $gate, [IDictionary] $overrides, [System.Threading.CancellationToken] $cancel) {
        $signature = [TaskEnv]::Signature($overrides)
        [System.Threading.Monitor]::Enter($gate.Lock)
        try {
            while ($gate.Active -gt 0 -and $gate.Signature -cne $signature) {
                $null = [System.Threading.Monitor]::Wait($gate.Lock, 50)
                $cancel.ThrowIfCancellationRequested()
            }
            if ($gate.Active -eq 0) {
                $saved = [ordered]@{}
                if ($null -ne $overrides) {
                    foreach ($key in $overrides.get_Keys()) {
                        $saved[[string]$key] = [Environment]::GetEnvironmentVariable([string]$key)
                        [TaskEnv]::Set([string]$key, [string]$overrides[$key])
                    }
                }
                $gate.Saved = $saved
                $gate.Signature = $signature
            }
            $gate.Active++
        }
        finally { [System.Threading.Monitor]::Exit($gate.Lock) }
    }

    static [void] Leave([hashtable] $gate) {
        [System.Threading.Monitor]::Enter($gate.Lock)
        try {
            $gate.Active--
            if ($gate.Active -le 0) {
                $gate.Active = 0
                if ($null -ne $gate.Saved) {
                    foreach ($key in $gate.Saved.get_Keys()) { [TaskEnv]::Set([string]$key, $gate.Saved[$key]) }
                }
                $gate.Saved = $null
                $gate.Signature = ''
                [System.Threading.Monitor]::PulseAll($gate.Lock)
            }
        }
        finally { [System.Threading.Monitor]::Exit($gate.Lock) }
    }
}

[NoRunspaceAffinity()]
class TaskScript {
    hidden static [string] $HostScript = 'param($scope, $code) . $scope $code 2>&1 | Out-Default'

    hidden static [string] $CaptureScript = 'param($scope, $code) . $scope $code'

    hidden static [string] $StreamScript = @'
param($scope, $code, $sink)
. $scope $code *>&1 | ForEach-Object { $sink.Add([System.Collections.DictionaryEntry]::new('x', $_)) }
'@


    hidden static [string] $LineScript = @'
param($scope, $code, $emit)
. $scope $code *>&1 | Out-String -Stream | ForEach-Object {
    if ($null -ne $emit.Buffer) { $emit.Buffer.Add($_) }
    elseif ($null -ne $emit.Sink) { $emit.Sink.Add([System.Collections.DictionaryEntry]::new('o', $emit.Prefix + $_)) }
    else { $emit.UI.WriteLine($emit.Prefix + $_) }
}
'@


    # Names that belong to PowerShell (automatic and preference variables). A Taskfile variable
    # with one of these names is not defined as a PowerShell variable; `{{.NAME}}` still works.
    hidden static [HashSet[string]] $Reserved = [HashSet[string]]::new([string[]]@(
            '_', 'args', 'ConfirmPreference', 'ConsoleFileName', 'DebugPreference', 'EnabledExperimentalFeatures',
            'Error', 'ErrorActionPreference', 'ErrorView', 'Event', 'EventArgs', 'EventSubscriber', 'ExecutionContext',
            'false', 'foreach', 'FormatEnumerationLimit', 'HOME', 'Host', 'InformationPreference', 'input',
            'IsCoreCLR', 'IsLinux', 'IsMacOS', 'IsWindows', 'LASTEXITCODE', 'Matches', 'MaximumHistoryCount',
            'MyInvocation', 'NestedPromptLevel', 'null', 'OFS', 'OutputEncoding', 'PID', 'PROFILE', 'ProgressPreference',
            'PSBoundParameters', 'PSCmdlet', 'PSCommandPath', 'PSCulture', 'PSDebugContext', 'PSDefaultParameterValues',
            'PSEdition', 'PSEmailServer', 'PSHOME', 'PSItem', 'PSModuleAutoLoadingPreference',
            'PSNativeCommandArgumentPassing', 'PSNativeCommandUseErrorActionPreference', 'PSScriptRoot', 'PSSenderInfo',
            'PSSessionApplicationName', 'PSSessionConfigurationName', 'PSSessionOption', 'PSStyle', 'PSUICulture',
            'PSVersionTable', 'PWD', 'Sender', 'ShellId', 'StackTrace', 'switch', 'this', 'Transcript', 'true',
            'VerbosePreference', 'WarningPreference', 'WhatIfPreference'
        ), [StringComparer]::OrdinalIgnoreCase)

    hidden static [regex] $SimpleName = [regex]::new('^[\p{L}\p{Nd}_]+$')

    # A fresh scope for one task run (or one dynamic variable / condition).
    static [object] NewScope() {
        $scope = [psmoduleinfo]::new($true)
        $scope.SessionState.PSVariable.Set('ErrorActionPreference', [ActionPreference]::Stop)
        return $scope
    }

    static [object] NewScope([IDictionary] $variables) {
        $scope = [TaskScript]::NewScope()
        $null = [TaskScript]::SetVariables($scope, $variables)
        return $scope
    }

    # A deep copy for user code: lists become arrays, maps (case-sensitive) ordered dictionaries.
    # The originals are shared by the whole run and must not be changed by a command.
    hidden static [object] ToScriptValue([object] $value) {
        $v = $value
        if ($v -is [psobject] -and $v -isnot [System.Management.Automation.PSCustomObject]) { $v = $v.psobject.BaseObject }
        if ($v -is [IDictionary]) {
            $map = [System.Collections.Specialized.OrderedDictionary]::new()
            foreach ($key in @($v.get_Keys())) { $map[[string]$key] = [TaskScript]::ToScriptValue($v[$key]) }
            return $map
        }
        if ($null -ne $v -and $v -isnot [string] -and $v -is [IEnumerable]) {
            $items = [List[object]]::new()
            foreach ($item in $v) { $items.Add([TaskScript]::ToScriptValue($item)) }
            return $items.ToArray()
        }
        return $v
    }

    # Defines Taskfile variables as PowerShell variables of $scope, so that commands can use
    # `$NAME` next to `{{.NAME}}`. Returns the names left out because PowerShell reserves them.
    static [string[]] SetVariables([object] $scope, [IDictionary] $variables) {
        $skipped = [List[string]]::new()
        if ($null -eq $variables) { return $skipped.ToArray() }
        foreach ($key in $variables.get_Keys()) {
            $name = [string]$key
            if (-not [TaskScript]::SimpleName.IsMatch($name)) { continue }
            if ([TaskScript]::Reserved.Contains($name)) { $skipped.Add($name); continue }
            try { $scope.SessionState.PSVariable.Set($name, [TaskScript]::ToScriptValue($variables[$key])) }
            catch { $skipped.Add($name) }
        }
        return $skipped.ToArray()
    }

    # The names of the PowerShell variables a script refers to (`$name`, `@name`, `$script:name`;
    # not `$env:NAME`). Empty when the script does not parse.
    static [string[]] VariableNames([string] $text) {
        $names = [List[string]]::new()
        if ([string]::IsNullOrEmpty($text) -or -not ($text.Contains('$') -or $text.Contains('@'))) { return $names.ToArray() }
        $tokens = $null
        $errors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref] $tokens, [ref] $errors)
        $seen = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        foreach ($node in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] }, $true)) {
            if (-not $node.VariablePath.IsVariable) { continue }
            # UserPath keeps a scope qualifier: script:name -> name
            $name = $node.VariablePath.UserPath
            $name = $name.Substring($name.LastIndexOf(':') + 1)
            if ($seen.Add($name)) { $names.Add($name) }
        }
        return $names.ToArray()
    }

    hidden static [bool] IsStop([Exception] $exception) {
        $current = $exception
        while ($null -ne $current) {
            if ($current -is [PipelineStoppedException] -or $current -is [OperationCanceledException]) { return $true }
            $current = $current.InnerException
        }
        return $false
    }

    hidden static [ErrorRecord] ToErrorRecord([ErrorRecord] $caught) {
        $exception = $caught.Exception
        while ($null -ne $exception.InnerException -and
            ($exception -is [MethodInvocationException] -or $exception -is [System.Reflection.TargetInvocationException])) {
            $exception = $exception.InnerException
        }
        if ($exception -is [IContainsErrorRecord] -and $null -ne $exception.ErrorRecord) { return $exception.ErrorRecord }
        return [ErrorRecord]::new($exception, 'TaskCommandFailed', [ErrorCategory]::NotSpecified, $null)
    }

    # Runs PowerShell source text inside $scope with $directory as the current location.
    #
    # $mode host - render everything on the host (Out-Default)
    # capture - return the success output in Output
    # stream - send every record (all streams) to $target, a sink collection
    # lines - send formatted text lines to $target (@{ Buffer; Sink; UI; Prefix })
    #
    # Returns ExitCode (0 = success), Message (failure reason), Error (ErrorRecord) and Output.
    # A command fails when it throws a terminating error, calls `exit N` with N != 0, or
    # leaves $LASTEXITCODE non-zero (an unhandled native command failure).
    static [object] Invoke([string] $text, [object] $scope, [string] $directory, [string] $mode, [object] $target) {
        $result = [pscustomobject]@{ ExitCode = 0; Message = ''; Error = $null; Output = $null }
        $code = $null
        try { $code = [scriptblock]::Create($text) }
        catch {
            $result.Error = [TaskScript]::ToErrorRecord($_)
            $result.ExitCode = 1
            $result.Message = $result.Error.Exception.Message
            return $result
        }
        if (-not [string]::IsNullOrEmpty($directory) -and [System.IO.Directory]::Exists($directory)) {
            Set-Location -LiteralPath $directory
        }
        $global:LASTEXITCODE = 0
        $wrapper = switch ($mode) {
            'host' { [TaskScript]::HostScript }
            'stream' { [TaskScript]::StreamScript }
            'lines' { [TaskScript]::LineScript }
            default { [TaskScript]::CaptureScript }
        }
        $shell = [powershell]::Create([RunspaceMode]::CurrentRunspace)
        try {
            [void]$shell.AddScript($wrapper, $true).AddArgument($scope).AddArgument($code)
            if ($mode -eq 'stream' -or $mode -eq 'lines') { [void]$shell.AddArgument($target) }
            try {
                $output = $shell.Invoke()
                if ($mode -eq 'capture') { $result.Output = $output }
            }
            catch {
                if ([TaskScript]::IsStop($_.Exception)) { throw }
                $result.Error = [TaskScript]::ToErrorRecord($_)
                $result.ExitCode = 1
                $result.Message = $result.Error.Exception.Message
            }
        }
        finally { $shell.Dispose() }

        if ($null -eq $result.Error) {
            $last = $global:LASTEXITCODE
            if ($last -is [int] -and $last -ne 0) {
                $result.ExitCode = $last
                $result.Message = "exit status $last"
            }
        }
        return $result
    }

    # Converts captured output to the string value of a dynamic variable.
    static [string] OutputToString([object] $output) {
        $parts = [List[string]]::new()
        if ($null -ne $output) {
            foreach ($item in $output) {
                if ($null -eq $item) { $parts.Add(''); continue }
                $parts.Add([LanguagePrimitives]::ConvertTo($item, [string], [cultureinfo]::InvariantCulture))
            }
        }
        $text = [string]::Join("`n", $parts)
        if ($text.EndsWith("`r`n")) { return $text.Substring(0, $text.Length - 2) }
        if ($text.EndsWith("`n")) { return $text.Substring(0, $text.Length - 1) }
        return $text
    }

    # Evaluates a condition (status / precondition / if). It holds when the script succeeds
    # and its last output is not the boolean $false - so both `Test-Path x` and exit codes work.
    # The literals true / false are accepted too: Taskfiles commonly use a template that
    # renders to one of them (`if: '{{eq .ENV "prod"}}'`) and rely on the shell builtins.
    static [bool] Test([string] $text, [string] $directory, [IDictionary] $variables) {
        $literal = $text.Trim()
        if ($literal -ceq 'true') { return $true }
        if ($literal -ceq 'false') { return $false }
        $result = [TaskScript]::Invoke($text, [TaskScript]::NewScope($variables), $directory, 'capture', $null)
        if ($result.ExitCode -ne 0) { return $false }
        $last = $null
        if ($null -ne $result.Output) { foreach ($item in $result.Output) { $last = $item } }
        if ($last -is [psobject]) { $last = $last.psobject.BaseObject }
        if ($last -is [bool] -and -not $last) { return $false }
        return $true
    }
}