Skip to content

Completion

duho.completion

Shell completion script generation (bash/zsh/fish/powershell).

Decision (do not revisit): STATIC script generation -- these functions emit a self-contained completion script the user installs once, NOT a dynamic argcomplete-style hook that re-invokes the program on every Tab. Zero runtime dependency, zero per-invocation cost: a core differentiator vs. argcomplete.

All four emitters (bash, zsh, fish, powershell) share one parser-tree walk (_walk) that turns a built argparse.ArgumentParser into a plain, shell-agnostic CompletionSpec. Only the emitters know shell syntax.

Completion data is read off the built parser's private attrs (parser._actions, parser._subparsers) -- the same internal contract parsers.py already relies on elsewhere in this codebase.

Two-level quoting. A value (a choice, a subcommand name, a flag) can pass through TWO parses: the STATIC parse when the shell first reads/sources the generated script, and -- for zsh's _arguments action lists and fish's complete -a/-n arguments -- a SECOND, dynamic evaluation every time the user presses Tab. A quoter that only survives the first parse (bash's _bash_wordlist, zsh/fish's old shared _sq) does not protect the second. Every site that reaches a second evaluation therefore escapes each value for that evaluation FIRST (_zsh_word / the fish-word escaper below), then wraps the result for the static parse (_sq / _fsq).

__all__ = ['CompletionOption', 'CompletionPositional', 'CompletionSpec', 'spec', 'bash', 'zsh', 'fish', 'powershell'] module-attribute

CompletionOption(flags, takes_value, choices=None, is_path=False) dataclass

One optional argument (e.g. --name/-n).

choices = None class-attribute instance-attribute

flags instance-attribute

is_path = False class-attribute instance-attribute

takes_value instance-attribute

CompletionPositional(name, choices=None, is_path=False, hidden=False) dataclass

One positional argument.

choices = None class-attribute instance-attribute

hidden = False class-attribute instance-attribute

is_path = False class-attribute instance-attribute

name instance-attribute

CompletionSpec(prog, options=list(), positionals=list(), subcommands=dict(), help='', path=()) dataclass

Shell-agnostic view of a single (sub)parser and its subcommand tree.

help = '' class-attribute instance-attribute

options = _dc.field(default_factory=list) class-attribute instance-attribute

path = () class-attribute instance-attribute

positionals = _dc.field(default_factory=list) class-attribute instance-attribute

prog instance-attribute

subcommands = _dc.field(default_factory=dict) class-attribute instance-attribute

bash(parser, prog=None)

Emit a self-contained bash completion script for parser.

