Invoke-NativeApplication.psm1

$csPath = Join-Path -Path $PSScriptRoot -ChildPath 'OutputLine.cs'
Add-Type -Path $csPath

# Maps each out parameter to the key it reads from the capture hashtable filled by
# Invoke-NativeApplicationCore. Both public functions share this map.
$script:CaptureVariableParameters = @{
    StdOutVariable    = 'StdOut'
    StdErrVariable    = 'StdErr'
    OutputVariable    = 'Output'
    ExitCodeVariable  = 'ExitCode'
    SuccessVariable   = 'Success'
    DurationVariable  = 'Duration'
    StartTimeVariable = 'StartTime'
    EndTimeVariable   = 'EndTime'
    CommandVariable   = 'Command'
}

# Out parameters that require the output lines to be buffered.
$script:LineCaptureParameters = @('StdOutVariable', 'StdErrVariable', 'OutputVariable')

<#
.SYNOPSIS
    Invokes a native application with proper STDERR handling and exit code validation.

.DESCRIPTION
    Executes a native application (external command) via a ScriptBlock, captures both
    STDOUT and STDERR streams, and validates the process exit code. Each output line is
    returned as a InvokeNativeApplication.OutputLine object that behaves like a string but carries
    an IsError property indicating whether it originated from STDERR.

    When called from the PowerShell prompt, STDERR is not redirected so that it displays
    with the default console formatting. When called from a script, STDERR is captured
    via 2>&1 redirection. Requesting -StdErrVariable or -OutputVariable forces the
    redirection even at the prompt, because the STDERR lines cannot be captured otherwise.

    Every out parameter takes the NAME of a variable (without the $ sign, although a
    leading $ is tolerated) and sets that variable in the caller's scope, following the
    convention of the built-in -OutVariable and -ErrorVariable common parameters. The
    variables are set even when the function throws on a disallowed exit code, so a
    caller that wraps the call in try/catch can still inspect what was captured.

.PARAMETER ScriptBlock
    The script block containing the native application invocation.

.PARAMETER ArgumentList
    A hashtable of arguments to splat into the script block.

.PARAMETER AllowedExitCodes
    An array of exit codes considered successful. Defaults to @(0).

.PARAMETER IgnoreExitCode
    When specified, the function does not throw on non-zero exit codes.

.PARAMETER StdOutVariable
    Name of the variable receiving the STDOUT lines only, as an
    InvokeNativeApplication.OutputLine array.

.PARAMETER StdErrVariable
    Name of the variable receiving the STDERR lines only, as an
    InvokeNativeApplication.OutputLine array. Specifying it forces STDERR redirection
    even when called from the prompt.

.PARAMETER OutputVariable
    Name of the variable receiving all lines in their original interleaved order, as an
    InvokeNativeApplication.OutputLine array. This is the same sequence the function
    returns. Specifying it forces STDERR redirection even when called from the prompt.

.PARAMETER ExitCodeVariable
    Name of the variable receiving the exit code of the native application, or $null when
    the script block did not run a native application at all.

.PARAMETER SuccessVariable
    Name of the variable receiving $true when the exit code is one of AllowedExitCodes
    (or when there is no exit code at all), $false otherwise. Unlike the throwing
    behavior, it is not affected by -IgnoreExitCode.

.PARAMETER DurationVariable
    Name of the variable receiving the execution time as a TimeSpan.

.PARAMETER StartTimeVariable
    Name of the variable receiving the DateTime the execution started at.

.PARAMETER EndTimeVariable
    Name of the variable receiving the DateTime the execution finished at.

.PARAMETER CommandVariable
    Name of the variable receiving the executed script block and its splatted arguments
    rendered as a string, suitable for logging.

.EXAMPLE
    Invoke-NativeApplication { git status }

    Runs 'git status' and throws if git returns a non-zero exit code.

.EXAMPLE
    Invoke-NativeApplication { robocopy source dest /MIR } -AllowedExitCodes @(0, 1)

    Treats exit codes 0 and 1 as successful.

.EXAMPLE
    Invoke-NativeApplication { robocopy source dest /MIR } -AllowedExitCodes (0..3)

    Treats exit codes 0 through 3 as successful using the range operator.

.EXAMPLE
    Invoke-NativeApplication { robocopy source dest /MIR } -AllowedExitCodes ((0..3) + (8, 10) + (20..30))

    Combines ranges with individual codes. Treats 0-3, 8, 10, and 20-30 as successful.

