Skip to content

Add PowerShell tab completion support - #212

Open
HeyItsGilbert wants to merge 7 commits into
tqdm:mainfrom
HeyItsGilbert:main
Open

Add PowerShell tab completion support#212
HeyItsGilbert wants to merge 7 commits into
tqdm:mainfrom
HeyItsGilbert:main

Conversation

@HeyItsGilbert

@HeyItsGilbert HeyItsGilbert commented Feb 14, 2026

Copy link
Copy Markdown

Summary

This PR adds PowerShell tab completion support to shtab, enabling native PowerShell argument completion for CLI applications built with argparse.

Key features:

  • Generates PowerShell completion scripts using Register-ArgumentCompleter -Native
  • Supports PowerShell 5.1+ (Windows Desktop) and PowerShell Core 7.x (cross-platform)
  • Automatically discovers subcommands, options, choices, and file/directory completions
  • Follows the same architecture as existing bash/zsh/tcsh support
  • Includes examples and comprehensive documentation

Changes

  • shtab/init.py: Added PowerShell completer implementation with:

    • get_powershell_commands() for parser tree extraction
    • complete_powershell() decorated completer function
    • PowerShell script template with state machine for completion dispatch
    • Serialization helpers for PowerShell hashtable generation
    • Integration with existing CHOICE_FUNCTIONS infrastructure
  • Documentation & Examples:

    • Updated README.rst and docs/index.md to list PowerShell as supported shell
    • Added PowerShell installation & usage guide in docs/use.md
    • Updated examples/customcomplete.py with PowerShell preamble
  • Tests: Extended existing parametrized test suite to include PowerShell

Building & Testing Locally

Prerequisites

pip install -e .

Run tests

# All tests (including PowerShell smoke tests)
pytest tests/test_shtab.py -v

# PowerShell-only tests (requires pwsh)
pytest tests/test_shtab.py -k "powershell" -v

Generate completion script manually

# For shtab itself
shtab --shell=powershell shtab.main.get_main_parse > shtab.ps1

# Or for an example app
python -c "from examples.customcomplete import main; print(main)" | shtab --shell=powershell > custom.ps1

Test completion in PowerShell

# Load the generated script
. ./shtab.ps1

# Test completions (Ctrl+Space or type -<TAB>)
shtab -<TAB>

Enable debug output

Set the SHTAB_DEBUG environment variable to trace completion state:

$env:SHTAB_DEBUG=1
. ./shtab.ps1