Descends the command line only on a word that is BOTH a real subcommand name of the CURRENT node AND typed once that node's own positionals are already satisfied -- argparse itself consumes a node's own positionals before ever treating a word as its subparsers dispatch value, so a positional whose choices happen to include a real subcommand name is never mistaken for one; every other bare word counts as one of that node's own positionals, tracked by position so only the pending positional's own candidates (its choices via compgen -W, or compgen -f for a Path positional) are offered, not every positional's at once. A value-taking flag's value is skipped the same way, including the split --opt = value form bash produces for --opt=value; the value-flag set is resolved per command path, not merged globally. Every value-taking flag -- choice, Path, or free -- gets its own $prev arm. COMPREPLY is always filled by a while IFS= read -r loop over a process-substitution compgen call (not mapfile, which bash 3.2 -- still macOS's /bin/bash -- does not have), never $(...) splicing, so results are never word-split or glob-expanded a second time. Registered with -o bashdefault -o default -o filenames so bash's native filename completion applies whenever nothing above matches, and Path positionals/options get properly escaped/slashed directory names. Every candidate word list (compgen -W) is itself escaped character-by-character against a conservative safe set (see _bash_wordlist) so compgen's own SECOND, dynamic re-evaluation of that argument cannot run command or process substitution, expand a glob, or split on an embedded space.

Source code in src/duho/completion/_bash.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def bash(parser: _argparse.ArgumentParser, prog: _ty.Optional[str] = None) -> str:
    """Emit a self-contained bash completion script for `parser`.

    Descends the command line only on a word that is BOTH a real subcommand
    name of the CURRENT node AND typed once that node's own positionals are
    already satisfied -- argparse itself consumes a node's own positionals
    before ever treating a word as its subparsers dispatch value, so a
    positional whose `choices` happen to include a real subcommand name is
    never mistaken for one; every other bare word counts as one of that
    node's own positionals, tracked by position so only the pending
    positional's own candidates (its `choices` via `compgen -W`, or
    `compgen -f` for a Path positional) are offered, not every positional's
    at once. A value-taking flag's value is skipped the same way, including
    the split `--opt = value` form bash produces for `--opt=value`; the
    value-flag set is resolved per command path, not merged globally.
    Every value-taking flag -- choice, Path, or free -- gets its own
    `$prev` arm. `COMPREPLY` is always filled by a `while IFS= read -r` loop
    over a process-substitution `compgen` call (not `mapfile`, which bash
    3.2 -- still macOS's `/bin/bash` -- does not have), never `$(...)` splicing, so results
    are never word-split or glob-expanded a second time. Registered
    with `-o bashdefault -o default -o filenames` so bash's native filename
    completion applies whenever nothing above matches, and Path
    positionals/options get properly escaped/slashed directory names. Every
    candidate word list (`compgen -W`) is itself escaped character-by-character
    against a conservative safe set (see `_bash_wordlist`) so `compgen`'s own
    SECOND, dynamic re-evaluation of that argument cannot run command or
    process substitution, expand a glob, or split on an embedded space.
    """
    root = _walk(parser, prog=prog)
    root_prog = _validate_prog(root.prog)
    func = _bash_func_name(root_prog)
    specs = _all_specs(root)

    lines: list[str] = []
    lines.append(f"# bash completion for {root_prog}")
    lines.append(f"{func}() {{")
    lines.append("    local cur prev")
    lines.append("    COMPREPLY=()")
    lines.append('    cur="${COMP_WORDS[COMP_CWORD]}"')
    lines.append('    prev="${COMP_WORDS[COMP_CWORD-1]}"')
    lines.append("    # `--opt=value` arrives as the three words --opt, =, value")
    lines.append("    # (`=` is in COMP_WORDBREAKS); look one word further back.")
    lines.append('    if [ "$prev" = "=" ] && [ "$COMP_CWORD" -ge 2 ]; then')
    lines.append('        prev="${COMP_WORDS[COMP_CWORD-2]}"')
    lines.append("    fi")
    lines.append("    # `--opt=` with nothing typed yet arrives as --opt, = -- `cur`")
    lines.append(
        '    # IS the literal "=" (there is no fourth, empty word), which would'
    )
    lines.append("    # otherwise be used to filter candidates and match nothing.")
    lines.append('    if [ "$cur" = "=" ]; then')
    lines.append('        cur=""')
    lines.append("    fi")
    lines.append("")
    lines.append("    # Walk COMP_WORDS to find which (sub)command we are in: descend")
    lines.append("    # only on a word that is a REAL subcommand name of the current")
    lines.append("    # node AND only once that node's own positionals are already")
    lines.append("    # satisfied (argparse consumes a node's own positionals before")
    lines.append("    # ever treating a word as its subparsers dispatch value, so a")
    lines.append("    # positional whose choices happen to include a real subcommand")
    lines.append(
        "    # name must not be mistaken for one); skip the value that follows"
    )
    lines.append("    # a value-taking flag of the current node (including the split")
    lines.append("    # `--opt = value` form), and count every other bare word as one")
    lines.append("    # of the current node's own positionals.")
    lines.append('    local cmd_path=""')
    lines.append("    local npos=0")
    lines.append("    local i=1")
    lines.append("    local skip=0")
    lines.append("    local w is_vflag is_sub own_pos")
    lines.append("    while [ $i -lt $COMP_CWORD ]; do")
    lines.append('        w="${COMP_WORDS[i]}"')
    lines.append("        if [ $skip -gt 0 ]; then")
    lines.append("            skip=$((skip - 1))")
    lines.append("        else")
    lines.append('            case "$w" in')
    lines.append("                -*)")
    lines.append("                    is_vflag=0")
    lines.append('                    case "$cmd_path" in')
    for s in specs:
        vflags = _value_flag_names(s)
        if not vflags:
            continue
        pattern = "|".join(_bashq(f) for f in vflags)
        lines.append(f"                        {_bashq(_cmd_key(s))})")
        lines.append('                            case "$w" in')
        lines.append(f"                                {pattern}) is_vflag=1 ;;")
        lines.append("                            esac")
        lines.append("                            ;;")
    lines.append("                    esac")
    lines.append("                    if [ $is_vflag -eq 1 ]; then")
    lines.append('                        if [ "${COMP_WORDS[i+1]}" = "=" ]; then')
    lines.append("                            skip=2")
    lines.append("                        else")
    lines.append("                            skip=1")
    lines.append("                        fi")
    lines.append("                    fi")
    lines.append("                    ;;")
    lines.append("                *)")
    lines.append("                    is_sub=0")
    lines.append("                    own_pos=0")
    lines.append('                    case "$cmd_path" in')
    for s in specs:
        if not s.subcommands:
            continue
        lines.append(
            f"                        {_bashq(_cmd_key(s))}) own_pos={len(s.positionals)} ;;"
        )
    lines.append("                    esac")
    lines.append("                    if [ $npos -ge $own_pos ]; then")
    lines.append('                        case "$cmd_path" in')
    for s in specs:
        if not s.subcommands:
            continue
        pattern = "|".join(_bashq(n) for n in s.subcommands)
        lines.append(f"                            {_bashq(_cmd_key(s))})")
        lines.append('                                case "$w" in')
        lines.append(f"                                    {pattern}) is_sub=1 ;;")
        lines.append("                                esac")
        lines.append("                                ;;")
    lines.append("                        esac")
    lines.append("                    fi")
    lines.append("                    if [ $is_sub -eq 1 ]; then")
    lines.append('                        cmd_path="${cmd_path:+$cmd_path }$w"')
    lines.append("                        npos=0")
    lines.append("                    else")
    lines.append("                        npos=$((npos + 1))")
    lines.append("                    fi")
    lines.append("                    ;;")
    lines.append("            esac")
    lines.append("        fi")
    lines.append("        i=$((i + 1))")
    lines.append("    done")
    lines.append("")

    for cspec in specs:
        key = _cmd_key(cspec)
        lines.append(f'    if [ "$cmd_path" = {_bashq(key)} ]; then')

        # prev-based value completion: EVERY value-taking flag gets an arm,
        # not just choice/Path ones, so a free-value flag never falls
        # through to the general candidate list.
        value_opts = [o for o in cspec.options if o.takes_value]
        if value_opts:
            lines.append('        case "$prev" in')
            for opt in value_opts:
                flag_pattern = "|".join(_bashq(f) for f in opt.flags)
                lines.append(f"            {flag_pattern})")
                if opt.choices:
                    words = _bash_wordlist(list(opt.choices))
                    lines.append(
                        f'                COMPREPLY=(); while IFS= read -r w; do COMPREPLY+=("$w"); done < <(compgen -W {words} -- "$cur")'
                    )
                elif opt.is_path:
                    lines.append(
                        '                COMPREPLY=(); while IFS= read -r w; do COMPREPLY+=("$w"); done < <(compgen -f -- "$cur")'
                    )
                else:
                    lines.append("                COMPREPLY=()")
                lines.append("                return 0 ;;")
            lines.append("        esac")

        flags = _flag_names(cspec)
        n_pos = len(cspec.positionals)
        lines.append('        case "$cur" in')
        lines.append("            -*)")
        if flags:
            words = _bash_wordlist(flags)
            lines.append(
                f'                COMPREPLY=(); while IFS= read -r w; do COMPREPLY+=("$w"); done < <(compgen -W {words} -- "$cur")'
            )
        else:
            lines.append("                COMPREPLY=()")
        lines.append("                return 0 ;;")
        lines.append("        esac")

        if n_pos:
            lines.append('        case "$npos" in')
            for idx, pos in enumerate(cspec.positionals):
                lines.append(f"            {idx})")
                if pos.choices:
                    words = _bash_wordlist(list(pos.choices))
                    lines.append(
                        f'                COMPREPLY=(); while IFS= read -r w; do COMPREPLY+=("$w"); done < <(compgen -W {words} -- "$cur")'
                    )
                elif pos.is_path:
                    lines.append(
                        '                COMPREPLY=(); while IFS= read -r w; do COMPREPLY+=("$w"); done < <(compgen -f -- "$cur")'
                    )
                else:
                    lines.append("                COMPREPLY=()")
                lines.append("                return 0 ;;")
            lines.append("        esac")

        if cspec.subcommands:
            names = sorted(cspec.subcommands)
            words = _bash_wordlist(names)
            lines.append(f"        if [ $npos -eq {n_pos} ]; then")
            lines.append(
                f'            COMPREPLY=(); while IFS= read -r w; do COMPREPLY+=("$w"); done < <(compgen -W {words} -- "$cur")'
            )
            lines.append("            return 0")
            lines.append("        fi")

        lines.append("        COMPREPLY=()")
        lines.append("        return 0")
        lines.append("    fi")

    lines.append("}")
    lines.append(
        f"complete -o bashdefault -o default -o filenames -F {func} {_bashq(root_prog)}"
    )
    lines.append("")
    return "\n".join(lines)

fish(parser, prog=None)

Emit a fish completion script (complete -c <prog> ... lines) for parser.

Each rule is gated by _fish_condition, which resolves the exact (sub)command path via a generated helper function (see _fish_path_resolver) so a node's flags and subcommand names appear only on its own exact path -- not above, below, or at a same-named sibling path elsewhere in the tree. Choice/free-value options use -x (require a value, no file completion mixed in); Path-typed options keep -r -F for fish's native file completion. Every value reaching a -a or -n argument -- which fish tokenizes and expands AGAIN at completion time -- is escaped for that second pass with _fish_word before being wrapped for the static parse with _fsq, fish's own quoter that also escapes a trailing backslash: it's, dry run, $(touch x) and a Windows-style C:\ choice all round-trip as literal text instead of running or corrupting the file.

Source code in src/duho/completion/_fish.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def fish(parser: _argparse.ArgumentParser, prog: _ty.Optional[str] = None) -> str:
    """Emit a fish completion script (`complete -c <prog> ...` lines) for `parser`.

    Each rule is gated by `_fish_condition`, which resolves the exact
    (sub)command path via a generated helper function (see
    `_fish_path_resolver`) so a node's flags and subcommand names appear
    only on its own exact path -- not above, below, or at a same-named
    sibling path elsewhere in the tree. Choice/free-value options use `-x`
    (require a value, no file completion mixed in); Path-typed options keep
    `-r -F` for fish's native file completion. Every value reaching a `-a`
    or `-n` argument -- which fish tokenizes and expands AGAIN at
    completion time -- is escaped for that second pass with `_fish_word`
    before being wrapped for the static parse with `_fsq`, fish's own
    quoter that also escapes a trailing backslash: `it's`, `dry run`,
    `$(touch x)` and a Windows-style `C:\\` choice all round-trip as literal
    text instead of running or corrupting the file.
    """
    root = _walk(parser, prog=prog)
    root_prog = _validate_prog(root.prog)
    prog_q = _fsq(root_prog)
    specs = _all_specs(root)
    path_func = _fish_func_name(root_prog)

    lines: list[str] = []
    lines.append(f"# fish completion for {root_prog}")
    lines.append(f"complete -c {prog_q} -f")
    lines.append("")
    lines.extend(_fish_path_resolver(path_func, specs))

    for cspec in specs:
        cond_args = ["-n", _fsq(_fish_condition(cspec, path_func))]

        for name, sub in cspec.subcommands.items():
            parts = (
                [f"complete -c {prog_q}"] + cond_args + ["-a", _fsq(_fish_word(name))]
            )
            # The one-line help, NOT the fully-qualified prog, as the description.
            description = sub.help or ""
            if description:
                parts.extend(["-d", _fsq(description)])
            lines.append(" ".join(parts))

        for opt in cspec.options:
            long_flags = [f for f in opt.flags if f.startswith("--")]
            # A single-dash MULTI-char flag (e.g. ``-rc``) is an old-style flag:
            # fish's ``-s`` is for a single character only, so use ``-o`` instead.
            short_flags = [
                f
                for f in opt.flags
                if not f.startswith("--")
                and f.startswith("-")
                and len(f.lstrip("-")) == 1
            ]
            old_flags = [
                f
                for f in opt.flags
                if not f.startswith("--")
                and f.startswith("-")
                and len(f.lstrip("-")) > 1
            ]
            parts = [f"complete -c {prog_q}"] + cond_args
            for lf in long_flags:
                parts.extend(["-l", _fsq(lf.lstrip("-"))])
            for sf in short_flags:
                parts.extend(["-s", _fsq(sf.lstrip("-"))])
            for of in old_flags:
                parts.extend(["-o", _fsq(of.lstrip("-"))])
            if opt.takes_value:
                if opt.choices:
                    values = " ".join(_fish_word(c) for c in opt.choices)
                    parts.extend(["-x", "-a", _fsq(values)])
                elif opt.is_path:
                    parts.extend(["-r", "-F"])
                else:
                    # A free-value option: require a value but offer no
                    # candidates of our own; do not also mix in files.
                    parts.append("-x")
            lines.append(" ".join(parts))

        for pos in cspec.positionals:
            if pos.choices:
                values = " ".join(_fish_word(c) for c in pos.choices)
                parts = [f"complete -c {prog_q}"] + cond_args + ["-a", _fsq(values)]
                lines.append(" ".join(parts))
            elif pos.is_path:
                parts = [f"complete -c {prog_q}"] + cond_args + ["-F"]
                lines.append(" ".join(parts))

    lines.append("")
    return "\n".join(lines)

powershell(parser, prog=None)

Emit a PowerShell completion script for parser.

Registers a Register-ArgumentCompleter -Native script block that reconstructs the (sub)command path from the command elements strictly BEFORE the cursor, descending only on words that are real subcommand names of the current node (a per-path table, mirroring bash), and tracks how many of the current node's own positionals have been consumed so only the pending one's choices are offered.

Descends into a subcommand only once the current node's own positionals are already consumed ($nposByPath, mirroring bash's own_pos gate) -- argparse itself consumes a node's own positionals before ever treating a word as its subparsers dispatch value, so a positional whose choices happen to include a real subcommand name is never mistaken for one.

