Skip to content

Add a way to lazily autoload argument completers #17283

Open

Description

Summary of the new feature / enhancement

Currently, argument completers for native commands must be explicitly registered using Register-ArgumentCompleter. However, this means that the module which registers the completer must be eagerly loaded in $PROFILE during startup, otherwise the completions aren't loaded. If many different argument completers are registered, this may take a considerable amount of time on each startup, even when most of the completions are never used in the session.

As an example, importing https://github.com/MatejKafka/PowerShell-ArgumentCompleters takes about 175 ms on my computer. Currently, I'm loading them in an OnIdle event callback, which helps, but it's still not ideal and doesn't scale well to a larger number of completers.

It would be nice to add autoloading support for argument completers. Completions for native commands (together with inline description) are the main reason why I prefer fish shell autocompletion over the one in PowerShell.


As an additional food for thought (which is definitely out-of-scope for this issue), currently there isn't any official repository for argument completers. If that was added, it could be possibly to interactively offer the user to download completions for the given command (if available) when they first trigger autocomplete on it, greatly improving the experience of using native commands in PowerShell. It already kinda works this way on Linux, where many packages also install autocompleters for commonly used shells.

Proposed technical implementation details (optional)

Possible implementation: Add an entry to the module manifest, listing the commands for which it provides argument completers. When completions for the command are requested, the module is autoloaded, which registers the argument completers.

This implementation has an issue where if the module is large (slow to load), there may be a considerable delay before the completions appear. This could be further improved by also specifying which file contains the argument completer for the given command, so that only that file could be loaded, without loading the whole module. This would allow for one big module, which registers many different completers, each in a separate file, without having to load the whole module.

