Search code examples
powershellpowershell-coretab-completionpowershell-7.3

How to make a PowerShell argument completer that only suggests files not already selected?


I'm not sure if it's possible to do this so going to first explain what I want to happen.

my PowerShell module function has this parameter

[ValidateScript({ Test-Path $_ -PathType Leaf })][ValidatePattern("\.xml$")][parameter(Mandatory = $true)][string[]]$PolicyPaths,

It accepts multiple .xml files.

I've been using this argument completer for it:

$ArgumentCompleterPolicyPaths = {
    Get-ChildItem | where-object { $_.extension -like '*.xml' } | foreach-object { return "`"$_`"" }
}
Register-ArgumentCompleter -CommandName "Deploy-SignedWDACConfig" -ParameterName "PolicyPaths" -ScriptBlock $ArgumentCompleterPolicyPaths

It's been working fine. Now I want to improved it so that when I need to select multiple .xml files from the current working directory, and start selecting them by pressing Tab (without typing anything because I don't know the file names), the suggested files don't contain the ones I've already selected. The way I select them is by pressing Tab first and after each selected file, add a comma , and then press Tab again to select another from the suggestions.

I've tried 2 new argument completers but none of them work the way I want.

here is the first one:

$ArgumentCompleterPolicyPaths = {
  # Get the current command and the already bound parameters
  param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
  # Get the xml files in the current directory
  Get-ChildItem | Where-Object { $_.Extension -like '*.xml' } | ForEach-Object {
    # Check if the file is already selected
    if ($fakeBoundParameters.PolicyPaths -notcontains $_.FullName) {
      # Return the file name with quotes
      return "`"$_`""
    }
  }
}
Register-ArgumentCompleter -CommandName "Deploy-SignedWDACConfig" -ParameterName "PolicyPaths" -ScriptBlock $ArgumentCompleterPolicyPaths

and here is the second one:

# Define a class that inherits from ArgumentCompleterAttribute
class XmlFileCompleter : ArgumentCompleterAttribute {
  # Override the GetArgumentCompletionSuggestions method
  [System.Collections.Generic.IEnumerable[System.Management.Automation.CompletionResult]] GetArgumentCompletionSuggestions(
    [System.Management.Automation.CommandAst]$commandAst,
    [System.Management.Automation.CommandParameterAst]$parameterAst,
    [System.Collections.IDictionary]$fakeBoundParameters,
    [System.Management.Automation.Language.IScriptPosition]$cursorPosition
  ) {
    # Get all XML files in the current directory
    $xmlFiles = Get-ChildItem -Path . -Filter *.xml
    # Filter out the files that have already been selected
    $xmlFiles = $xmlFiles | Where-Object { $fakeBoundParameters[$parameterAst.ParameterName] -notcontains $_.Name }
    # Return the file names as completion results
    foreach ($xmlFile in $xmlFiles) {
      [System.Management.Automation.CompletionResult]::new($xmlFile.Name, $xmlFile.Name, 'ParameterValue', $xmlFile.Name)
    }
  }
}

The last throws this error in VS code:

Unable to find type [ArgumentCompleterAttribute].

P.S This is current behavior with my argument tab completer, see how it suggests the same file that I already selected: https://1drv.ms/u/s!AtCaUNAJbbvIhupw8-67jn6ScaBOGw?e=a35FoG

it's APNG file so just drag n drop it on a browser.


Solution

    • The problem is that the dictionary of provisionally bound parameters, $fakeBoundParameters, only contains information about other parameters if you haven't typed a prefix of a non-initial array element to be completed.

      • That is, with el1 and el2 representing previously typed / tab-completed array elements, pressing Tab after el1, el2, causes the parameter at hand not to be included as an entry in $fakeBoundParameters, so that the el1 and el2 values cannot be examined that way.

        • This is due to a bug / design limitation of the $fakeBoundParameter dictionary passed to argument-completers, up to at least PowerShell 7.4.0-preview.3: a syntactically incomplete expression such as el1, el2, seemingly prevents inclusion of the array elements provided so far; see GitHub issue #17975
      • Therefore, for an array parameter being tab-completed without having typed at least one character at the start of the new element, any array elements previously typed / completed are in effect not reflected in $fakeBoundParameters.

    • The workaround is to search the command AST passed reflected in the $commandAst completer script-block for string-constant expressions that end end in .xml, as shown below:

      • Note:
        • At least hypothetically, this could yield false positives, namely if other parameters too accept strings ending in .xml
        • As in your own attempt, what the user has manually typed before attempting tab-completion of a given array element is not considered; doing so would require more work.
    Register-ArgumentCompleter `
      -CommandName Deploy-SignedWDACConfig `
      -ParameterName PolicyPaths `
      -ScriptBlock {
        # Get the current command and the already bound parameters
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    
        # Find all string constants in the AST that end in ".xml"
        $existing = $commandAst.FindAll({ 
            $args[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] -and 
            $args[0].Value -like '*.xml' 
          }, 
          $false
        ).Value  
    
        # Get the xml files in the current directory
        Get-ChildItem -Filter *.xml | ForEach-Object {
          # Check if the file is already selected
          if ($_.FullName -notin $existing) {
            # Return the file name with quotes
            "`"$_`""
          }
        }
      }
    

    As for your attempt to use ArgumentCompleterAttribute:

    • You got an error, because you didn't use the full type name as the base class name in your custom class definition, System.Management.Automation.ArgumentCompleterAttribute

      • Curiously, however, omitting the Attribute suffix, i.e. ArgumentCompleter does seem to work without a namespace qualifier.
    • That said, instead of sub-classing this ArgumentCompleter, you could simply use it directly, passing the same script block you used with Register-ArgumentCompleter as an attribute property; that is, inside your Deploy-SignedWDACConfig command, you can decorate the $PolicyPaths parameter declaration with [ArgumentCompleter({ ... })].