usage-argv 6.0.0

Zero-allocation argv parser for usage specs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
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
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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
//! The shell scripts that call the hidden completion command.
//!
//! Each is a handful of lines, because the thinking is on the other side: the binary answers with
//! candidates and, when paths belong there, a marker. So a script's whole job is to hand over the
//! line and the cursor, present what comes back the way its shell presents things, and pass the
//! position to the shell's own path completion if the marker appeared.
//!
//! What that replaces is worth stating. mise's current scripts hard-fail unless the separate
//! `usage` CLI is installed, dump a spec into `$XDG_CACHE_HOME` with `mise usage`, prune stale
//! spec files by age, and shell out to `usage complete-word` on every Tab. None of that is here.
//!
//! Where a shell already knows how to do something, it does it: zsh's `compadd` with aligned
//! descriptions, fish's `__fish_complete_path`, PowerShell's `CompletionCompleters`. The one place
//! this file argues with a shell is bash, which needs `compopt -o filenames` set from inside the
//! function because whether paths are involved is not known until the answer comes back.

use crate::complete::Shell;

/// The phrase every generated script carries, and the only way to tell one from a file somebody
/// wrote by hand.
///
/// Deliberately the *family* and not this crate: usage-lib stamps `@generated by usage-cli`, and
/// `usage g completion --install` writes those scripts through the same writer this one uses. An
/// ownership test naming only `usage-argv` would call every CLI-generated script a stranger's file
/// and refuse to upgrade it, which is the whole promise inverted.
///
/// [`crate::install`] reads this back off disk to decide whether replacing a file is an upgrade or
/// a theft, so changing this string turns every already-installed script into a stranger's file.
/// `every_generated_script_carries_the_marker_the_installer_looks_for` and
/// `a_script_generated_by_the_cli_is_ours_too` are the guards.
pub(crate) const GENERATED_MARKER: &str = "@generated by usage";

/// Whether a name can be registered, invoked, and quoted in every generated script.
///
/// The accepted set is what binaries are actually called. It is a shared predicate because two
/// callers have to agree on it and disagree on what to do about a failure: [`script_for`] asserts,
/// since the name comes from the spec its author wrote, while [`crate::install`] returns an error,
/// since a name can reach it from a command line.
pub(crate) fn is_one_shell_word(name: &str) -> bool {
    // `.` and `..` are spelled out of accepted characters and are not names: for bash the file is
    // called the name itself, so either one would plan a path that leaves the directory the plan
    // just chose. Refused here, where the answer is a diagnostic, rather than later as a failed
    // write.
    !name.is_empty()
        && name != "."
        && name != ".."
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
}

/// The completion script for `bin` in `shell`, ready to be written to a file or sourced.
///
/// The binary is named rather than found: a script that resolved the binary itself would complete
/// against whichever copy came first on `PATH`, which is not always the one the user is typing.
///
/// Every invocation quotes the name, so one containing a space still *runs*. Registering it is
/// another matter: zsh's `#compdef` line is a magic comment read by `compinit` before any shell
/// quoting happens, and there is nowhere to put a quote in it. A binary whose name is not a
/// single shell word therefore cannot be completed in zsh by anyone, which the assertion says
/// out loud rather than leaving to be discovered at a prompt.
pub fn script(bin: &str, shell: Shell) -> String {
    script_for(bin, bin, shell)
}

/// A completion script registered for `name` that asks `bin` for its answers.
///
/// This is the explicit shell-alias API: sourcing the result completes `name`, while the
/// callback always executes the real binary. It does not inspect shell configuration or rely
/// on the shell expanding aliases in a non-interactive completion subprocess.
pub fn script_for(bin: &str, name: &str, shell: Shell) -> String {
    // A hard assertion, not a debug one: the alternative is a release build quietly writing a
    // script that registers half a name, or one whose apostrophe closes the quoting around it
    // and turns the rest into something else entirely. The name comes from the spec its author
    // wrote, not from anything a user typed, so this is a mistake surfacing where it can be
    // fixed. The accepted set is what binaries are actually called.
    assert!(
        is_one_shell_word(name),
        "a completion script cannot register {name:?}: a binary's name has to be one plain \
         shell word, and zsh's `#compdef` line has nowhere to put a quote even if it were \
         quoted everywhere else"
    );
    assert!(
        is_one_shell_word(bin),
        "a completion script cannot invoke {bin:?}: a binary's name has to be one plain \
         shell word so it can be quoted safely in every generated script"
    );
    match shell {
        Shell::Bash => bash(bin, name),
        Shell::Zsh => zsh(bin, name),
        Shell::Fish => fish(bin, name),
        Shell::Nu => nu(bin, name, &nu_ident(name)),
        Shell::PowerShell => powershell(bin, name),
    }
}

/// A nushell identifier for a binary's name, one name to one identifier.
///
/// The other four shells take a binary's name verbatim as part of a function name — bash, zsh and
/// fish all accept `-`, `.` and `+` there, which is worth knowing because sanitizing them away is
/// what makes two names collide. nushell binds a variable, where `-` would be read as
/// subtraction, so its name has to be escaped rather than flattened: flattening mapped `foo-bar`
/// and `foo+bar` both to `foo_bar`, and two scripts loaded together would each have completed the
/// other's binary.
fn nu_ident(bin: &str) -> String {
    let mut out = String::with_capacity(bin.len());
    for c in bin.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c);
        } else {
            // The underscore is escaped too, so that no escape can be spelled by hand into a
            // name and collide with the character it stands for.
            out.push_str(&format!("_x{:02x}", c as u32));
        }
    }
    out
}

/// The header every script carries, so nobody edits one by hand twice.
fn header(bin: &str, shell: Shell, comment: &str) -> String {
    format!(
        "{comment} {GENERATED_MARKER}-argv for `{bin} __complete_word__ --shell {}`\n\
         {comment} Do not edit: regenerate it. Needs no other program, and no cached spec —\n\
         {comment} the binary answers from the tables it was compiled with.\n",
        shell.as_str()
    )
}

fn bash(bin: &str, name: &str) -> String {
    let head = header(bin, Shell::Bash, "#");
    format!(
        r#"{head}
_usage_complete_{name}() {{
    local __usage_out __usage_line __usage_files=
    # Truncated here rather than passed with an offset: every shell counts a cursor in its own
    # units — characters in a UTF-8 locale for bash and zsh, characters for fish and PowerShell —
    # and a number that means one thing here and another there is a bug waiting for a non-ASCII
    # command line. Cut with the shell's own offset, the units cancel out and what arrives is
    # exactly the text before the cursor.
    __usage_out="$(command '{bin}' __complete_word__ --shell bash \
        --line "${{COMP_LINE:0:$COMP_POINT}}" 2>/dev/null)" || return 1

    COMPREPLY=()
    while IFS= read -r __usage_line; do
        case "$__usage_line" in
            $'\001files') __usage_files=any ;;
            $'\001dirs') __usage_files=dirs ;;
            $'\001executables') __usage_files=executables ;;
            $'\001commands') __usage_files=commands ;;
            '') ;;
            *) COMPREPLY+=("$__usage_line") ;;
        esac
    done <<< "$__usage_out"

    if [[ -n $__usage_files ]]; then
        # Set here rather than on `complete`, because whether this position takes a path is not
        # known until the answer comes back. It is what makes bash append a `/` to a directory
        # and stop escaping what it should not.
        [[ $__usage_files == commands ]] || compopt -o filenames 2>/dev/null
        local __usage_cur="${{COMP_WORDS[COMP_CWORD]}}" __usage_path
        local -a __usage_paths=()
        if [[ $__usage_files == commands ]]; then
            while IFS= read -r __usage_path; do __usage_paths+=("$__usage_path"); done \
                < <(compgen -c -- "$__usage_cur")
        elif [[ $__usage_files == executables ]]; then
            while IFS= read -r __usage_path; do
                [[ -d "$__usage_path" || -x "$__usage_path" ]] && __usage_paths+=("$__usage_path")
            done < <(compgen -f -- "$__usage_cur")
        elif [[ $__usage_files == dirs ]]; then
            while IFS= read -r __usage_path; do __usage_paths+=("$__usage_path"); done \
                < <(compgen -d -- "$__usage_cur")
        else
            while IFS= read -r __usage_path; do __usage_paths+=("$__usage_path"); done \
                < <(compgen -f -- "$__usage_cur")
        fi
        # Guarded, because an empty array expands to one empty word in older bash.
        (( ${{#__usage_paths[@]}} )) && COMPREPLY+=("${{__usage_paths[@]}}")
    fi
}}
complete -F _usage_complete_{name} '{name}'
"#,
    )
}

/// zsh's script, whose function is named after the *binary* rather than sanitized.
///
/// `compinit` autoloads a file from `$fpath` called `_mise` and calls the function of that name,
/// so `_my-tool` is what a `my-tool` completion has to define — and zsh, unlike an identifier,
/// is perfectly happy with the dash.
fn zsh(bin: &str, name: &str) -> String {
    let head = header(bin, Shell::Zsh, "#");
    // `compadd` rather than `_describe`, which groups matches sharing a `:`-separated prefix and
    // shows one per group — so mise's `release:create`, `release:docs-sync` and `release:pr`
    // collapse into a single entry. The display column is built here instead, descriptions
    // aligned to the longest value, which is the presentation the reference arrived at.
    // The magic comment first, the header under it. `compinit` reads only the *first* line of a
    // file in `$fpath` looking for `#compdef`, so a script that leads with a comment of its own
    // is autoloaded and never registers — while sourcing it keeps working through the `compdef`
    // call at the bottom, which is what hid this.
    format!(
        r#"#compdef {name}
{head}
_{name}() {{
    local -a values=() descriptions=() inserts=()
    local __usage_files= __usage_line __usage_menu=0
    # `$BUFFER[1,CURSOR]` is the text before the cursor, cut with zsh's own offset — see the
    # bash script on why the cutting happens here rather than through a `--cursor` argument.
    while IFS= read -r __usage_line; do
        case "$__usage_line" in
            $'\001files') __usage_files=any; continue ;;
            $'\001dirs') __usage_files=dirs; continue ;;
            $'\001executables') __usage_files=executables; continue ;;
            $'\001commands') __usage_files=commands; continue ;;
            '') continue ;;
        esac
        local -a parts=("${{(@ps:\t:)__usage_line}}")
        values+=("${{parts[1]}}")
        descriptions+=("${{parts[2]}}")
        inserts+=("${{parts[3]}}")
        # A quoted insert means the value needed quoting, and zsh should offer a menu rather
        # than silently inserting one of several possibilities.
        [[ "${{parts[3]}}" == "'"* ]] && __usage_menu=1
    done < <(command '{bin}' __complete_word__ --shell zsh \
        --line "${{BUFFER[1,CURSOR]}}" 2>/dev/null)

    local __usage_ret=1
    (( __usage_menu )) && compstate[insert]=menu
    if (( ${{#inserts[@]}} )); then
        local -a display=()
        local i max=0 value pad
        for value in "${{values[@]}}"; do
            (( ${{#value}} > max )) && max=${{#value}}
        done
        for (( i = 1; i <= ${{#values[@]}}; i++ )); do
            if [[ -n "${{descriptions[i]}}" ]]; then
                pad=$(( max - ${{#values[i]}} ))
                display+=("${{values[i]}}${{(l:pad:: :)}}  -- ${{descriptions[i]}}")
            else
                display+=("${{values[i]}}")
            fi
        done
        # `-U` because the binary already filtered by the typed prefix, and `-Q` because the
        # inserts are quoted already.
        compadd -l -d display -U -Q -S '' -a inserts && __usage_ret=0
    fi

    case "$__usage_files" in
        any) _files && __usage_ret=0 ;;
        dirs) _files -/ && __usage_ret=0 ;;
        executables) _files -g '*(-/,*)' && __usage_ret=0 ;;
        commands) _command_names && __usage_ret=0 ;;
    esac
    return $__usage_ret
}}
# Installed either way. Dropped in `$fpath` as `_{name}`, compinit autoloads the file and calls
# the function named after it — which is why the function is `_{name}` and not something tidier.
# Sourced from a config instead, nothing has called it yet, so it registers itself.
if [ "$funcstack[1]" = "_{name}" ]; then
    _{name} "$@"
else
    compdef _{name} '{name}'
fi
"#,
    )
}

fn fish(bin: &str, name: &str) -> String {
    let head = header(bin, Shell::Fish, "#");
    // `commandline -cp` is the line up to the cursor, so the cursor is its length — fish has no
    // separate offset to pass, and needs none.
    format!(
        r#"{head}
function __usage_complete_{name}
    set -l line (commandline -cp)
    # `commandline -cp` is already cut at the cursor, so there is nothing to say about where
    # the cursor is: the end of what it gives is where the cursor was.
    set -l out (command '{bin}' __complete_word__ --shell fish --line "$line" 2>/dev/null)
    # Built with printf rather than written literally: fish's `case` takes patterns, not
    # computed values, and a control byte is not something to spell twice.
    set -l marker_any (printf '\x01files')
    set -l marker_dirs (printf '\x01dirs')
    set -l marker_executables (printf '\x01executables')
    set -l marker_commands (printf '\x01commands')
    set -l files ""
    for entry in $out
        if test "$entry" = "$marker_any"
            set files any
        else if test "$entry" = "$marker_dirs"
            set files dirs
        else if test "$entry" = "$marker_executables"
            set files executables
        else if test "$entry" = "$marker_commands"
            set files commands
        else if test -n "$entry"
            # printf, not echo: fish's echo reads a leading -n, -e, -s or -E as its own option,
            # so a CLI with a `-n` would have that candidate swallowed on the way to the prompt.
            # printf takes its format first and everything after it as data.
            printf '%s\n' $entry
        end
    end
    # fish's own path completion, which knows about `~`, variables and remote paths.
    switch $files
        case any
            __fish_complete_path (commandline -ct)
        case dirs
            __fish_complete_directories (commandline -ct)
        case executables
            for candidate in (__fish_complete_path (commandline -ct))
                set -l value (string split -m 1 (printf '\t') -- $candidate)[1]
                if test -d "$value"; or test -x "$value"
                    printf '%s\n' $candidate
                end
            end
        case commands
            __fish_complete_command (commandline -ct)
    end
end

# `-f` so fish offers no filenames of its own: this CLI says when they belong, and the function
# produces them itself when they do.
complete -c '{name}' -f -a '(__usage_complete_{name})'
"#,
    )
}

fn nu(bin: &str, name: &str, ident: &str) -> String {
    let head = header(bin, Shell::Nu, "#");
    // nushell's external completer is handed the spans it split, never the line — so the line is
    // put back together from them. Lossless in the direction that matters: nushell has already
    // done the splitting, and re-quoting a span that contains a space says so.
    format!(
        r#"{head}
def --env __usage_complete_{ident} [spans: list<string>] {{
    let line = ($spans | each {{|span|
        if ($span | str contains " ") {{ $'"($span)"' }} else {{ $span }}
    }} | str join " ")
    let out = (^{bin} __complete_word__ --shell nu --line $line | complete)
    if $out.exit_code != 0 {{ return null }}
    let lines = ($out.stdout | lines | where {{|l| $l != "" }})
    let marker = "\u{{1}}"
    let wants_files = ($lines | any {{|l| $l == $marker + "files" or $l == $marker + "dirs" or $l == $marker + "executables" }})
    let wants_commands = ($lines | any {{|l| $l == $marker + "commands" }})
    let declared = (
        $lines
        | where {{|l| not ($l | str starts-with $marker) }}
        | each {{|l|
            let parts = ($l | split row (char tab))
            {{
                value: ($parts | get 0)
                description: (if ($parts | length) > 1 {{ $parts | get 1 }} else {{ "" }})
            }}
        }}
    )
    # which with no names returns nushell's commands and the executables on PATH.
    # Unlike commandline complete, it cannot reinterpret an exact command name as
    # the start of that command's arguments or re-enter this external completer.
    let commands = (if $wants_commands {{
        let prefix = ($spans | last)
        let insensitive = $nu.os-info.name == "windows"
        let match_prefix = if $insensitive {{ $prefix | str downcase }} else {{ $prefix }}
        which
        | where {{|row|
            let candidate = if $insensitive {{ $row.command | str downcase }} else {{ $row.command }}
            $candidate | str starts-with $match_prefix
        }}
        | each {{|row| {{ value: $row.command, description: $row.path }} }}
    }} else {{
        []
    }})
    let candidates = ($declared | append $commands)
    let command_is_path = (($spans | last) | str contains "/") or (($spans | last) | str contains "\\")
    let wants_path_fallback = $wants_files or ($wants_commands and $command_is_path)
    # `null` is how a nushell completer says "you do this one", and what it does is complete
    # paths. So an answer that is only the marker returns null rather than nothing, which would
    # mean "there is nothing here".
    #
    # Where there are candidates *and* paths, nushell can be told one or the other and not both:
    # returning a list means "these are the completions", and there is no option beside it for
    # "and files too". The candidates win, because they are what this CLI knows and a path is
    # something the user can finish typing. Every other shell here appends both; this is
    # nushell's completer interface rather than a decision of this design.
    if ($candidates | is-empty) and $wants_path_fallback {{ null }} else {{ $candidates }}
}}

# Slot this into the external completer nushell already has, if any, rather than replacing it:
# a config that completes several tools should keep completing all of them.
let __usage_previous_{ident} = ($env.config.completions.external.completer? | default null)
$env.config.completions.external.completer = {{|spans|
    if ($spans | get 0) == "{name}" {{
        __usage_complete_{ident} $spans
    }} else if $__usage_previous_{ident} != null {{
        do $__usage_previous_{ident} $spans
    }} else {{
        null
    }}
}}
"#
    )
}

fn powershell(bin: &str, name: &str) -> String {
    let head = header(bin, Shell::PowerShell, "#");
    // PowerShell hands over an AST and a cursor position, and has no way for a native completer
    // to say "you take this one" — so its own file completer is called directly, which is the
    // same machinery it would have used.
    format!(
        r#"{head}
Register-ArgumentCompleter -Native -CommandName '{name}' -ScriptBlock {{
    param($wordToComplete, $commandAst, $cursorPosition)

    $marker = [char]1
    # `$cursorPosition` is an offset into the whole input buffer while `Extent.Text` is only this
    # command's span, so after a pipeline or a `;` the two do not share an origin. Subtracting the
    # extent's start puts them back on the same footing, and cutting here means no offset has to
    # travel — see the bash script on why.
    $extent = $commandAst.Extent
    $offset = $cursorPosition - $extent.StartOffset
    if ($offset -lt 0) {{ $offset = 0 }}
    if ($offset -gt $extent.Text.Length) {{ $offset = $extent.Text.Length }}
    $line = $extent.Text.Substring(0, $offset)
    $out = @(& '{bin}' __complete_word__ --shell powershell --line $line 2>$null)

    $files = $null
    $results = [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new()
    foreach ($entry in $out) {{
        if ([string]::IsNullOrEmpty($entry)) {{ continue }}
        if ($entry -eq ($marker + 'files')) {{ $files = 'any'; continue }}
        if ($entry -eq ($marker + 'dirs')) {{ $files = 'dirs'; continue }}
        if ($entry -eq ($marker + 'executables')) {{ $files = 'executables'; continue }}
        if ($entry -eq ($marker + 'commands')) {{ $files = 'commands'; continue }}
        $parts = $entry -split "`t", 2
        $value = $parts[0]
        $description = if ($parts.Count -gt 1 -and $parts[1]) {{ $parts[1] }} else {{ $value }}
        $results.Add(
            [System.Management.Automation.CompletionResult]::new(
                $value, $value, 'ParameterValue', $description
            )
        )
    }}

    if ($files -eq 'commands') {{
        foreach ($command in Get-Command -Name ($wordToComplete + '*') -CommandType Application, ExternalScript -ErrorAction SilentlyContinue) {{
            $results.Add(
                [System.Management.Automation.CompletionResult]::new(
                    $command.Name, $command.Name, 'Command', $command.Source
                )
            )
        }}
    }} elseif ($files) {{
        # PowerShell's own, so that `~`, drive-relative paths and provider paths behave as they
        # do everywhere else in the shell.
        foreach ($path in [System.Management.Automation.CompletionCompleters]::CompleteFilename($wordToComplete)) {{
            # Trust PowerShell's result type for directories because CompletionText may already
            # carry quoting. Executable leaves are checked as commands after stripping only the
            # outer quote characters PowerShell added.
            if ($files -eq 'dirs' -and $path.ResultType -ne 'ProviderContainer') {{
                continue
            }}
            if ($files -eq 'executables' -and $path.ResultType -ne 'ProviderContainer') {{
                $candidatePath = $path.CompletionText.Trim([char[]]@([char]39, [char]34))
                if (-not (Get-Command -Name $candidatePath -CommandType Application, ExternalScript -ErrorAction SilentlyContinue)) {{
                    continue
                }}
            }}
            $results.Add($path)
        }}
    }}

    $results
}}
"#
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_script_names_the_binary_and_the_hidden_command() {
        for shell in [
            Shell::Bash,
            Shell::Zsh,
            Shell::Fish,
            Shell::Nu,
            Shell::PowerShell,
        ] {
            let out = script("mise", shell);
            assert!(out.contains("mise"), "{shell:?}");
            assert!(out.contains("__complete_word__"), "{shell:?}");
            assert!(out.contains("--shell"), "{shell:?}");
            // And nothing about the thing this replaces: no second program, no cached spec.
            assert!(!out.contains("usage complete-word"), "{shell:?}");
            assert!(!out.contains("XDG_CACHE_HOME"), "{shell:?}");
        }
    }

    #[test]
    fn an_alias_registers_separately_from_the_binary_it_invokes() {
        let cases = [
            (
                Shell::Bash,
                "complete -F _usage_complete_m 'm'",
                "command 'mise'",
            ),
            (Shell::Zsh, "#compdef m", "command 'mise'"),
            (Shell::Fish, "complete -c 'm'", "command 'mise'"),
            (Shell::Nu, r#"== "m""#, "^mise __complete_word__"),
            (
                Shell::PowerShell,
                "-CommandName 'm'",
                "& 'mise' __complete_word__",
            ),
        ];
        for (shell, registration, invocation) in cases {
            let out = script_for("mise", "m", shell);
            assert!(out.contains(registration), "{shell:?}: {out}");
            assert!(out.contains(invocation), "{shell:?}: {out}");
            if shell == Shell::Zsh {
                assert!(out.contains("`_m`"), "{out}");
                assert!(!out.contains("`_mise`"), "{out}");
            }
        }
    }

    /// zsh reads `#compdef` on the *first* line and nowhere else.
    ///
    /// A file in `$fpath` whose first line is a comment of this generator's own is autoloaded and
    /// never registers, while sourcing the same file keeps working through the `compdef` call at
    /// the bottom — so the half that breaks is the half a test has to look for.
    #[test]
    fn the_zsh_script_leads_with_its_magic_comment() {
        let out = script("mise", Shell::Zsh);
        assert!(
            out.starts_with("#compdef mise\n"),
            "the first line has to be the magic comment: {out}"
        );
        // The header is still there, under it.
        assert!(out.contains("# @generated by usage-argv"), "{out}");
    }

    /// A candidate that looks like an option to the shell's own printer is still offered.
    ///
    /// fish's `echo` reads a leading `-n`, `-e`, `-s` or `-E` as its own option, so a CLI with a
    /// `-n` had that candidate swallowed on the way to the prompt. `printf` takes its format
    /// first and everything after it as data.
    #[test]
    fn the_fish_script_prints_candidates_as_data() {
        let out = script("mise", Shell::Fish);
        assert!(
            !out.contains("echo $entry"),
            "fish's echo eats a leading -n: {out}"
        );
        assert!(out.contains(r"printf '%s\n' $entry"), "{out}");
    }

    #[test]
    fn executable_path_markers_filter_non_executable_files() {
        let bash = script("mise", Shell::Bash);
        assert!(
            bash.contains(r#"[[ -d "$__usage_path" || -x "$__usage_path" ]]"#),
            "{bash}"
        );

        let fish = script("mise", Shell::Fish);
        assert!(
            fish.contains(r#"test -d "$value"; or test -x "$value""#),
            "{fish}"
        );

        let powershell = script("mise", Shell::PowerShell);
        assert!(
            powershell.contains("-CommandType Application, ExternalScript"),
            "{powershell}"
        );
        assert!(powershell.contains("} elseif ($files) {"), "{powershell}");
        assert!(!powershell.contains("} else if ($files) {"), "{powershell}");

        let zsh = script("mise", Shell::Zsh);
        assert!(zsh.contains("_files -g '*(-/,*)'"), "{zsh}");
    }

    #[test]
    #[should_panic(expected = "one plain shell word")]
    fn a_name_no_script_could_register_is_refused() {
        // zsh's `#compdef` line is read by `compinit` before shell quoting exists, so a name
        // that is not one word cannot be registered there by anyone. Refused loudly, in release
        // builds too, rather than written into a script that looks fine and completes nothing.
        script("my tool", Shell::Zsh);
    }

    #[test]
    #[should_panic(expected = "one plain shell word")]
    fn a_name_carrying_a_quote_is_refused_too() {
        // The interpolations are single-quoted, so an apostrophe would close the quoting and
        // hand the rest of the line to the shell as syntax.
        script("it's", Shell::Bash);
    }

    #[test]
    fn two_names_that_are_not_identifiers_do_not_become_one() {
        // `foo-bar` and `foo+bar` are two binaries. Flattening both to `foo_bar` meant that with
        // two scripts loaded, completing one ran the other's completer — so bash, zsh and fish
        // take the name verbatim (all three accept these characters in a function name) and
        // nushell, which cannot, escapes rather than flattens.
        for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Nu] {
            let dash = script("foo-bar", shell);
            let plus = script("foo+bar", shell);
            assert_ne!(dash, plus, "{shell:?} generated the same script for both");
            // And neither borrows the other's function name.
            assert!(!dash.contains("foo+bar"), "{shell:?} dash: {dash}");
            assert!(!plus.contains("foo-bar"), "{shell:?} plus: {plus}");
        }

        assert_eq!(nu_ident("foo-bar"), "foo_x2dbar");
        assert_eq!(nu_ident("foo+bar"), "foo_x2bbar");
        // The underscore is escaped too, so a name cannot spell an escape and collide with what
        // it stands for.
        assert_ne!(nu_ident("foo_x2dbar"), nu_ident("foo-bar"));
    }

    #[test]
    fn a_binary_name_that_is_not_an_identifier_still_gives_one() {
        // `my-tool` is an ordinary name for a binary, and bash is perfectly happy with it in a
        // function name — which is why nothing is sanitized away here.
        let out = script("my-tool", Shell::Bash);
        assert!(out.contains("_usage_complete_my-tool()"), "{out}");
        assert!(
            out.contains(r"complete -F _usage_complete_my-tool 'my-tool'"),
            "{out}"
        );
        // Quoted at every invocation too, so a name needing quotes at least runs — see the
        // assertion in `script` for the one place a quote cannot go.
        assert!(
            out.contains(r"command 'my-tool' __complete_word__"),
            "{out}"
        );
    }

    #[test]
    fn nu_asks_nushell_for_command_candidates() {
        let out = script("ex", Shell::Nu);
        assert!(out.contains("which"), "{out}");
        assert!(!out.contains("| commandline complete"), "{out}");
        assert!(out.contains("let wants_commands"), "{out}");
        assert!(out.contains("let wants_path_fallback"), "{out}");
        assert!(out.contains("$nu.os-info.name == \"windows\""), "{out}");
        assert!(
            !out.contains("or $l == $marker + \"commands\" }})\n    let declared"),
            "a command marker must not trigger path fallback: {out}"
        );
    }
}