Every value-taking flag gets an explicit branch -- choices, an empty result for a Path flag (native file completion takes over), and an empty result for a free-value flag -- and command-path/flag comparisons are case-SENSITIVE (-ceq/-ccontains/-cmatch), matching argparse instead of PowerShell's default case-insensitivity. The three per-path LOOKUP TABLES ($subsByPath/$vflagsByPath/ $nposByPath) are ordinal (case-sensitive) Dictionary instances, not PowerShell's own @{} hashtable literal, which compares keys case-INsensitively by default -- with a plain @{}, sibling subcommands differing only in case (run/Run) shared one slot and clobbered each other's positional/subcommand tables. The inserted completion TEXT is ALWAYS single-quoted (both the ASCII quote and PowerShell's Unicode "smart" single-quote range doubled), unconditionally rather than only when it contains whitespace or a metacharacter, so a candidate like dry run or $(rm) is inserted as one literal argument instead of being split or evaluated when the line is run -- this is a different protection from _psq, which only keeps the script BODY safe when it is first parsed.

Source code in src/duho/completion/_powershell.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def powershell(parser: _argparse.ArgumentParser, prog: _ty.Optional[str] = None) -> str:
    """Emit a PowerShell completion script for `parser`.

    Registers a ``Register-ArgumentCompleter -Native`` script block that
    reconstructs the (sub)command path from the command elements strictly
    BEFORE the cursor, descending only on words that are real subcommand
    names of the current node (a per-path table, mirroring bash), and
    tracks how many of the current node's own positionals
    have been consumed so only the pending one's choices are offered.

    Descends into a subcommand only once the current node's own positionals
    are already consumed (`$nposByPath`, mirroring bash's `own_pos` gate) --
    argparse itself consumes a node's own positionals before ever treating a
    word as its subparsers dispatch value, so a positional whose choices
    happen to include a real subcommand name is never mistaken for one.

    Every value-taking flag gets an explicit branch -- choices, an empty
    result for a Path flag (native file completion takes over), and an
    empty result for a free-value flag -- and command-path/flag
    comparisons are case-SENSITIVE (``-ceq``/``-ccontains``/``-cmatch``),
    matching argparse instead of PowerShell's default case-insensitivity.
    The three per-path LOOKUP TABLES (`$subsByPath`/`$vflagsByPath`/
    `$nposByPath`) are ordinal (case-sensitive) `Dictionary` instances, not
    PowerShell's own `@{}` hashtable literal, which compares keys
    case-INsensitively by default -- with a plain `@{}`, sibling subcommands
    differing only in case (``run``/``Run``) shared one slot and clobbered
    each other's positional/subcommand tables. The inserted completion TEXT
    is ALWAYS single-quoted (both the ASCII quote and PowerShell's Unicode
    "smart" single-quote range doubled), unconditionally rather than only
    when it contains whitespace or a metacharacter, so a candidate like
    ``dry run`` or ``$(rm)`` is inserted as one literal argument instead of
    being split or evaluated when the line is run -- this is a *different*
    protection from `_psq`, which only keeps the script BODY safe when it is
    first parsed.
    """
    root = _walk(parser, prog=prog)
    root_prog = _validate_prog(root.prog)
    specs = _all_specs(root)

    lines: list[str] = []
    lines.append(f"# PowerShell completion for {root_prog}")
    lines.append(
        f"Register-ArgumentCompleter -Native -CommandName {_psq(root_prog)} "
        f"-ScriptBlock {{"
    )
    lines.append("    param($wordToComplete, $commandAst, $cursorPosition)")
    lines.append("")
    lines.append("    $elements = @($commandAst.CommandElements)")
    # `@{}` compares keys case-insensitively, so `run` and `Run` would share
    # a table; an ordinal Dictionary keeps them apart.
    dict_ctor = (
        "[System.Collections.Generic.Dictionary[string,object]]::new("
        "[StringComparer]::Ordinal)"
    )
    lines.append(f"    $subsByPath = {dict_ctor}")
    for s in specs:
        if s.subcommands:
            names = ", ".join(_psq(n) for n in s.subcommands)
            lines.append(f"    $subsByPath[{_psq(_cmd_key(s))}] = @({names})")
    lines.append(f"    $vflagsByPath = {dict_ctor}")
    for s in specs:
        vflags = _value_flag_names(s)
        if vflags:
            values = ", ".join(_psq(f) for f in vflags)
            lines.append(f"    $vflagsByPath[{_psq(_cmd_key(s))}] = @({values})")
    lines.append(f"    $nposByPath = {dict_ctor}")
    for s in specs:
        if s.subcommands:
            lines.append(f"    $nposByPath[{_psq(_cmd_key(s))}] = {len(s.positionals)}")
    lines.append("")
    lines.append("    # Reconstruct the (sub)command path from the elements strictly")
    lines.append("    # before the cursor, descending only on real subcommand names")
    lines.append("    # of the current node and skipping a value flag's value.")
    lines.append("    $cmdPath = ''")
    lines.append("    $npos = 0")
    lines.append("    $skip = $false")
    lines.append("    $prev = ''")
    lines.append("    for ($i = 1; $i -lt $elements.Count; $i++) {")
    lines.append("        $el = $elements[$i]")
    lines.append("        # Skip any element whose extent reaches the cursor: the word")
    lines.append("        # currently being completed always ends there, whether it is")
    lines.append("        # empty or partially typed. A text-equality check here would")
    lines.append(
        "        # ALSO skip an earlier, already-typed element that happens to"
    )
    lines.append("        # repeat the same text (e.g. a subcommand named the same as")
    lines.append("        # the word being completed), dropping it from $cmdPath.")
    lines.append("        if ($el.Extent.EndOffset -ge $cursorPosition) { continue }")
    # Use the dequoted value of a string constant: `.Extent.Text` keeps the
    # user's quotes and would never match the unquoted tables. Anything else
    # uses the raw text.
    lines.append(
        "        $text = if ($el -is "
        "[System.Management.Automation.Language.StringConstantExpressionAst]) "
        "{ $el.Value } else { $el.Extent.Text }"
    )
    lines.append("        $prev = $text")
    lines.append("        if ($skip) { $skip = $false; continue }")
    lines.append("        if ($text -clike '-*') {")
    lines.append("            $vflags = @()")
    lines.append(
        "            if ($vflagsByPath.ContainsKey($cmdPath)) { $vflags = $vflagsByPath[$cmdPath] }"
    )
    lines.append("            if ($vflags -ccontains $text) { $skip = $true }")
    lines.append("            continue")
    lines.append("        }")
    lines.append("        $subs = @()")
    lines.append(
        "        if ($subsByPath.ContainsKey($cmdPath)) { $subs = $subsByPath[$cmdPath] }"
    )
    lines.append("        $ownPos = 0")
    lines.append(
        "        if ($nposByPath.ContainsKey($cmdPath)) { $ownPos = $nposByPath[$cmdPath] }"
    )
    lines.append("        if ($npos -ge $ownPos -and $subs -ccontains $text) {")
    lines.append(
        '            $cmdPath = if ($cmdPath) { "$cmdPath $text" } else { $text }'
    )
    lines.append("            $npos = 0")
    lines.append("        } else {")
    lines.append("            $npos++")
    lines.append("        }")
    lines.append("    }")
    lines.append("")
    lines.append("    $candidates = @()")

    first = True
    for cspec in specs:
        key = _cmd_key(cspec)
        cond = "if" if first else "elseif"
        first = False
        lines.append(f"    {cond} ($cmdPath -ceq {_psq(key)}) {{")

        choice_opts = [o for o in cspec.options if o.takes_value and o.choices]
        other_value_opts = [o for o in cspec.options if o.takes_value and not o.choices]
        if choice_opts or other_value_opts:
            lines.append("        $matched = $false")
            lines.append("        switch -CaseSensitive -Exact ($prev) {")
            for opt in choice_opts:
                values = ", ".join(_psq(c) for c in opt.choices)
                for flag in opt.flags:
                    lines.append(
                        f"            {_psq(flag)} {{ $candidates = @({values}); $matched = $true }}"
                    )
            for opt in other_value_opts:
                # A Path flag: native file completion takes over. A free
                # (non-choice, non-Path) flag: no candidates of our own,
                # never the surrounding flags/subcommand names.
                for flag in opt.flags:
                    lines.append(
                        f"            {_psq(flag)} {{ $candidates = @(); $matched = $true }}"
                    )
            lines.append("        }")
            lines.append("        if (-not $matched) {")
            indent = "            "
        else:
            lines.append("        if ($true) {")
            indent = "            "

        lines.append(f"{indent}if ($wordToComplete -clike '-*') {{")
        flag_names = _flag_names(cspec)
        if flag_names:
            values = ", ".join(_psq(f) for f in flag_names)
            lines.append(f"{indent}    $candidates = @({values})")
        else:
            lines.append(f"{indent}    $candidates = @()")
        lines.append(f"{indent}}} else {{")
        n_pos = len(cspec.positionals)
        if n_pos:
            lines.append(f"{indent}    switch ($npos) {{")
            for idx, pos in enumerate(cspec.positionals):
                lines.append(f"{indent}        {idx} {{")
                if pos.choices:
                    values = ", ".join(_psq(c) for c in pos.choices)
                    lines.append(f"{indent}            $candidates = @({values})")
                else:
                    lines.append(f"{indent}            $candidates = @()")
                lines.append(f"{indent}        }}")
            lines.append(f"{indent}        default {{ $candidates = @() }}")
            lines.append(f"{indent}    }}")
        if cspec.subcommands:
            sub_names = ", ".join(_psq(n) for n in cspec.subcommands)
            lines.append(
                f"{indent}    if ($npos -eq {n_pos}) {{ $candidates = @({sub_names}) }}"
            )
        if not n_pos and not cspec.subcommands:
            lines.append(f"{indent}    $candidates = @()")
        lines.append(f"{indent}}}")
        lines.append("        }")
        lines.append("    }")

    lines.append("")
    lines.append("    $escaped = [regex]::Escape($wordToComplete)")
    lines.append(
        '    $candidates | Where-Object { $_ -cmatch "^$escaped" } '
        "| Sort-Object -Unique -CaseSensitive | ForEach-Object {"
    )
    lines.append("        $text = $_")
    # Quote every candidate, doubling the ASCII and the Unicode "smart" single
    # quotes (U+2018-U+201B) the tokenizer treats alike; quoting unconditionally
    # also keeps a leading `#` or `@` literal.
    lines.append(
        '        $text = "\'" + ($text -replace '
        "'[''\\u2018\\u2019\\u201A\\u201B]', '$0$0') + \"'\""
    )
    lines.append(
        "        [System.Management.Automation.CompletionResult]::new("
        "$text, $_, 'ParameterValue', $_)"
    )
    lines.append("    }")
    lines.append("}")
    lines.append("")
    return "\n".join(lines)