Activity

  1. daxian-dbw commented on Oct 17, 2022

    @daxian-dbw
    Member

    Auto loading a module at the time of user pressing Tab is dangerous. It's likely will block the user input for a very noticeable period of time and the user simply cannot cancel whatever PowerShell is doing at that point. So, it seems PowerShell freezes, and the user has to wait till it becomes responsive.

  2. MatejKafka commented on Oct 18, 2022

    @MatejKafka
    ContributorAuthor
    1. The current alternative is eagerly loading the autocompletion module in $PROFILE, so the user has to pay this cost on each startup, not just on the first completion invocation.

    2. Your described behavior is exactly what already happens for PowerShell commands – if I invoke completion on a command from a not-yet-loaded module, there's a delay where PowerShell is loading the module to provide completion. Thus, allowing autoload for external completers does not make this situation any worse.

  3. JamesWTruher commented on Oct 21, 2022

    @JamesWTruher
    Collaborator

    The WG discussed this. We feel that there are a number of threads that need pulling here:

    • Dongbo Wang (@daxian-dbw) raised an excellent point about performance (think the huge az module)
    • We did agree that the module should likely have a way to specify completions
    • We absolutely agree about having a good way to acquire/share completions
    • We can't ignore completions for non-Modules (scripts/native apps)
    • fish/bash/zsh all do completion a little differently, what can we learn from them (and what can we avoid)?

    We think a comprehensive approach is going to provide better results long term, rather than just trying to solve this problem for modules. We're suggesting that an RFC is really needed here.

  4. added
    RFC-RequiredWorking Groups have decided that an RFC is required for this contribution
    on Oct 21, 2022
  5. iSazonov commented on Oct 21, 2022

    @iSazonov
    Collaborator
    • fish/bash/zsh all do completion a little differently, what can we learn from them (and what can we avoid)?

    They use scripts for completion. This is a way for code injection. I highly doubt that MSFT will agree to run someone else's extra code! Mandatory signing of such files is not convenient for users.

    Now the .Net Command Line project uses the approach of starting target application with every tab. It is obvious how problematic this approach is.

    I would suggest joining forces with this project and developing a standard for declarative description of meta information about parameters and arguments.
    PowerShell uses this now through attributes. If there were a standard, it would be able to read external information (XML/Json) from any application and use it for completion.

  6. SteveL-MSFT commented on Jan 6, 2023

    @SteveL-MSFT
    Member

    I wonder if it would make sense to add a new parameter to Register-ArgumentCompleter called -Persist along with an optional -Scope (default to CurrentUser, but can accept AllUsers) which modifies powershell.config.json as the way to persistent auto re-registration of completers on startup? Of course, we'd need a corresponding Unregister-ArgumentCompleter cmdlet which doesn't exist today and decide separately if it works beyond persisted ones.

    • perf: should not be an issue as actual registration happens at startup and not at tab-completion time (no different than if it WAS in a $profile)
    • this approach isn't limited to modules, but could be used by modules. a native command installed via MSI can shell out to pwsh to call this cmdlet
    • sharing of completions is outside the scope of this other than being modules on powershellgallery
    • my preference is to not have a folder that anything can drop scripts into and get run. although this approach is not a huge step forward from that, it may make sense to have a Get-ArgumentCompleter cmdlet for auditing
  7. heaths commented on Jan 6, 2023

    @heaths
    Contributor

    A bit tangential, but modifying existing files during an install - whether MSI on Windows, brew, apt on debian flavors, etc. - is very problematic. Those "resources" can't really be tracked and removed safely with the packages or upgraded correctly, so most installers - even with custom solutions - don't even try. For years this was a problem with .NET Framework's machine.config, which is why we eventually came up with other solutions for first- and third-party solutions.

    A common solution on most linux distros is a directory for drop-in customizations, like /etc/apt/sources.list.d (".d" for a directory is a common naming convention). Files (which, on linux, everything is a file) in whole are best relegated to installers.

    That said, PowerShell already has a directory-based solution: modules. Could Crescendo fill this void?

  8. daxian-dbw commented on Jan 6, 2023

    @daxian-dbw
    Member

    Regarding Steve Lee (@SteveL-MSFT)'s proposal in #17283 (comment), it is still eager loading, meaning the long delay in startup reported here will remain an issue. Also, to register completion, it requires an installer to start the pwsh process to run Register-ArgumentCompleter during the installation, which is error prone in my opinion.

    I think we should have a "completion path", similar to $PSModulePath, which contains directories that powershell will probe for completion meta files (this aligns with how it works in other shells). The completion meta file can be named as <command-name>+<.pwsh>, and it has to be a powershell data file just like a .psd1 file. Initially, the metadata contained in the file should just be a 2-element hash table:

    @{
        module = "module-name"
        command = "command-name"
    }
    

    When tab completion on a native command is triggered, the engine completion code will:

    1. first look into context.NativeArgumentCompleters to find if a completer is already registered for the native command.
      • if so, run the registered completer and done.
    2. If not, then the completion code searches "completion path", and stop when finding the first matching completion meta file.
      • we could have an analysis cache file generated just like the module discovery
      • do nothing if the search fails
    3. retrieve the metadata, then call the "module-name/command-name" assuming that the module has been deployed when installing the native command.
      • do nothing if the command call failed
    4. again, look into context.NativeArgumentCompleters to find if a completer is now available
      • do nothing if still not available, OR
      • run the newly registered completer and done.

    With this approach, an installer just needs to deploy a completion module and a completion meta file to enable completion for the native command in powershell, which is way less error prone, and also allow lazy-loading of the completer automatically when it's needed.

    One may argue that the lazy-loading upon tab completion happens may cause unresponsive interactive experience at least for the first tab for a particular native command (I did so myself in #17283 (comment) 😄). That's true, but it may not be such a bad thing either -- it forces the completion module author to be more performance oriented in designing the module and argument completer.


    fish has the "completion path" concept for adding completion support: https://fishshell.com/docs/current/completions.html#where-to-put-completions

    Fish automatically searches through any directories in the list variable $fish_complete_path, and any completions defined are automatically loaded when needed. A completion file must have a filename consisting of the name of the command to complete and the suffix .fish.

  9. SteveL-MSFT commented on Jan 7, 2023

    @SteveL-MSFT
    Member

    Dongbo Wang (@daxian-dbw) so with a file path based approach would we have both shared and user directories? would modules also use this mechanism?

  10. iSazonov commented on Jan 7, 2023

    @iSazonov
    Collaborator

    My suggestion was #13428 - create generic support for a native command wrapper.
    To do something clever with native commands PowerShell needs meta information. PowerShell already has the means to create such meta information. With pseudo/native cmdlets we could add any meta information in the usual way. (Apart from parameter descriptions it could be (optionally) transcoding, output parsing, etc.)
    I don't understand why we have to invent something else if we already have everything.

    A more fundamental approach would be to synchronize with the .Net CommandLine project team so that PowerShell can take meta information directly from the native command dll.

  11. 6 remaining items

  12. segevfiner commented on Jan 14, 2024

    @segevfiner

    Bump

  13. henno commented on Mar 22, 2024

    @henno

    Could we engineer a method to initiate asynchronous preloading of parameter autocomplete suggestions for a cmdlet immediately after the user enters the cmdlet name as the initial word in the prompt and hits space following the cmdlet's name?

  14. segevfiner commented on Sep 18, 2024

    @segevfiner

    Still relevant. Having many completion scripts slows down startup, even if they end up unused in the session.

  15. MatejKafka commented on Dec 25, 2024

    @MatejKafka
    ContributorAuthor

    Joel Bennett (@Jaykul)

    What if there are two of them?

    Fair point, but the same issue occurs when multiple modules provide the same exported cmdlet, so there are existing rules that we could follow.

  16. totkeks commented on Feb 8, 2025

    @totkeks

    Just had the same issue and it seems there is no accepted solution yet. Together with my LLM friends I hacked together this piece of code:

    function Register-LazyCompleter {
    	param (
    		[string]$CommandName,
    		[scriptblock]$CompletionScript
    	)
    
    	$scriptBlock = {
    		param($wordToComplete, $commandAst, $cursorPosition)
    
    		& $CompletionScript
    	}.GetNewClosure()
    
    	Register-ArgumentCompleter -CommandName $CommandName -ScriptBlock $scriptBlock
    }
    
    Register-LazyCompleter -CommandName podman -CompletionScript {
    	(podman completion powershell | Out-String) -replace 'podman.exe', 'podman' | Invoke-Expression
    }

    The lazy part works. Just the tiny issue of the first completion that triggers the lazy load... it falls back to completing using the files in the current directory and I have no idea how to call the now registered completer after & $CompletionScript has finished.

    LLM suggested

    $ExecutionContext.InvokeCommand.GetCommand(
    			'TabExpansion2',
    			[System.Management.Automation.CommandTypes]::Function
    		).ScriptBlock.InvokeReturnAsIs($wordToComplete, $commandAst, $cursorPosition)

    but this doesn't seem to work. Not sure if it is the command or because the & Completionscript ends the control of the scriptblock.

  17. MatejKafka commented on Feb 8, 2025

    @MatejKafka
    ContributorAuthor

    Norman Dankert (@totkeks) I use the following implementation, which seems to work reliably (but uses undocumented pwsh internals):

    $Flags = [System.Reflection.BindingFlags]"Instance, NonPublic"
    $Context = $ExecutionContext.GetType().GetField("_context", $Flags).GetValue($ExecutionContext)
    $NativeProp = $Context.GetType().GetProperty("NativeArgumentCompleters", $Flags)
    
    ### Set a completer for command `$CommandName` that loads another pwsh module when invoked for the first time.
    ### The specified module should use Register-ArgumentCompleter to register the actual completer during load.
    function Set-SubstituteCompleter($CommandName, $CompleterModuleName) {
        $Context = $script:Context
        $NativeProp = $script:NativeProp
        Register-ArgumentCompleter -CommandName $CommandName -ScriptBlock {
            try {
                # import the actual completer to replace this one
                Import-Module -Global $CompleterModuleName -ErrorAction Stop
            } catch {
                # if loading the completer fails, fall back to the default completer and log an error
                #  (it will not be shown, but it will be available using `Get-Error`)
                throw "Failed to run the autocompleter for '$CommandName', provided by module '$CompleterModuleName': $_"
            }
            # forward it once
            $Completer = $NativeProp.GetValue($Context)[$CommandName]
            return & $Completer @Args
        }.GetNewClosure()
    }
  18. totkeks commented on Feb 8, 2025

    @totkeks

    Thanks, that works amazingly well, even for the first completion. Just made a small change, so it also works for completions that come from PowerShell scripts like go and rust use.

    Also, the hint about Get-Error was extremely useful. I didn't know about this and wondered why I don't see any output on the failed completions.

    function Register-LazyCompleter($CommandName, $CompletionScript) {
    	$Context = $script:Context
    	$NativeProp = $script:NativeProp
    
    	Register-ArgumentCompleter -CommandName $CommandName -ScriptBlock {
    		try {
    			. $CompletionScript
    		}
    		catch {
    			throw "Failed to run the autocompleter for '$CommandName'"
    		}
    		$Completer = $NativeProp.GetValue($Context)[$CommandName]
    		return & $Completer @Args
    	}.GetNewClosure()
    }
    
    # go completer
    Register-LazyCompleter oh-my-posh {
    	oh-my-posh completion powershell | Out-String | Invoke-Expression
    }
    
    # rust completer
    Register-LazyCompleter uv {
    	uv generate-shell-completion powershell | Out-String | Invoke-Expression
    }
    
    # native completer
    Register-LazyCompleter winget {
    	Register-ArgumentCompleter -Native -CommandName winget -ScriptBlock {
    		param($wordToComplete, $commandAst, $cursorPosition)
    
    		winget complete --word "$wordToComplete" --commandline "$commandAst" --position $cursorPosition | ForEach-Object {
    			[CompletionResult]::new($_, $_, 'ParameterValue', $_)
    		}
    	}
    }
  19. segevfiner commented on Dec 26, 2025

    @segevfiner

    Bump

  20. lyra95 commented on Jun 11, 2026

    @lyra95

    This hacky code works fine if there are no name collisions between completion scripts:

    function Invoke-ExpressionGlobal {
        param([string]$Script)
    
        $beforeVars  = Get-Variable -Scope Global | Select-Object -ExpandProperty Name
        $beforeFuncs = Get-ChildItem function: | Select-Object -ExpandProperty Name
    
        Invoke-Expression $Script
    
        Get-Variable | Where-Object { $_.Name -notin $beforeVars } | ForEach-Object {
            Set-Variable -Name $_.Name -Value $_.Value -Scope Global
        }
    
        Get-ChildItem function: | Where-Object { $_.Name -notin $beforeFuncs } | ForEach-Object {
            Set-Item "function:global:$($_.Name)" -Value $_.ScriptBlock
        }
    }
    
    # podman
    Set-Alias -Name docker -Value podman
    Register-ArgumentCompleter -CommandName 'podman', 'docker' -ScriptBlock {
        param($wordToComplete, $commandAst, $cursorPosition)
        
        if (-not $global:__podmanCompleterBlock) {
            Invoke-ExpressionGlobal (podman completion powershell | Out-String)
        }
    
        $global:__podmanCompleterBlock.Invoke($wordToComplete, $commandAst, $cursorPosition)
    }
    
    # kubectl
    Set-Alias -Name k -Value kubectl
    Register-ArgumentCompleter -CommandName 'kubectl', 'k' -ScriptBlock {
        param($wordToComplete, $commandAst, $cursorPosition)
        
        if (-not $global:__kubectlCompleterBlock) {
            Invoke-ExpressionGlobal (kubectl completion powershell | Out-String)
        }
    
        $global:__kubectlCompleterBlock.Invoke($wordToComplete, $commandAst, $cursorPosition)
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Issue-Enhancementthe issue is more of a feature request than a bugRFC-RequiredWorking Groups have decided that an RFC is required for this contributionWG-Engine-ParameterBinder

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions