//! 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",
        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}"
        );
    }
}
