powershellfunctionscopeglobal

Can a function be created in the global scope from a .ps1 script's script block?


Consider a PowerShell script named s.ps1 with content

1/0

and the hypothetical function

NAME
    New-GlobalFunction

SYNOPSIS
    Creates a new function in global scope from a script.

SYNTAX
    New-GlobalFunction [-ScriptPath] <string> [-FunctionName] <string>

I am hoping to be able to define global function Invoke-S using New-GlobalFunction such that Invoke-S produces error position information showing the correct file similar to the following:

PS C:\> New-GlobalFunction -ScriptPath .\s.p1 -FunctionName Invoke-S; Invoke-S
RuntimeException: C:\s.ps1:1
Line |
   1 |  1/0
     |  ~~~
     | Attempted to divide by zero.
----------

Attempted to divide by zero.
at <ScriptBlock>, C:\s.ps1: line 1

Note the mention of the original file c.ps1.

The architecture of PowerShell suggests this ought to be possible since scripts and functions are just script blocks:

In the PowerShell programming language, a script block is a collection of statements or expressions that can be used as a single unit. The collection of statements can be enclosed in braces ({}), defined as a function, or saved in a script file.

Indeed the script block defined by s.ps1 is accessible by Get-Command .\s.ps1 | % ScriptBlock. The error with the correct location information above was produced by invoking that script block. In fact, that script block can be turned into a local function with the command

New-Item -Path Function: -Name Invoke-S -Value (Get-Command .\s.ps1 | % ScriptBlock)

I have not, however, succeeded at creating such a global function.

Is there a way to define New-GlobalFunction such that an error produced by the script block defined in the file at -ScriptPath contains the correct file in its error position message?

Methods that don't work

The code below attempts the different methods I have tried for creating Invoke-S. It outputs the following:

method                    new_error                 invoke_error                           stacktrace
------                    ---------                 ------------                           ----------
iex function global                                 Attempted to divide by zero.           at global:Invoke-S, <No file>…
function global: $sb      Missing function body in… The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…
New-Item Function:                                  The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…
New-Item global:Function: Cannot find drive. A dri… The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…
Get-Variable function:    Cannot find a variable w… The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…

These methods either do not create the function such that it is available in the caller's scope (error messages 'Invoke-S' is not recognized, or the stack trace does not mention the correct file (error message at global:Invoke-S, <No file>).

function New-GlobalFunction {
param(
    [Parameter(Mandatory)][string]   $ScriptPath  ,
    [Parameter(Mandatory)][string]   $FunctionName,
    [Parameter(Mandatory)][ValidateSet(
        'iex function global'      ,
        'function global: $sb'     ,
        'New-Item Function:'       ,
        'New-Item global:Function:',
        'Get-Variable function:'    )]$Method
)
switch ($Method) {
    'iex function global' {
        Invoke-Expression `
            -Command "function global:$FunctionName {
                        $(Get-Content $ScriptPath)
                     }"
    }
    'function global: $sb' {
        $sb =
            Get-Command           `
                -Name $ScriptPath |
            % ScriptBlock
        # function global:Invoke-S $sb
        throw 'Missing function body in function declaration.'
    }
    'New-Item Function:' {
        New-Item                       `
            -ErrorAction Stop          `
            -Path        Function:     `
            -Name        $FunctionName `
            -Value (Get-Command $ScriptPath |
                    % ScriptBlock            )
    }
    'New-Item global:Function:' {
        New-Item                    `
            -ErrorAction Stop       `
            -Path  global:Function: `
            -Name  $FunctionName    `
            -Value (Get-Command $ScriptPath |
                    % ScriptBlock            )
    }
    'Get-Variable function:' {
        Get-Variable          `
            -Name 'function:' `
            -ErrorAction Stop
    }
}}