.EXAMPLE
    $output = Invoke-NativeApplication { dotnet build } -IgnoreExitCode
    $errors = $output | Where-Object { $_.IsError }

    Captures all output including errors without throwing, then filters
    for lines that came from STDERR.

.EXAMPLE
    Invoke-NativeApplication { dotnet build } -IgnoreExitCode -StdOutVariable 'out' -StdErrVariable 'err' -ExitCodeVariable 'code' | Out-Null
    if ($code -ne 0) { $err }

    Captures the streams separately into $out and $err and the exit code into $code.

.EXAMPLE
    try {
        Invoke-NativeApplication { dotnet build } -StdErrVariable 'err' -DurationVariable 'duration'
    } catch {
        Write-Warning -Message ('Build failed after {0} with {1} error lines' -f $duration, $err.Count)
    }

    The out variables are set before the exit code exception is thrown, so they are
    available in the catch block.

.OUTPUTS
    InvokeNativeApplication.OutputLine
    One object per output line. Each behaves like a string but carries
    an IsError property indicating whether it originated from STDERR.

.NOTES
    Alias: exec

.LINK
    https://mnaoumov.wordpress.com/2015/01/11/execution-of-external-commands-in-powershell-done-right/

.LINK
    https://mnaoumov.wordpress.com/2015/03/31/execution-of-external-commands-native-applications-in-powershell-done-right-part-2/
#>

function Invoke-NativeApplication {
    [CmdletBinding()]
    param(
        [Parameter(Position=0)][ScriptBlock] $ScriptBlock,
        [Parameter(Position=1)][HashTable] $ArgumentList,
        [Parameter()][int[]] $AllowedExitCodes = @(0),
        [Parameter()][switch] $IgnoreExitCode,
        [Parameter()][string] $StdOutVariable,
        [Parameter()][string] $StdErrVariable,
        [Parameter()][string] $OutputVariable,
        [Parameter()][string] $ExitCodeVariable,
        [Parameter()][string] $SuccessVariable,
        [Parameter()][string] $DurationVariable,
        [Parameter()][string] $StartTimeVariable,
        [Parameter()][string] $EndTimeVariable,
        [Parameter()][string] $CommandVariable
    )

    $capture = @{}

    try {
        Invoke-NativeApplicationCore `
            -ScriptBlock $ScriptBlock `
            -ArgumentList $ArgumentList `
            -AllowedExitCodes $AllowedExitCodes `
            -IgnoreExitCode:$IgnoreExitCode `
            -Capture $capture `
            -CaptureLines:(Test-CaptureRequested -BoundParameters $PSBoundParameters -ParameterName $script:LineCaptureParameters) `
            -CaptureStdErr:(Test-CaptureRequested -BoundParameters $PSBoundParameters -ParameterName @('StdErrVariable', 'OutputVariable'))
    } finally {
        Set-CaptureVariable -SessionState $PSCmdlet.SessionState -BoundParameters $PSBoundParameters -Capture $capture
    }
}

<#
.SYNOPSIS
    Invokes a native application, ignoring exit codes and filtering out STDERR lines.

.DESCRIPTION
    A convenience wrapper around Invoke-NativeApplication that always ignores the exit
    code and returns only STDOUT lines (lines where IsError is false). Useful when you
    want to silently capture the successful output of a command without error noise.

    It supports the same out parameters, so the exit code and the STDERR lines it filters
    out of the return value are still available. See Invoke-NativeApplication for their
    description.

.PARAMETER ScriptBlock
    The script block containing the native application invocation.

.PARAMETER ArgumentList
    A hashtable of arguments to splat into the script block.

.PARAMETER StdOutVariable
    Name of the variable receiving the STDOUT lines only. Same as the returned lines.

.PARAMETER StdErrVariable
    Name of the variable receiving the STDERR lines only, i.e. the lines filtered out of
    the return value.

.PARAMETER OutputVariable
    Name of the variable receiving all lines in their original interleaved order.

.PARAMETER ExitCodeVariable
    Name of the variable receiving the exit code of the native application, or $null when
    the script block did not run a native application at all.

.PARAMETER SuccessVariable
    Name of the variable receiving $true when the exit code is 0 (or when there is no exit
    code at all), $false otherwise.

.PARAMETER DurationVariable
    Name of the variable receiving the execution time as a TimeSpan.

.PARAMETER StartTimeVariable
    Name of the variable receiving the DateTime the execution started at.

.PARAMETER EndTimeVariable
    Name of the variable receiving the DateTime the execution finished at.

.PARAMETER CommandVariable
    Name of the variable receiving the executed script block and its splatted arguments
    rendered as a string, suitable for logging.

.EXAMPLE
    $branches = Invoke-NativeApplicationSafe { git branch }

    Gets the list of git branches, ignoring any STDERR output and exit code.

.EXAMPLE
    $branches = Invoke-NativeApplicationSafe { git branch } -ExitCodeVariable 'code' -StdErrVariable 'err'
    if ($code -ne 0) { Write-Warning -Message ($err -join [System.Environment]::NewLine) }

    Keeps the filtered output as the return value while still inspecting the exit code
    and the discarded STDERR lines.

.OUTPUTS
    InvokeNativeApplication.OutputLine
    One object per STDOUT line. Each behaves like a string with IsError
    always set to False (STDERR lines are filtered out).

.NOTES
    Alias: safeexec
#>

function Invoke-NativeApplicationSafe {
    [CmdletBinding()]
    param(
        [Parameter(Position=0)][ScriptBlock] $ScriptBlock,
        [Parameter(Position=1)][HashTable] $ArgumentList,
        [Parameter()][string] $StdOutVariable,
        [Parameter()][string] $StdErrVariable,
        [Parameter()][string] $OutputVariable,
        [Parameter()][string] $ExitCodeVariable,
        [Parameter()][string] $SuccessVariable,
        [Parameter()][string] $DurationVariable,
        [Parameter()][string] $StartTimeVariable,
        [Parameter()][string] $EndTimeVariable,
        [Parameter()][string] $CommandVariable
    )

    $capture = @{}

    try {
        Invoke-NativeApplicationCore `
            -ScriptBlock $ScriptBlock `
            -ArgumentList $ArgumentList `
            -AllowedExitCodes @(0) `
            -IgnoreExitCode `
            -Capture $capture `
            -CaptureLines:(Test-CaptureRequested -BoundParameters $PSBoundParameters -ParameterName $script:LineCaptureParameters) `
            -CaptureStdErr:(Test-CaptureRequested -BoundParameters $PSBoundParameters -ParameterName @('StdErrVariable', 'OutputVariable')) |
            Where-Object -FilterScript { -not $_.IsError }
    } finally {
        Set-CaptureVariable -SessionState $PSCmdlet.SessionState -BoundParameters $PSBoundParameters -Capture $capture
    }
}

