File: Completions\CompletionScripts.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Cli.Completions;
 
internal static class CompletionScripts
{
    internal static string[] SupportedShells { get; } = ["bash", "fish", "pwsh", "zsh"];
 
    internal static string? DetectShell(IEnvironment environment)
    {
        var shell = Path.GetFileNameWithoutExtension(environment.GetEnvironmentVariable("SHELL"));
        if (SupportedShells.Contains(shell, StringComparer.Ordinal))
        {
            return shell;
        }
 
        return environment.IsWindows() ? "pwsh" : null;
    }
 
    internal static string Generate(string shell)
    {
        // Resolve aspire through PATH on every request. In particular, npm and bundle installs
        // run a versioned native binary whose ProcessPath must not be pinned in a shell profile.
        // Bash/Zsh send decoded argument tokens, not shell syntax. The other hooks send only
        // text before the cursor so the CLI can use its UTF-16 length rather than shell offsets.
        var script = shell switch
        {
            "bash" => """
                # Bash completion for Aspire. Source this file from ~/.bashrc.
                _aspire_decode_ansi_c()
                {
                    local value="$1" encoded='' character digits limit code i
                    # Bash 3.2 differs from newer versions for these control-escape forms.
                    local control_backslash=$'\c\\' incomplete_control=$'\c'
                    # printf %b differs from $'...': \c stops output, \0 accepts an extra
                    # octal digit, and escaped quotes/? remain escaped. Normalize those
                    # differences without evaluating input as shell code.
                    # https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html
                    for ((i = 0; i < ${#value}; i++)); do
                        character="${value:i:1}"
                        if [[ "$character" != '\' || i+1 -eq ${#value} ]]; then
                            encoded+="$character"
                            continue
                        fi
                        i=$((i+1))
                        character="${value:i:1}"
                        case "$character" in
                            "'"|'"'|'?') encoded+="$character" ;;
                            [0-7]|x|u|U)
                                digits=''
                                case "$character" in
                                    [0-7]) digits="$character"; limit=3 ;;
                                    x) limit=2 ;;
                                    u) limit=4 ;;
                                    U) limit=8 ;;
                                esac
                                while [[ ${#digits} -lt $limit && i+1 -lt ${#value} ]]; do
                                    if [[ "$character" == [0-7] ]]; then
                                        [[ "${value:i+1:1}" == [0-7] ]] || break
                                    else
                                        [[ "${value:i+1:1}" == [[:xdigit:]] ]] || break
                                    fi
                                    i=$((i+1))
                                    digits+="${value:i:1}"
                                done
                                if [[ "$character" == [0-7] ]]; then
                                    encoded+="\0$digits"
                                elif [[ -n "$digits" ]]; then
                                    encoded+="\\$character$digits"
                                else
                                    encoded+="\\\\$character"
                                fi
                                ;;
                            c)
                                if [[ i+1 -lt ${#value} ]]; then
                                    i=$((i+1))
                                    character="${value:i:1}"
                                    if [[ "$character" == '\' && "${value:i+1:1}" == '\' && ${#control_backslash} -eq 1 ]]; then i=$((i+1)); fi
                                    if [[ "$character" == '?' ]]; then
                                        encoded+=$'\c?'
                                    else
                                        printf -v code '%d' "'$character"
                                        printf -v character '\\0%03o' "$((code & 31))"
                                        encoded+="$character"
                                    fi
                                else
                                    encoded+="\\${incomplete_control}"
                                fi
                                ;;
                            *) encoded+="\\$character" ;;
                        esac
                    done
                    # Restore the caller's character locale for \u and \U, while the scanner
                    # uses byte offsets to match Bash's COMP_POINT. printf -v preserves newlines.
                    LC_ALL="$2" printf -v REPLY '%b' "$encoded"
                }
 
                _aspire_complete()
                {
                    local line suggestion word='' quote='' character escaped=false started=false i text ansi='' REPLY code
                    local character_locale="${LC_ALL:-${LC_CTYPE:-${LANG:-C}}}"
                    local -a arguments=()
                    local LC_ALL=C
                    COMPREPLY=()
                    line="${COMP_LINE:0:COMP_POINT}"
 
                    # Decode literal shell words without eval or expansion. For example:
                    #   echo "a;b"; aspire run --apphost 'path with spaces' --log-level "De
                    # Separators inside quotes are data. Older Bash versions include preceding
                    # commands in COMP_LINE; COMP_WORDS also splits option values at '=' and ':'.
                    for ((i = 0; i < ${#line}; i++)); do
                        character="${line:i:1}"
                        if [[ "$quote" == ansi ]]; then
                            if [[ "$escaped" == true ]]; then
                                ansi+="\\$character"
                                escaped=false
                            elif [[ "$character" == '\' ]]; then
                                escaped=true
                            elif [[ "$character" == "'" ]]; then
                                _aspire_decode_ansi_c "$ansi" "$character_locale"
                                word+="$REPLY"
                                quote=''
                            else
                                ansi+="$character"
                            fi
                        elif [[ "$escaped" == true ]]; then
                            if [[ "$quote" == '"' && "$character" != '$' && "$character" != '`' &&
                                  "$character" != '"' && "$character" != '\' && "$character" != $'\n' ]]; then
                                word+='\'
                            fi
                            [[ "$character" == $'\n' ]] || word+="$character"
                            escaped=false
                        elif [[ "$character" == '\' && "$quote" != "'" ]]; then
                            escaped=true
                            started=true
                        elif [[ -n "$quote" ]]; then
                            if [[ "$character" == "$quote" ]]; then
                                quote=''
                            else
                                word+="$character"
                            fi
                        else
                            case "$character" in
                                '$')
                                    if [[ "${line:i+1:1}" == "'" ]]; then
                                        quote=ansi
                                        ansi=''
                                        i=$((i+1))
                                    else
                                        word+="$character"
                                    fi
                                    started=true
                                    ;;
                                "'"|'"') quote="$character"; started=true ;;
                                ' '|$'\t'|$'\r')
                                    if [[ "$started" == true ]]; then arguments+=("$word"); fi
                                    word=''
                                    started=false
                                    ;;
                                ';'|'|'|'&'|'('|')'|$'\n')
                                    arguments=()
                                    word=''
                                    started=false
                                    ;;
                                *) word+="$character"; started=true ;;
                            esac
                        fi
                    done
                    if [[ "$quote" == ansi ]]; then
                        [[ "$escaped" == false ]] || ansi+='\'
                        _aspire_decode_ansi_c "$ansi" "$character_locale"
                        word+="$REPLY"
                    else
                        [[ "$escaped" == false ]] || word+='\'
                    fi
                    arguments+=("$word")
 
                    while IFS= read -r suggestion; do
                        [[ -n "$suggestion" ]] || continue
                        # Without -o filenames, Readline inserts custom candidates verbatim.
                        # Inside an open quote, escape for that quote; Readline closes it.
                        if [[ -z "$quote" ]]; then
                            printf -v text '%q' "$suggestion"
                        else
                            text=''
                            for ((i = 0; i < ${#suggestion}; i++)); do
                                character="${suggestion:i:1}"
                                if [[ "$quote" == ansi ]]; then
                                    case "$character" in
                                        "'"|\\) text+="\\$character" ;;
                                        *)
                                            printf -v code '%d' "'$character"
                                            if [[ $code -lt 32 || $code -eq 127 ]]; then
                                                printf -v character '\\%03o' "$code"
                                            fi
                                            text+="$character"
                                            ;;
                                    esac
                                elif [[ "$quote" == "'" && "$character" == "'" ]]; then
                                    text+="'\''"
                                else
                                    if [[ "$quote" == '"' && ( "$character" == '\' || "$character" == '"' ||
                                          "$character" == '$' || "$character" == '`' ) ]]; then
                                        text+='\'
                                    fi
                                    text+="$character"
                                fi
                            done
                        fi
                        COMPREPLY+=("$text")
                    done < <(command aspire '[suggest:tokens]' "${arguments[@]:1}" 2>/dev/null)
                }
                complete -o default -F _aspire_complete aspire
                """,
            "zsh" => """
                #compdef aspire
                _aspire()
                {
                    local suggestions
                    local -a arguments values
                    # Zsh supplies only this command's words, even after a pipeline or ';'.
                    # Q removes quoting without evaluating substitutions; PREFIX excludes the
                    # suffix after the cursor and the opening quote of an unfinished argument.
                    arguments=("${(@Q)words[2,CURRENT-1]}")
                    suggestions=$(command aspire '[suggest:tokens]' "${arguments[@]}" "$PREFIX" 2>/dev/null)
                    values=("${(@f)suggestions}")
                    if [[ -n "$suggestions" ]]; then
                        compadd -- "${values[@]}"
                    else
                        _default
                    fi
                }
                # A fresh Zsh profile may not have initialized the completion system yet.
                # -i audits and excludes insecure directories instead of prompting during profile
                # loading. Unlike -u, it does not trust insecure completion directories.
                # https://zsh.sourceforge.io/Doc/Release/Completion-System.html#Initialization
                if (( ! $+functions[compdef] )); then
                    autoload -Uz compinit
                    compinit -i || return
                fi
                compdef _aspire aspire
                # An autoloaded #compdef file must also complete its first invocation.
                if [[ "$funcstack[1]" == "_aspire" ]]; then
                    _aspire "$@"
                fi
                """,
            "fish" => """
                # Fish completion for Aspire. Save as ~/.config/fish/completions/aspire.fish.
                # `complete` prints registrations as: complete aspire -a '(__aspire_complete)'.
                # Inspect the registration, not the function: `complete --erase` leaves functions.
                if not complete --command aspire | string match --quiet -- "* -a '(__aspire_complete)'"
                    complete --command aspire --arguments '(__aspire_complete)'
                end
                function __aspire_complete
                    set -l line (commandline --current-process --cut-at-cursor)
                    command aspire '[suggest]' "$line" 2>/dev/null
                end
                """,
            "pwsh" => """
                # PowerShell 7+ completion for Aspire. Dot-source this file from $PROFILE.
                & {
                    $completer = {
                        param($wordToComplete, $commandAst, $cursorPosition)
 
                        # The cursor is relative to the entire input, but an AST can start after a
                        # pipeline/statement or use a quoted executable with the call operator (&).
                        $commandEnd = $commandAst.CommandElements[0].Extent.EndOffset
                        $argumentStart = $commandEnd - $commandAst.Extent.StartOffset
                        $argumentLength = [Math]::Max(0, $cursorPosition - $commandEnd)
                        # AST extents exclude trailing whitespace after the last token.
                        $arguments = $commandAst.ToString().Substring($argumentStart)
                        $line = 'aspire' + $arguments.PadRight($argumentLength).Substring(0, $argumentLength)
                        $prefix = $wordToComplete.TrimStart([char[]]@("'", '"'))
                        & aspire '[suggest]' $line 2>$null | ForEach-Object {
                            if (-not [string]::IsNullOrWhiteSpace($_) -and $_.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
                                $text = $_
                                if ($text -match '[\s''"`$;&|<>(){}\[\]*?@#]') {
                                    $text = "'" + $text.Replace("'", "''") + "'"
                                }
                                [System.Management.Automation.CompletionResult]::new(
                                    $text, $_, [System.Management.Automation.CompletionResultType]::ParameterValue, $_)
                            }
                        }
                    }
                    Register-ArgumentCompleter -Native -CommandName aspire, aspire.exe, aspire.cmd -ScriptBlock $completer
                    # npm uses aspire.ps1 in PowerShell. Script-level registration (without a
                    # ParameterName) uses the same three arguments but a separate completer table.
                    Register-ArgumentCompleter -CommandName aspire, aspire.ps1 -ScriptBlock $completer
                }
                """,
            _ => throw new ArgumentException($"Unsupported shell: {shell}", nameof(shell))
        };
 
        return script.ReplaceLineEndings("\n") + "\n";
    }
}