spec(parser, prog=None)

Build the shell-agnostic :class:CompletionSpec tree for parser.

Public wrapper around the internal parser-tree walk: the dataclasses are documented and exported, but until now the only producer was the private _walk, so a caller writing a completion emitter for a shell this module doesn't cover (nushell, elvish, ...) had no supported way to get one. Additive, so a PATCH release pre-1.0.

Source code in src/duho/completion/_spec.py
172
173
174
175
176
177
178
179
180
181
182
183
def spec(
    parser: _argparse.ArgumentParser, prog: _ty.Optional[str] = None
) -> CompletionSpec:
    """Build the shell-agnostic :class:`CompletionSpec` tree for ``parser``.

    Public wrapper around the internal parser-tree walk: the
    dataclasses are documented and exported, but until now the only producer
    was the private `_walk`, so a caller writing a completion emitter for a
    shell this module doesn't cover (nushell, elvish, ...) had no supported
    way to get one. Additive, so a PATCH release pre-1.0.
    """
    return _walk(parser, prog=prog)

zsh(parser, prog=None)

Emit a #compdef-style zsh completion script for parser.

Standard zsh subcommand dispatch: one function per (sub)command node. A node with subcommands calls _arguments -C -s with a single *::arg:->args rest spec covering EVERYTHING after its own options -- never separate numbered specs for its own positionals or the command name. Sharing one _arguments -C call between a plain numbered positional and *:: makes zsh's own bookkeeping of "which word is which" ambiguous ($line cycles between several candidate splits instead of settling on one), so a node with a positional of its own ahead of a subcommand table never reached that subcommand at all. Folding both the node's own positionals AND the command word into *::arg:->args keeps $line a single, deterministic source of truth: $line's last element is always the word currently being completed (even when empty), so $#line - 1 is the number of this node's own words already typed in full -- fewer than its positional count still means completing one of ITS OWN positionals, exactly that many means completing the command word itself, and more means dispatch: $line[<n_pos + 1>] is the (already-typed) subcommand name. Because the real _arguments engine parses options for us, a value-taking flag before the cursor never breaks this count. ${(Q)...} strips any quoting the user themselves typed around the subcommand name before comparing it to our own unquoted case labels -- otherwise a subcommand name that needed quoting (spaces, a shell metacharacter) could never dispatch, since $line preserves the user's raw typed form. Every choice and positional message is escaped for zsh's SECOND (dynamic) evaluation via _zsh_word before being wrapped in _sq for the first: a value containing $(...), ;, or a colon cannot run code or break the spec at Tab-time. The root function name itself is hashed (_zsh_root_func_name), matching _bash_func_name/_fish_func_name, so two progs that sanitise to the same identifier (my-app/my.app) never collide -- every nested function name is built on top of this root name.