@HeyItsGilbert HeyItsGilbert changed the title Add PowerShell tab completion support (#1) Add PowerShell tab completion support Feb 14, 2026

@pmhahn pmhahn left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just had a short look. Looks okay mostly, but someone with PowerShell knowledge should have a look at the embedded PS1 code.

Found some minor nits.

Maybe split the large __init__.py into individual files in the future? Or at least move the embedded shell-scripts parts into individual files? See #199 ?

Comment thread shtab/__init__.py Outdated
Comment thread shtab/__init__.py Outdated
Comment thread shtab/__init__.py Outdated
Comment thread shtab/__init__.py Outdated
Comment thread shtab/__init__.py Outdated


def _powershell_escape(string: str) -> str:
"""Escape a string for use inside a PowerShell single-quoted string."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I would prefer that _ps1_escape adds the surrounding single-quotes itself. I'm no PS1 expert myself, but at least with bash there are several ways to escape strings

  • in single-quotes only single-quotes must be handles special
  • in double-quotes several characters still need backslash escaping
  • without quote many characters must be backslash escaped

With bash shlex.quote() could switch between those choices or even depend on the string to escape and we don't care which is uses as it is an internal details.

With your _ps1_escape here you always must use single quotes and we later can't change it if we find something better.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rewrote this a bit to always wrap in single quotes. I did find an very weird edge case, but that should be dealt with now. I checked where I use it, and honestly I don't see where we would want anything but single quoted strings. What the tool passes in should probably never be interpolated. If there is something dynamic, we should do that in the python code to keep things deterministic. If you can think of case, let me know.

Comment thread shtab/__init__.py Outdated
Comment thread shtab/__init__.py
return "@{\n" + "\n".join(entries) + "\n}"


def get_powershell_commands(root_parser, root_prefix, choice_functions=None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh oh, I see lots of get_bash_commands() code here. There are many differences, but I don't have time to dig in right now to find out, why they differ.

Maybe someone™ should refactor this and to extract common code? 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this addressed in this PR?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but a mental note to whoever to address this soon.

@HeyItsGilbert
HeyItsGilbert requested a review from pmhahn March 16, 2026 19:32

@pmhahn pmhahn left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay; only 1 nit you might want to fix.

Again: I'm a Linux-only user and have no experience with PS, so I cannot say if the completion works as expected. Nice work, thanks.

Comment thread shtab/__init__.py Outdated
@casperdcl
casperdcl force-pushed the main branch 4 times, most recently from 8fd5dfa to ca739c5 Compare August 2, 2026 20:32
Copilot AI lite review requested due to automatic review settings August 4, 2026 14:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new PowerShell completion backend to shtab, generating native PowerShell argument completion scripts via Register-ArgumentCompleter -Native, and updates tests + documentation to include PowerShell as a supported shell.

Changes:

  • Added a PowerShell completion generator (complete_powershell) and supporting serializer/parser-traversal helpers in shtab/__init__.py.
  • Extended the existing pytest suite expectations to include PowerShell output markers.
  • Updated docs/README/CONTRIBUTING to list PowerShell support and provide PowerShell installation/usage snippets.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
shtab/__init__.py Implements PowerShell script generation, adds PowerShell entries to choice/utility infrastructure.
tests/test_shtab.py Extends parametrized expectations to cover the new powershell shell output.
README.rst Documents PowerShell as supported and updates usage examples accordingly.
docs/use.md Adds PowerShell setup instructions and updates shell lists in usage text.
docs/index.md Lists PowerShell among supported shells.
CONTRIBUTING.md Notes the new complete_powershell() entry point among core functions.
Suppressed comments (4)

shtab/init.py:1038

  • _powershell_escape() currently doubles curly apostrophes (U+2018/U+2019) as well as the ASCII quote. PowerShell only needs escaping for the ASCII single-quote delimiter ('), so doubling curly quotes will change the literal content of strings and break completions for choices containing those characters.
    s = str(string)
    for ch in ("'", "\u2018", "\u2019"):
        s = s.replace(ch, ch * 2)
    return "'" + s + "'"

shtab/init.py:1126

  • In get_powershell_commands(), the optional-action suppression check is if optional == SUPPRESS:, but optional is an argparse.Action object. This condition will never be true, so suppressed options will be included in completions.
        for optional in parser._get_optional_actions():
            if optional == SUPPRESS:
                continue

shtab/init.py:1095

  • get_powershell_commands() does not skip positional actions whose help is SUPPRESS. This will leak hidden/internal arguments into the generated completion tables (unlike the other shell implementations which skip suppressed actions).
        for i, positional in enumerate(parser._get_positional_actions()):
            action_key = f"{prefix}_pos_{i}"
            if hasattr(positional, 'complete'):
                comp_pattern = complete2pattern(positional.complete, 'powershell', choice_type2fn,

shtab/init.py:1117

  • get_powershell_commands() currently skips storing nargs when it is '?' (and also compares against string '1'). The PowerShell completion state machine relies on correct nargs to know when to advance positional slots, so omitting '?' (and not handling int 1) can lead to mis-tracking positional consumption.
            if positional.nargs not in (None, "1", "?"):
                nargs[action_key] = str(positional.nargs)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread shtab/__init__.py Outdated
Comment thread shtab/__init__.py
Comment thread shtab/__init__.py
Comment thread docs/use.md Outdated
@casperdcl
casperdcl force-pushed the main branch 2 times, most recently from cd7cdc7 to aaad68e Compare August 4, 2026 15:09

@casperdcl casperdcl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this start! Just rebased.

Locally, some things don't work for me:

  • customcomplete -<TAB> doesn't complete anything
  • customcomplete process foo csv <tab> shouldn't complete files
  • customcomplete process <tab> needs changing shtab.cmd("head -c5 /dev/random ...") to something windows-compatible

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.89744% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.88%. Comparing base (25d975f) to head (bde0fcc).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #212      +/-   ##
==========================================
+ Coverage   84.71%   84.88%   +0.16%     
==========================================
  Files           3        3              
  Lines         471      549      +78     
  Branches       93      114      +21     
==========================================
+ Hits          399      466      +67     
- Misses         38       44       +6     
- Partials       34       39       +5     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

HeyItsGilbert and others added 4 commits August 10, 2026 23:26
---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Philipp Hahn <pmhahn+github@pmhahn.de>
- Enhanced the `_powershell_escape` function to handle smart quotes.
- Updated the function to return a properly quoted string for PowerShell.
- Adjusted related functions to ensure consistent escaping behavior.
pre-commit-ci Bot and others added 3 commits August 10, 2026 23:28
- complete_powershell now captures argparse help= text (with %(default)s/
  %(prog)s expansion) into a $..._help table and surfaces it as each
  CompletionResult's tooltip via a new Get-ActionHelp helper.
- _shtab_powershell_compgen_files/_dirs (and the glob() preamble) preserved
  only the basename, dropping the typed directory prefix (e.g. completing
  "dir/pre" returned "file.txt" instead of "dir/file.txt"); fixed via a
  shared _shtab_powershell_join_prefix helper, guarding Split-Path against
  an empty $WordToComplete (it throws on "").
- When a completer has nothing to offer, PowerShell falls back to native
  filesystem completion unless the script block explicitly returns $null
  (PowerShell/PowerShell#19628); the generated script now does so instead
  of silently producing zero output.
- cmd()'s PowerShell preamble named its wrapper function
  _shtab_pattern_compgen_{abs(hash(command))} while the compgens table
  referenced _shtab_pattern_compgen_{sha(command)} -- always a mismatch,
  so the dynamic-command completer never worked. Now both use sha().
- examples/customcomplete.py: replaced the Unix-only
  `head -c5 /dev/random | base32` completer (invalid under PowerShell's
  Invoke-Expression) with `git rev-parse --short HEAD`, matching the
  cross-shell convention already used in pathcomplete.py.
- tests: added a powershell_candidates() harness that drives the
  generated script through pwsh's TabExpansion2 and covers
  test_file_completion[powershell].
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request shell-ps1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants