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?
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}
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
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:
Function:
references the built-in Function:
drive exposed by the Function
provider
global:Invoke-S
refers to a function named Invoke-S
in the global scope; that is, global:
is a scope modifier.
function global:Test-Me { 'hi!' }
defines a Test-Me
function in the global scope.The function name includes -
, which is a character that requires enclosure of the entire (namespace-notation) variable-name expression in {...}
in order to be recognized as a single variable expression.
Set-Content
solution; Function:global:Invoke-S
works fine as a single argument.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).
Get-Command
preserves the full origin information, namely including the path of the originating .ps1
file.The solution above hinges on being able to use a scope modifier in the function name, namely global:
However, there is no way to specify a relative scope by way of a scope modifier, as would be needed if you wanted to define a function in the caller's scope - whatever scope the caller lives in.
Relative scopes, e.g. 1
to refer to the parent scope, are only supported via the -Scope
parameter of entity-specific cmdlets, such as Set-Variable
and Set-Alias
; there is no cmdlet.Set-Function
New-Item
and Set-Content
, could be extended to provide entity-specific dynamic parameters (an existing example of such parameters are the -File
and -Directory
switches that are specific to the FileSystem
provider).This omission is the subject of GitHub discussion #16881 (reader discretion advised, due to inappropriate language), and the only - suboptimal - workaround for now is to use .NET reflection in order to access non-public members (whose continued existence [in that form] isn't guaranteed), notably the internal .GetScopeByID()
method, as you note, which itself can only be accessed via an object obtained via another non-public API.
The solution below uses a simplified version of the code in the linked discussion, and, unlike the former, also supports putting the function, New-FunctionInCallerScope
, inside a module, although the assumption is that it is only ever called from outside that module.
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.