Source code in src/duho/completion/_zsh.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def zsh(parser: _argparse.ArgumentParser, prog: _ty.Optional[str] = None) -> str:
    """Emit a `#compdef`-style zsh completion script for `parser`.

    Standard zsh subcommand dispatch: one function per (sub)command node. A
    node with subcommands calls `_arguments -C -s` with a single `*::arg:->args`
    rest spec covering EVERYTHING after its own options -- never separate
    numbered specs for its own positionals or the command name. Sharing one
    `_arguments -C` call between a plain numbered positional and `*::` makes
    zsh's own bookkeeping of "which word is which" ambiguous (`$line` cycles
    between several candidate splits instead of settling on one), so a node
    with a positional of its own ahead of a subcommand table never reached
    that subcommand at all. Folding both the node's own positionals AND the
    command word into `*::arg:->args` keeps `$line` a single, deterministic
    source of truth: `$line`'s last element is always the word currently
    being completed (even when empty), so `$#line - 1` is the number of this
    node's own words already typed in full -- fewer than its positional
    count still means completing one of ITS OWN positionals, exactly that
    many means completing the command word itself, and more means dispatch:
    `$line[<n_pos + 1>]` is the (already-typed) subcommand name.  Because the
    real `_arguments` engine parses options for us, a value-taking flag
    before the cursor never breaks this count. `${(Q)...}` strips any
    quoting the user themselves typed around the subcommand name before
    comparing it to our own unquoted case labels -- otherwise a subcommand
    name that needed quoting (spaces, a shell metacharacter) could never
    dispatch, since `$line` preserves the user's raw typed form. Every
    choice and positional message is escaped for zsh's SECOND (dynamic)
    evaluation via `_zsh_word` before being wrapped in `_sq` for the first:
    a value containing `$(...)`, `;`, or a colon cannot run code or
    break the spec at Tab-time. The root function name itself is hashed
    (`_zsh_root_func_name`), matching `_bash_func_name`/`_fish_func_name`, so
    two progs that sanitise to the same identifier (`my-app`/`my.app`) never
    collide -- every nested function name is built on top of this root name.
    """
    root = _walk(parser, prog=prog)
    root_prog = _validate_prog(root.prog)
    func = _zsh_root_func_name(root_prog)

    lines: list[str] = []
    lines.append(f"#compdef {root_prog}")
    lines.append("")

    for cspec in _all_specs(root):
        funcid = _zsh_funcid(func, cspec.path)
        lines.append(f"{funcid} () {{")

        args_items: list[str] = [_zsh_optspec(opt) for opt in cspec.options]
        n_pos = len(cspec.positionals)

        if cspec.subcommands:
            lines.append('    local curcontext="$curcontext" state state_descr line')
            lines.append("    typeset -A opt_args")
            args_items.append(_sq("*::arg:->args"))
            arguments_flags = "-C -s"
        else:
            n = 0
            for pos in cspec.positionals:
                n += 1
                args_items.append(_sq(_zsh_pos_spec(n, pos)))
            arguments_flags = "-s"

        if args_items:
            lines.append(f"    _arguments {arguments_flags} \\")
            for idx, item in enumerate(args_items):
                sep = " \\" if idx < len(args_items) - 1 else ""
                lines.append(f"        {item}{sep}")
        else:
            lines.append(f"    _arguments {arguments_flags}")

        if cspec.subcommands:
            lines.append("")
            lines.append("    case $state in")
            lines.append("        args)")
            lines.append("            case $(( ${#line} - 1 )) in")
            for idx, pos in enumerate(cspec.positionals):
                lines.append(f"                {idx})")
                if pos.choices:
                    # Each candidate is one literal shell word (`compadd` gets only
                    # the static parse), so `_sq` alone suffices, without the
                    # `_zsh_word` an `_arguments` spec string needs.
                    values = " ".join(_sq(c) for c in pos.choices)
                    lines.append(f"                    compadd -- {values}")
                elif pos.is_path:
                    lines.append("                    _files")
                lines.append("                    ;;")
            lines.append(f"                {n_pos})")
            names = " ".join(_sq(name) for name in cspec.subcommands)
            lines.append(f"                    compadd -- {names}")
            lines.append("                    ;;")
            lines.append("                *)")
            # `_arguments -C` republishes `$words`/`$CURRENT` only relative to
            # `*::`, not past this node's `n_pos` own positionals: reset both
            # here to start at the subcommand name.
            lines.append("                    local words CURRENT")
            # `(@)` keeps an array slice, including a trailing empty element for
            # the word being completed; a plain slice would drop it.
            lines.append(f'                    words=("${{(@)line[{n_pos + 1},-1]}}")')
            lines.append("                    CURRENT=${#words}")
            lines.append(f"                    case ${{(Q)line[{n_pos + 1}]}} in")
            for name, sub in cspec.subcommands.items():
                subfuncid = _zsh_funcid(func, sub.path)
                # A quoted case label is matched literally (no glob
                # interpretation of a hostile name) -- reuse `_sq`.
                lines.append(f"                        {_sq(name)})")
                lines.append(f"                            {subfuncid}")
                lines.append("                            ;;")
            lines.append("                    esac")
            lines.append("                    ;;")
            lines.append("            esac")
            lines.append("            ;;")
            lines.append("    esac")

        lines.append("}")
        lines.append("")

    lines.append(f'{_zsh_funcid(func, ())} "$@"')
    lines.append("")
    return "\n".join(lines)