Search code examples
powershellmodulescopetab-completion

Module script-scoped variables not accessable in module function's ArgumentCompleter block


test.psm1:

$script:ProviderItem = [System.Management.Automation.CompletionResultType]::ProviderItem
function Get-Files {Get-ChildItem -Path 'C:\Windows\System32\WindowsPowerShell\v1.0\en-US\about_Functions*.txt'}
function Test
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ParameterSetName = 'Name', Position = 0, ValueFromPipeline)]
        [ArgumentCompleter({
            param ($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams)
            Get-Files | Where-Object {$_.Name -like "*$WordToComplete*"} | ForEach-Object {
                $resultName = $_.Name
                $resultFN = $_.FullName
                $toolTip = "File: $resultFN"
                [System.Management.Automation.CompletionResult]::new($resultName, $resultFN, $script:ProviderItem, $toolTip)
            } #ForEach-Object
        })]
        [System.String[]]$Name
    )
    begin {Write-Output $script:ProviderItem}
    process { foreach ($n in $Name) {Write-Output $n} }
}

NOTES:

  • This is for illustration; You could easily use 'ProviderItem' instead of a constant in the [System.Management.Automation.CompletionResult] constructor.
  • In this example the Get-Files function is intended to be a private (non-exported) function.


  • I'm wondering why autocompletion works when $ProviderItem is scoped as $global:ProviderItem but not $script:ProviderItem
  • In the module manifest, even if $ProviderItem is scoped globally I still have to export all functions, rather than just Test, in order to get tab-completion to work properly.
    • Doesn't Work: FunctionsToExport = 'Test'
      • Tab completion falls back on TabExpansion2 and lists child items in the current directory.
    • Works: FunctionsToExport = '*'
      • Performs tab-completion as I expect.
  • I thought this might have to do with scoping and PSReadLine, but ISE behaves the same way, so I'm obviously missing something critical.


Questions:

  • How can I use Get-Files inside an ArgumentCompleter block of a different function's parameter block(s), export only Test and still retain tab-completion?
  • Can I avoid using the global scope for module-wide constants that are used in ArgumentCompleter function param blocks?

Solution

  • Because the ArgumentCompleter scriptblock has no knowledge about the Module it is being invoked in, thus has no knowledge about variables defined in the module scope. A simple way to prove this is the case is by changing the CompletionResult arguments to:

    [System.Management.Automation.CompletionResult]::new(
        $resultName,
        $resultFN,
        (& (Get-Command Test).Module { $ProviderItem }),
        $toolTip)
    

    Moreover, defining the variable as $script: is not needed, all variables defined in the .psm1 are already scoped to the commands in your module.

    Exactly the same applies for Get-Files if FunctionsToExport = 'Test', then the it is scoped to your module and the completer scriptblock has no knowledge about it, you would've to:

    & (Get-Command Test).Module { Get-Files } | Where-Object { ....
    

    A workaround can be to use a class that implements IArgumentCompleter attribute, classes defined in the module scope can see the scoped variables and functions without issues, same applies to Register-ArgumentCompleter.

    Sharing the class implementation here:

    using namespace System
    using namespace System.Collections
    using namespace System.Collections.Generic
    using namespace System.Management.Automation
    using namespace System.Management.Automation.Language
    
    $ProviderItem = [CompletionResultType]::ProviderItem
    
    function Get-Files {
        Get-ChildItem -Path 'C:\Windows\System32\WindowsPowerShell\v1.0\en-US\about_Functions*.txt'
    }
    
    class CustomCompleter : IArgumentCompleter {
        [IEnumerable[CompletionResult]] CompleteArgument(
            [string] $commandName,
            [string] $parameterName,
            [string] $wordToComplete,
            [CommandAst] $commandAst,
            [IDictionary] $fakeBoundParameters
        ) {
            $out = [List[CompletionResult]]::new()
            Get-Files | Where-Object { $_.Name -like "*$WordToComplete*" } | ForEach-Object {
                $resultName = $_.Name
                $resultFN = $_.FullName
                $toolTip = "File: $resultFN"
                $out.Add([CompletionResult]::new($resultName, $resultFN, $ProviderItem, $toolTip))
            }
            return $out.ToArray()
        }
    }
    
    function Test {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory, ParameterSetName = 'Name', Position = 0, ValueFromPipeline)]
            [ArgumentCompleter([CustomCompleter])]
            [string[]] $Name
        )
        begin {
            Write-Output $ProviderItem
        }
        process {
            foreach ($n in $Name) {
                Write-Output $n
            }
        }
    }