<#
.SYNOPSIS
    Shared implementation behind Invoke-NativeApplication and Invoke-NativeApplicationSafe.

.DESCRIPTION
    Runs the script block, streams the output lines and fills the Capture hashtable. The
    hashtable is filled in a finally block, so it is populated even when the script block
    itself throws or when the exit code check throws.

    The capture is deliberately passed as a hashtable rather than as variable names,
    because $PSCmdlet.SessionState.PSVariable.Set() reaches the immediate caller only. A
    name forwarded from a public function would land in the module's scope instead of the
    end user's one.

.PARAMETER Capture
    Hashtable filled with the captured data. Owned by the calling public function.

.PARAMETER CaptureLines
    When specified, the output lines are buffered so that they can be captured. Without
    it the lines are only streamed to the pipeline.

.PARAMETER CaptureStdErr
    When specified, STDERR is redirected even when called from the prompt, so that the
    STDERR lines can be captured.
#>

function Invoke-NativeApplicationCore {
    [CmdletBinding()]
    param(
        [Parameter()][ScriptBlock] $ScriptBlock,
        [Parameter()][HashTable] $ArgumentList,
        [Parameter()][int[]] $AllowedExitCodes = @(0),
        [Parameter()][switch] $IgnoreExitCode,
        [Parameter(Mandatory=$true)][HashTable] $Capture,
        [Parameter()][switch] $CaptureLines,
        [Parameter()][switch] $CaptureStdErr
    )

    $backupErrorActionPreference = $ErrorActionPreference
    $command = $ScriptBlock.ToString().Trim()

    if (($null -ne $ArgumentList) -and ($ArgumentList.Count -gt 0)) {
        $command = '{0} with parameters {1}' -f $command, ([PSCustomObject] $ArgumentList)
    }

    $startTime = Get-Date
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
    $exitCode = $null
    $capturedOutput = $null

    if ($CaptureLines) {
        $capturedOutput = New-Object -TypeName System.Collections.ArrayList
    }

    $ErrorActionPreference = "Continue"
    try {
        Write-Verbose -Message ('Executing native application {0}' -f $command)
        if ($CaptureStdErr -or (-not (Test-CalledFromPrompt))) {
            $wrapperScriptBlock = { & $ScriptBlock @ArgumentList 2>&1 }.GetNewClosure()
        } else {
            $wrapperScriptBlock = { & $ScriptBlock @ArgumentList }.GetNewClosure()
        }

        & $wrapperScriptBlock | ForEach-Object -Process {
            $isError = $_ -is [System.Management.Automation.ErrorRecord]

            if ($isError) {
                $message = $_.Exception.Message
            } else {
                $message = "$_"
            }

            $outputLine = New-Object -TypeName InvokeNativeApplication.OutputLine -ArgumentList $message, $isError

            if ($null -ne $capturedOutput) {
                $null = $capturedOutput.Add($outputLine)
            }

            $outputLine
        }

        if (Test-Path -Path Variable:LASTEXITCODE) {
            $exitCode = $LASTEXITCODE
        }

        if ((-not $IgnoreExitCode) -and ($null -ne $exitCode) -and ($AllowedExitCodes -notcontains $exitCode)) {
            throw ('Native application {0} failed at {1} with exit code {2}' -f
                $command, (Get-PSCallStack -ErrorAction SilentlyContinue)[2].Location, $exitCode)
        }
    } finally {
        $stopwatch.Stop()
        $ErrorActionPreference = $backupErrorActionPreference

        $Capture['Command'] = $command
        $Capture['StartTime'] = $startTime
        $Capture['EndTime'] = $startTime + $stopwatch.Elapsed
        $Capture['Duration'] = $stopwatch.Elapsed
        $Capture['ExitCode'] = $exitCode
        $Capture['Success'] = ($null -eq $exitCode) -or ($AllowedExitCodes -contains $exitCode)

        if ($null -ne $capturedOutput) {
            $Capture['Output'] = $capturedOutput.ToArray()
            $Capture['StdOut'] = @($capturedOutput | Where-Object -FilterScript { -not $_.IsError })
            $Capture['StdErr'] = @($capturedOutput | Where-Object -FilterScript { $_.IsError })
        }
    }
}