$(foreach ($method in 'iex function global'         ,
                      'function global: $sb',
                      'New-Item Function:'          ,
                      'New-Item global:Function:'   ,
                      'Get-Variable function:'       ) {
    [pscustomobject]@{
        method       = $method
        new_error    = $(try {$null =
                              New-GlobalFunction       `
                                -ScriptPath  .\s.ps1   `
                                -FunctionName Invoke-S `
                                -Method       $method   }
                        catch {$_})
        invoke_error = ($e =
                       try {Invoke-S -ErrorAction Stop}
                       catch {$_}     )
        stacktrace   = $e.ScriptStackTrace
        e            = $e
    }
    Remove-Item Function:Invoke-S -ErrorAction SilentlyContinue
}) |
    Format-Table `
        -Property @{e='method'      ;width=25},
                  @{e='new_error'   ;width=25},
                  @{e='invoke_error';width=38},
                  @{e='stacktrace'  ;width=30}

References


Solution

  • The following, using namespace variable notation, seems to work:

    $FunctionName = 'Invoke-S'
    $ScriptPath = '.\s.ps1'
    Invoke-Expression @"
    `${Function:global:$FunctionName} = (Get-Command "$ScriptPath").ScriptBlock
    "@
    

    The cmdlet-based equivalent of the above is actually simpler in this case, because the function name can directly be expressed in terms of a variable:

    $FunctionName = 'Invoke-S'
    $ScriptPath = '.\s.ps1'
    Set-Content Function:global:$FunctionName (Get-Command $ScriptPath).ScriptBlock
    

    In the context of your function:

    function New-GlobalFunction {
      param(
        [Parameter(Mandatory)] [string] $ScriptPath,
        [Parameter(Mandatory)] [string] $FunctionName
      )
      Set-Content Function:global:$FunctionName (Get-Command $ScriptPath).ScriptBlock
    }
    
    # Sample call
    New-GlobalFunction -ScriptPath .\s.ps1 -FunctionName Invoke-S
    

    Explanation:

    Specifically, the variable expression ${Function:global:Invoke-S} (the expanded form interpreted by Invoke-Expression[1]) / the (positionally implied) -Path argument with value Function:global:Invoke-S passed to Set-Content breaks down as follows:

    By assigning to ${Function:global:Invoke-S} / using that name as a path with Set-Content, the global Invoke-S function is either created or updated; what must be assigned / passed to the (positionally implied) -Value parameter of Set-Content is the function body (similarly, getting the value of this variable / using Get-Content Function:global:Invoke-S would return the function's body as a script block, assuming the function exists).


    Variant solution for defining a function in the caller's scope:

    function New-FunctionInCallerScope {
      param(
        [Parameter(Mandatory)] [string] $ScriptPath,
        [Parameter(Mandatory)] [string] $FunctionName
      )
    
      # Binding flags for finding non-public instance members via reflection.
      $bindingFlags = [System.Reflection.BindingFlags] 'NonPublic, Instance'
      # Get the caller's internal session state, via $PSCmdlet.SessionState
      $sessionStateInternal = [System.Management.Automation.SessionState].
                                GetMethod('get_Internal', $bindingFlags).
                                Invoke($PSCmdlet.SessionState, $null)
      # Determine the scope index relative to $PSCmdlet.SessionState[Internal],
      # which is the *caller's* session state:
      # * If this function is inside a module, it lives in a different "session state"
      #   (scope domain), so use the caller's *current* scope (0) 
      #   (the caller is assumed to be an *outside* caller).
      # * If this function is a non-module function, it runs in the same "session state"
      #   as the caller, but in a *child* scope (unless dot-sourced), so use the parent
      #   scope (1).
      $callerScopeIndex = if ($MyInvocation.MyCommand.Module) { 0 } else { 1 }
      # Retrieve the target scope.
      $scope = $sessionStateInternal.GetType().
                 GetMethod('GetScopeByID', $bindingFlags, [type[]] [int]).
                 Invoke($sessionStateInternal, $callerScopeIndex)
      # Get the scope's function table. 
      $funcTable = $scope.GetType().
                     GetMethod('get_FunctionTable', $bindingFlags).
                     Invoke($scope, $null)
      # Create / update the function with the given name and script block from the given file.
      # Note: A [System.Management.Automation.FunctionInfo] instance must be assigned, which is
      #       is obtained via an aux. transitory, scope-local function whose name doesn't matter.
      $funcTable[$FunctionName] = New-Item Function:UnusedName -Value (Get-Command $ScriptPath).ScriptBlock
    }
    
    # Sample calls:
    
    # Define function Invoke-S in the caller's scope.
    New-FunctionInCallerScope -ScriptPath .\s.ps1 -FunctionName Invoke-S
    
    # The function is now visible in this scope (and its descendants), 
    # but not in any parent scope.
    Invoke-S
    

    [1] Note that while use of Invoke-Expression (iex) is safe in this particular case, it should generally be avoided.