<#
.SYNOPSIS
    Tells whether any of the given out parameters was passed a non-empty variable name.
#>

function Test-CaptureRequested {
    param(
        [Parameter()][HashTable] $BoundParameters,
        [Parameter()][string[]] $ParameterName
    )

    foreach ($name in $ParameterName) {
        if ($BoundParameters.ContainsKey($name) -and (-not [string]::IsNullOrEmpty($BoundParameters[$name]))) {
            return $true
        }
    }

    return $false
}

<#
.SYNOPSIS
    Sets the requested out variables in the caller's scope.

.DESCRIPTION
    $PSCmdlet.SessionState of the public function refers to the session state of its
    caller, which is what makes the variables land in the end user's scope.
#>

function Set-CaptureVariable {
    param(
        [Parameter()][System.Management.Automation.SessionState] $SessionState,
        [Parameter()][HashTable] $BoundParameters,
        [Parameter()][HashTable] $Capture
    )

    foreach ($parameterName in $script:CaptureVariableParameters.Keys) {
        if (-not (Test-CaptureRequested -BoundParameters $BoundParameters -ParameterName $parameterName)) {
            continue
        }

        $variableName = ([string] $BoundParameters[$parameterName]).TrimStart('$')
        $captureKey = $script:CaptureVariableParameters[$parameterName]

        if ($Capture.ContainsKey($captureKey)) {
            $value = $Capture[$captureKey]
        } else {
            $value = $null
        }

        $SessionState.PSVariable.Set($variableName, $value)
    }
}

function Test-CalledFromPrompt {
    foreach ($frame in Get-PSCallStack) {
        if ($frame.Command -eq "prompt") {
            return $true
        }
    }

    return $false
}

Set-Alias -Name exec -Value Invoke-NativeApplication
Set-Alias -Name safeexec -Value Invoke-NativeApplicationSafe