diff --git a/choose/command.go b/choose/command.go index bf269b0..14ba482 100644 --- a/choose/command.go +++ b/choose/command.go @@ -6,9 +6,11 @@ import ( "os" "strings" + "github.com/alecthomas/kong" "github.com/charmbracelet/bubbles/paginator" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/stdin" + "github.com/charmbracelet/gum/style" "github.com/charmbracelet/lipgloss" ) @@ -89,3 +91,8 @@ func (o Options) Run() error { return err } + +// BeforeReset hook. Used to unclutter style flags. +func (o Options) BeforeReset(ctx *kong.Context) error { + return style.HideFlags(ctx) +} diff --git a/completion/bash.go b/completion/bash.go new file mode 100644 index 0000000..f187bcb --- /dev/null +++ b/completion/bash.go @@ -0,0 +1,600 @@ +package completion + +import ( + "bytes" + "fmt" + "io" + "sort" + "strings" + + "github.com/alecthomas/kong" +) + +type Bash struct{} + +func (b Bash) BeforeApply(app *kong.Kong) error { + return GenBashCompletion(app.Model.Node, app.Stdout) +} + +func writePreamble(buf io.StringWriter, name string) { + WriteStringAndCheck(buf, fmt.Sprintf("# bash completion for %-36s -*- shell-script -*-\n", name)) + WriteStringAndCheck(buf, fmt.Sprintf(` +__%[1]s_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Homebrew on Macs have version 1.3 of bash-completion which doesn't include +# _init_completion. This is a very minimal version of that function. +__%[1]s_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +__%[1]s_index_of_word() +{ + local w word=$1 + shift + index=0 + for w in "$@"; do + [[ $w = "$word" ]] && return + index=$((index+1)) + done + index=-1 +} + +__%[1]s_contains_word() +{ + local w word=$1; shift + for w in "$@"; do + [[ $w = "$word" ]] && return + done + return 1 +} + +__%[1]s_handle_go_custom_completion() +{ + __%[1]s_debug "${FUNCNAME[0]}: cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}" + + local shellCompDirectiveError=%[3]d + local shellCompDirectiveNoSpace=%[4]d + local shellCompDirectiveNoFileComp=%[5]d + local shellCompDirectiveFilterFileExt=%[6]d + local shellCompDirectiveFilterDirs=%[7]d + + local out requestComp lastParam lastChar comp directive args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly %[1]s allows to handle aliases + args=("${words[@]:1}") + # Disable ActiveHelp which is not supported for bash completion v1 + requestComp="%[8]s=0 ${words[0]} %[2]s ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __%[1]s_debug "${FUNCNAME[0]}: lastParam ${lastParam}, lastChar ${lastChar}" + + if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __%[1]s_debug "${FUNCNAME[0]}: Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __%[1]s_debug "${FUNCNAME[0]}: calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%%:*} + if [ "${directive}" = "${out}" ]; then + # There is not directive specified + directive=0 + fi + __%[1]s_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" + __%[1]s_debug "${FUNCNAME[0]}: the completions are: ${out}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + # Error code. No completion. + __%[1]s_debug "${FUNCNAME[0]}: received error from custom completion go code" + return + else + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __%[1]s_debug "${FUNCNAME[0]}: activating no space" + compopt -o nospace + fi + fi + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __%[1]s_debug "${FUNCNAME[0]}: activating no file completion" + compopt +o default + fi + fi + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local fullFilter filter filteringCmd + # Do not use quotes around the $out variable or else newline + # characters will be kept. + for filter in ${out}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __%[1]s_debug "File filtering command: $filteringCmd" + $filteringCmd + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + # Use printf to strip any trailing newline + subdir=$(printf "%%s" "${out}") + if [ -n "$subdir" ]; then + __%[1]s_debug "Listing directories in $subdir" + __%[1]s_handle_subdirs_in_dir_flag "$subdir" + else + __%[1]s_debug "Listing directories in ." + _filedir -d + fi + else + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${out}" -- "$cur") + fi +} + +__%[1]s_handle_reply() +{ + __%[1]s_debug "${FUNCNAME[0]}" + local comp + case $cur in + -*) + if [[ $(type -t compopt) = "builtin" ]]; then + compopt -o nospace + fi + local allflags + if [ ${#must_have_one_flag[@]} -ne 0 ]; then + allflags=("${must_have_one_flag[@]}") + else + allflags=("${flags[*]} ${two_word_flags[*]}") + fi + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${allflags[*]}" -- "$cur") + if [[ $(type -t compopt) = "builtin" ]]; then + [[ "${COMPREPLY[0]}" == *= ]] || compopt +o nospace + fi + + # complete after --flag=abc + if [[ $cur == *=* ]]; then + if [[ $(type -t compopt) = "builtin" ]]; then + compopt +o nospace + fi + + local index flag + flag="${cur%%=*}" + __%[1]s_index_of_word "${flag}" "${flags_with_completion[@]}" + COMPREPLY=() + if [[ ${index} -ge 0 ]]; then + PREFIX="" + cur="${cur#*=}" + ${flags_completion[${index}]} + if [ -n "${ZSH_VERSION:-}" ]; then + # zsh completion needs --flag= prefix + eval "COMPREPLY=( \"\${COMPREPLY[@]/#/${flag}=}\" )" + fi + fi + fi + + if [[ -z "${flag_parsing_disabled}" ]]; then + # If flag parsing is enabled, we have completed the flags and can return. + # If flag parsing is disabled, we may not know all (or any) of the flags, so we fallthrough + # to possibly call handle_go_custom_completion. + return 0; + fi + ;; + esac + + # check if we are handling a flag with special work handling + local index + __%[1]s_index_of_word "${prev}" "${flags_with_completion[@]}" + if [[ ${index} -ge 0 ]]; then + ${flags_completion[${index}]} + return + fi + + # we are parsing a flag and don't have a special handler, no completion + if [[ ${cur} != "${words[cword]}" ]]; then + return + fi + + local completions + completions=("${commands[@]}") + if [[ ${#must_have_one_noun[@]} -ne 0 ]]; then + completions+=("${must_have_one_noun[@]}") + elif [[ -n "${has_completion_function}" ]]; then + # if a go completion function is provided, defer to that function + __%[1]s_handle_go_custom_completion + fi + if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then + completions+=("${must_have_one_flag[@]}") + fi + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${completions[*]}" -- "$cur") + + if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${noun_aliases[*]}" -- "$cur") + fi + + if [[ ${#COMPREPLY[@]} -eq 0 ]]; then + if declare -F __%[1]s_custom_func >/dev/null; then + # try command name qualified custom func + __%[1]s_custom_func + else + # otherwise fall back to unqualified for compatibility + declare -F __custom_func >/dev/null && __custom_func + fi + fi + + # available in bash-completion >= 2, not always present on macOS + if declare -F __ltrim_colon_completions >/dev/null; then + __ltrim_colon_completions "$cur" + fi + + # If there is only 1 completion and it is a flag with an = it will be completed + # but we don't want a space after the = + if [[ "${#COMPREPLY[@]}" -eq "1" ]] && [[ $(type -t compopt) = "builtin" ]] && [[ "${COMPREPLY[0]}" == --*= ]]; then + compopt -o nospace + fi +} + +# The arguments should be in the form "ext1|ext2|extn" +__%[1]s_handle_filename_extension_flag() +{ + local ext="$1" + _filedir "@(${ext})" +} + +__%[1]s_handle_subdirs_in_dir_flag() +{ + local dir="$1" + pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return +} + +__%[1]s_handle_flag() +{ + __%[1]s_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + # if a command required a flag, and we found it, unset must_have_one_flag() + local flagname=${words[c]} + local flagvalue="" + # if the word contained an = + if [[ ${words[c]} == *"="* ]]; then + flagvalue=${flagname#*=} # take in as flagvalue after the = + flagname=${flagname%%=*} # strip everything after the = + flagname="${flagname}=" # but put the = back + fi + __%[1]s_debug "${FUNCNAME[0]}: looking for ${flagname}" + if __%[1]s_contains_word "${flagname}" "${must_have_one_flag[@]}"; then + must_have_one_flag=() + fi + + # if you set a flag which only applies to this command, don't show subcommands + if __%[1]s_contains_word "${flagname}" "${local_nonpersistent_flags[@]}"; then + commands=() + fi + + # keep flag value with flagname as flaghash + # flaghash variable is an associative array which is only supported in bash > 3. + if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then + if [ -n "${flagvalue}" ] ; then + flaghash[${flagname}]=${flagvalue} + elif [ -n "${words[ $((c+1)) ]}" ] ; then + flaghash[${flagname}]=${words[ $((c+1)) ]} + else + flaghash[${flagname}]="true" # pad "true" for bool flag + fi + fi + + # skip the argument to a two word flag + if [[ ${words[c]} != *"="* ]] && __%[1]s_contains_word "${words[c]}" "${two_word_flags[@]}"; then + __%[1]s_debug "${FUNCNAME[0]}: found a flag ${words[c]}, skip the next argument" + c=$((c+1)) + # if we are looking for a flags value, don't show commands + if [[ $c -eq $cword ]]; then + commands=() + fi + fi + + c=$((c+1)) + +} + +__%[1]s_handle_noun() +{ + __%[1]s_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + if __%[1]s_contains_word "${words[c]}" "${must_have_one_noun[@]}"; then + must_have_one_noun=() + elif __%[1]s_contains_word "${words[c]}" "${noun_aliases[@]}"; then + must_have_one_noun=() + fi + + nouns+=("${words[c]}") + c=$((c+1)) +} + +__%[1]s_handle_command() +{ + __%[1]s_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + local next_command + if [[ -n ${last_command} ]]; then + next_command="_${last_command}_${words[c]//:/__}" + else + if [[ $c -eq 0 ]]; then + next_command="_%[1]s_root_command" + else + next_command="_${words[c]//:/__}" + fi + fi + c=$((c+1)) + __%[1]s_debug "${FUNCNAME[0]}: looking for ${next_command}" + declare -F "$next_command" >/dev/null && $next_command +} + +__%[1]s_handle_word() +{ + if [[ $c -ge $cword ]]; then + __%[1]s_handle_reply + return + fi + __%[1]s_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + if [[ "${words[c]}" == -* ]]; then + __%[1]s_handle_flag + elif __%[1]s_contains_word "${words[c]}" "${commands[@]}"; then + __%[1]s_handle_command + elif [[ $c -eq 0 ]]; then + __%[1]s_handle_command + elif __%[1]s_contains_word "${words[c]}" "${command_aliases[@]}"; then + # aliashash variable is an associative array which is only supported in bash > 3. + if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then + words[c]=${aliashash[${words[c]}]} + __%[1]s_handle_command + else + __%[1]s_handle_noun + fi + else + __%[1]s_handle_noun + fi + __%[1]s_handle_word +} + +`, name, ShellCompNoDescRequestCmd, + ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, activeHelpEnvVar(name))) +} + +func writePostscript(buf io.StringWriter, name string) { + name = strings.ReplaceAll(name, ":", "__") + WriteStringAndCheck(buf, fmt.Sprintf("__start_%s()\n", name)) + WriteStringAndCheck(buf, fmt.Sprintf(`{ + local cur prev words cword split + declare -A flaghash 2>/dev/null || : + declare -A aliashash 2>/dev/null || : + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -s || return + else + __%[1]s_init_completion -n "=" || return + fi + + local c=0 + local flag_parsing_disabled= + local flags=() + local two_word_flags=() + local local_nonpersistent_flags=() + local flags_with_completion=() + local flags_completion=() + local commands=("%[1]s") + local command_aliases=() + local must_have_one_flag=() + local must_have_one_noun=() + local has_completion_function="" + local last_command="" + local nouns=() + local noun_aliases=() + + __%[1]s_handle_word +} + +`, name)) + WriteStringAndCheck(buf, fmt.Sprintf(`if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_%s %s +else + complete -o default -o nospace -F __start_%s %s +fi + +`, name, name, name, name)) + WriteStringAndCheck(buf, "# ex: ts=4 sw=4 et filetype=sh\n") +} + +func writeCommands(buf io.StringWriter, cmd *kong.Node) { + WriteStringAndCheck(buf, " commands=()\n") + for _, c := range cmd.Children { + if c == nil || c.Hidden { + continue + } + WriteStringAndCheck(buf, fmt.Sprintf(" commands+=(%q)\n", c.Name)) + writeCmdAliases(buf, c) + } + WriteStringAndCheck(buf, "\n") +} + +func writeFlagHandler(buf io.StringWriter, name string, annotations map[string][]string, cmd *kong.Node) { + for key, value := range annotations { + switch key { + case BashCompFilenameExt: + WriteStringAndCheck(buf, fmt.Sprintf(" flags_with_completion+=(%q)\n", name)) + + var ext string + if len(value) > 0 { + ext = fmt.Sprintf("__%s_handle_filename_extension_flag ", cmd.Parent.Name) + strings.Join(value, "|") + } else { + ext = "_filedir" + } + WriteStringAndCheck(buf, fmt.Sprintf(" flags_completion+=(%q)\n", ext)) + case BashCompCustom: + WriteStringAndCheck(buf, fmt.Sprintf(" flags_with_completion+=(%q)\n", name)) + + if len(value) > 0 { + handlers := strings.Join(value, "; ") + WriteStringAndCheck(buf, fmt.Sprintf(" flags_completion+=(%q)\n", handlers)) + } else { + WriteStringAndCheck(buf, " flags_completion+=(:)\n") + } + case BashCompSubdirsInDir: + WriteStringAndCheck(buf, fmt.Sprintf(" flags_with_completion+=(%q)\n", name)) + + var ext string + if len(value) == 1 { + ext = fmt.Sprintf("__%s_handle_subdirs_in_dir_flag ", cmd.Parent.Name) + value[0] + } else { + ext = "_filedir -d" + } + WriteStringAndCheck(buf, fmt.Sprintf(" flags_completion+=(%q)\n", ext)) + } + } +} + +const cbn = "\")\n" + +func writeShortFlag(buf io.StringWriter, flag *kong.Flag, cmd *kong.Node) { + name := fmt.Sprintf("%c", flag.Short) + format := " " + if len(flag.DefaultValue.String()) == 0 { + format += "two_word_" + } + format += "flags+=(\"-%s" + cbn + WriteStringAndCheck(buf, fmt.Sprintf(format, name)) + writeFlagHandler(buf, "-"+name, map[string][]string{}, cmd) +} + +func writeFlag(buf io.StringWriter, flag *kong.Flag, cmd *kong.Node) { + name := flag.Name + format := " flags+=(\"--%s" + if len(flag.DefaultValue.String()) == 0 { + format += "=" + } + format += cbn + WriteStringAndCheck(buf, fmt.Sprintf(format, name)) + if len(flag.DefaultValue.String()) == 0 { + format = " two_word_flags+=(\"--%s" + cbn + WriteStringAndCheck(buf, fmt.Sprintf(format, name)) + } + writeFlagHandler(buf, "--"+name, map[string][]string{}, cmd) +} + +func writeLocalNonPersistentFlag(buf io.StringWriter, flag *kong.Flag) { + name := flag.Name + format := " local_nonpersistent_flags+=(\"--%[1]s" + cbn + if len(flag.DefaultValue.String()) == 0 { + format += " local_nonpersistent_flags+=(\"--%[1]s=" + cbn + } + WriteStringAndCheck(buf, fmt.Sprintf(format, name)) + if flag.Short > 0 { + WriteStringAndCheck(buf, fmt.Sprintf(" local_nonpersistent_flags+=(\"-%c\")\n", flag.Short)) + } +} + +func writeFlags(buf io.StringWriter, cmd *kong.Node) { + WriteStringAndCheck(buf, ` flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + +`) + + for _, flag := range cmd.Flags { + if nonCompletableFlag(flag) { + continue + } + writeFlag(buf, flag, cmd) + if flag.Short != 0 { + writeShortFlag(buf, flag, cmd) + } + } + + WriteStringAndCheck(buf, "\n") +} + +func writeCmdAliases(buf io.StringWriter, cmd *kong.Node) { + if len(cmd.Aliases) == 0 { + return + } + + sort.Strings(cmd.Aliases) + + WriteStringAndCheck(buf, fmt.Sprint(` if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then`, "\n")) + for _, value := range cmd.Aliases { + WriteStringAndCheck(buf, fmt.Sprintf(" command_aliases+=(%q)\n", value)) + WriteStringAndCheck(buf, fmt.Sprintf(" aliashash[%q]=%q\n", value, cmd.Name)) + } + WriteStringAndCheck(buf, ` fi`) + WriteStringAndCheck(buf, "\n") +} +func writeArgAliases(buf io.StringWriter, cmd *kong.Node) { + WriteStringAndCheck(buf, " noun_aliases=()\n") + sort.Strings(cmd.Aliases) + for _, value := range cmd.Aliases { + WriteStringAndCheck(buf, fmt.Sprintf(" noun_aliases+=(%q)\n", value)) + } +} + +func gen(buf io.StringWriter, cmd *kong.Node) { + for _, c := range cmd.Children { + if c == nil || c.Hidden { + continue + } + gen(buf, c) + } + commandName := cmd.FullPath() + commandName = strings.ReplaceAll(commandName, " ", "_") + commandName = strings.ReplaceAll(commandName, ":", "__") + + if cmd.Parent == nil { + WriteStringAndCheck(buf, fmt.Sprintf("_%s_root_command()\n{\n", commandName)) + } else { + WriteStringAndCheck(buf, fmt.Sprintf("_%s()\n{\n", commandName)) + } + + WriteStringAndCheck(buf, fmt.Sprintf(" last_command=%q\n", commandName)) + WriteStringAndCheck(buf, "\n") + WriteStringAndCheck(buf, " command_aliases=()\n") + WriteStringAndCheck(buf, "\n") + + writeCommands(buf, cmd) + writeFlags(buf, cmd) + writeArgAliases(buf, cmd) + WriteStringAndCheck(buf, "}\n\n") +} + +// GenBashCompletion generates bash completion file and writes to the passed writer. +func GenBashCompletion(c *kong.Node, w io.Writer) error { + buf := new(bytes.Buffer) + writePreamble(buf, c.Name) + gen(buf, c) + writePostscript(buf, c.Name) + + _, err := buf.WriteTo(w) + return err +} diff --git a/completion/command.go b/completion/command.go new file mode 100644 index 0000000..b98547a --- /dev/null +++ b/completion/command.go @@ -0,0 +1,8 @@ +package completion + +type Completion struct { + Complete Complete `cmd:"" hidden:"" help:"Request shell completion"` + Bash Bash `cmd:"" help:"Generate the autocompletion script for bash"` + Zsh Zsh `cmd:"" help:"Generate the autocompletion script for zsh"` + // Fish Fish `cmd:"" help:"Generate the autocompletion script for fish"` +} diff --git a/completion/shell.go b/completion/shell.go new file mode 100644 index 0000000..855e5ae --- /dev/null +++ b/completion/shell.go @@ -0,0 +1,637 @@ +package completion + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/alecthomas/kong" +) + +type Complete struct { + Arg []string `arg:"" passthrough:""` +} + +type flagCompError struct { + subCommand string + flagName string +} + +func (e *flagCompError) Error() string { + return "Subcommand '" + e.subCommand + "' does not support flag '" + e.flagName + "'" +} + +// ShellCompDirective is a bit map representing the different behaviors the shell +// can be instructed to have once completions have been provided. +type ShellCompDirective int + +const ( + // ShellCompDirectiveError indicates an error occurred and completions should be ignored. + ShellCompDirectiveError ShellCompDirective = 1 << iota + + // ShellCompDirectiveNoSpace indicates that the shell should not add a space + // after the completion even if there is a single completion provided. + ShellCompDirectiveNoSpace + + // ShellCompDirectiveNoFileComp indicates that the shell should not provide + // file completion even when no completion is provided. + ShellCompDirectiveNoFileComp + + // ShellCompDirectiveFilterFileExt indicates that the provided completions + // should be used as file extension filters. + // For flags, using Command.MarkFlagFilename() and Command.MarkPersistentFlagFilename() + // is a shortcut to using this directive explicitly. The BashCompFilenameExt + // annotation can also be used to obtain the same behavior for flags. + ShellCompDirectiveFilterFileExt + + // ShellCompDirectiveFilterDirs indicates that only directory names should + // be provided in file completion. To request directory names within another + // directory, the returned completions should specify the directory within + // which to search. The BashCompSubdirsInDir annotation can be used to + // obtain the same behavior but only for flags. + ShellCompDirectiveFilterDirs + + // =========================================================================== + + // All directives using iota should be above this one. + // For internal use. + shellCompDirectiveMaxValue + + // ShellCompDirectiveDefault indicates to let the shell perform its default + // behavior after completions have been provided. + // This one must be last to avoid messing up the iota count. + ShellCompDirectiveDefault ShellCompDirective = 0 +) + +// Annotations for Bash completion. +const ( + // ShellCompRequestCmd is the name of the hidden command that is used to request + // completion results from the program. It is used by the shell completion scripts. + ShellCompRequestCmd = "completion complete" + // ShellCompNoDescRequestCmd is the name of the hidden command that is used to request + // completion results without their description. It is used by the shell completion scripts. + ShellCompNoDescRequestCmd = "completion completeNoDesc" + BashCompFilenameExt = "kong_annotation_bash_completion_filename_extensions" + BashCompCustom = "kong_annotation_bash_completion_custom" + BashCompOneRequiredFlag = "kong_annotation_bash_completion_one_required_flag" + BashCompSubdirsInDir = "kong_annotation_bash_completion_subdirs_in_dir" +) + +// Global map of flag completion functions. Make sure to use flagCompletionMutex before you try to read and write from it. +var flagCompletionFunctions = map[*kong.Flag]func(cmd *kong.Node, args []string, toComplete string) ([]string, ShellCompDirective){} + +// lock for reading and writing from flagCompletionFunctions +var flagCompletionMutex = &sync.RWMutex{} + +const ( + activeHelpMarker = "_activeHelp_ " + // The below values should not be changed: programs will be using them explicitly + // in their user documentation, and users will be using them explicitly. + activeHelpEnvVarSuffix = "_ACTIVE_HELP" + activeHelpGlobalEnvVar = "KONG_ACTIVE_HELP" + activeHelpGlobalDisable = "0" +) + +// activeHelpEnvVar returns the name of the program-specific ActiveHelp environment +// variable. It has the format _ACTIVE_HELP where is the name of the +// root command in upper case, with all - replaced by _. +func activeHelpEnvVar(name string) string { + // This format should not be changed: users will be using it explicitly. + activeHelpEnvVar := strings.ToUpper(fmt.Sprintf("%s%s", name, activeHelpEnvVarSuffix)) + return strings.ReplaceAll(activeHelpEnvVar, "-", "_") +} + +// WriteStringAndCheck writes a string into a buffer, and checks if the error is not nil. +func WriteStringAndCheck(b io.StringWriter, s string) { + _, err := b.WriteString(s) + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +// Returns a string listing the different directive enabled in the specified parameter +func (d ShellCompDirective) string() string { + var directives []string + if d&ShellCompDirectiveError != 0 { + directives = append(directives, "ShellCompDirectiveError") + } + if d&ShellCompDirectiveNoSpace != 0 { + directives = append(directives, "ShellCompDirectiveNoSpace") + } + if d&ShellCompDirectiveNoFileComp != 0 { + directives = append(directives, "ShellCompDirectiveNoFileComp") + } + if d&ShellCompDirectiveFilterFileExt != 0 { + directives = append(directives, "ShellCompDirectiveFilterFileExt") + } + if d&ShellCompDirectiveFilterDirs != 0 { + directives = append(directives, "ShellCompDirectiveFilterDirs") + } + if len(directives) == 0 { + directives = append(directives, "ShellCompDirectiveDefault") + } + + if d >= shellCompDirectiveMaxValue { + return fmt.Sprintf("ERROR: unexpected ShellCompDirective value: %d", d) + } + return strings.Join(directives, ", ") +} + +func (l Complete) BeforeApply(ctx *kong.Context) error { + _, completions, directive, err := getCompletions(ctx.Model.Node, ctx.Args[2:]) + if err != nil { + CompErrorln(err.Error()) + // Keep going for multiple reasons: + // 1- There could be some valid completions even though there was an error + // 2- Even without completions, we need to print the directive + } + + noDescriptions := false // (cmd.CalledAs() == ShellCompNoDescRequestCmd) + for _, comp := range completions { + // if GetActiveHelpConfig(finalCmd) == activeHelpGlobalDisable { + // // Remove all activeHelp entries in this case + // if strings.HasPrefix(comp, activeHelpMarker) { + // continue + // } + // } + if noDescriptions { + // Remove any description that may be included following a tab character. + comp = strings.Split(comp, "\t")[0] + } + + // Make sure we only write the first line to the output. + // This is needed if a description contains a linebreak. + // Otherwise the shell scripts will interpret the other lines as new flags + // and could therefore provide a wrong completion. + comp = strings.Split(comp, "\n")[0] + + // Finally trim the completion. This is especially important to get rid + // of a trailing tab when there are no description following it. + // For example, a sub-command without a description should not be completed + // with a tab at the end (or else zsh will show a -- following it + // although there is no description). + comp = strings.TrimSpace(comp) + + // Print each possible completion to stdout for the completion script to consume. + fmt.Fprintln(ctx.Stdout, comp) + } + + // As the last printout, print the completion directive for the completion script to parse. + // The directive integer must be that last character following a single colon (:). + // The completion script expects : + fmt.Fprintf(ctx.Stdout, ":%d\n", directive) + + // Print some helpful info to stderr for the user to understand. + // Output from stderr must be ignored by the completion script. + fmt.Fprintf(ctx.Stderr, "Completion ended with directive: %s\n", directive.string()) + + return nil +} + +func parseFlags(c *kong.Node, args []string) (int, error) { + flags := make([]*kong.Flag, 0) + for _, f := range c.Flags { + if f.Hidden || f.Tag.Optional || f.Tag.Ignored { + continue + } + flags = append(flags, f) + } + return len(flags), nil +} + +func traverse(c *kong.Node, args []string) (*kong.Node, []string, error) { + if len(args) == 0 { + return c, args, nil + } + + // Find the sub-command to complete. + for _, c := range c.Children { + if c.Name == args[0] { + return traverse(c, args[1:]) + } + } + + // If we didn't find a sub-command, we are at the end of the path. + // We can complete the sub-command name. + return c, args, nil +} + +func getCompletions(c *kong.Node, args []string) (*kong.Node, []string, ShellCompDirective, error) { + // The last argument, which is not completely typed by the user, + // should not be part of the list of arguments + CompDebugln(c.Name, false) + CompDebugln(fmt.Sprint(args), false) + toComplete := args[len(args)-1] + trimmedArgs := args[:len(args)-1] + CompDebugln(fmt.Sprintf("toComplete: %v", toComplete), false) + CompDebugln(fmt.Sprintf("trimmedArgs: %v", trimmedArgs), false) + + var finalCmd *kong.Node + var finalArgs []string + var err error + + finalCmd, finalArgs, err = traverse(c, trimmedArgs) + if err != nil { + // Unable to find the real command. E.g., someInvalidCmd + return c, []string{}, ShellCompDirectiveDefault, fmt.Errorf("Unable to find a command for arguments: %v", trimmedArgs) + } + CompDebugln(fmt.Sprintf("finalCmd: %v", finalCmd.Name), false) + CompDebugln(fmt.Sprintf("finalArgs: %v", finalArgs), false) + // Check if we are doing flag value completion before parsing the flags. + // This is important because if we are completing a flag value, we need to also + // remove the flag name argument from the list of finalArgs or else the parsing + // could fail due to an invalid value (incomplete) for the flag. + flag, finalArgs, toComplete, flagErr := checkIfFlagCompletion(finalCmd, finalArgs, toComplete) + + // Check if interspersed is false or -- was set on a previous arg. + // This works by counting the arguments. Normally -- is not counted as arg but + // if -- was already set or interspersed is false and there is already one arg then + // the extra added -- is counted as arg. + flagCompletion := true + newArgCount, _ := parseFlags(finalCmd, append(finalArgs, "--")) //len(finalCmd.Flags) //finalCmd.Flags().NArg() + + // Parse the flags early so we can check if required flags are set + realArgCount, err := parseFlags(finalCmd, finalArgs) + if err != nil { + return finalCmd, []string{}, ShellCompDirectiveDefault, fmt.Errorf("Error while parsing flags from args %v: %s", finalArgs, err.Error()) + } + + if newArgCount > realArgCount { + // don't do flag completion (see above) + flagCompletion = false + } + // Error while attempting to parse flags + if flagErr != nil { + // If error type is flagCompError and we don't want flagCompletion we should ignore the error + if _, ok := flagErr.(*flagCompError); !(ok && !flagCompletion) { + return finalCmd, []string{}, ShellCompDirectiveDefault, flagErr + } + } + + // We only remove the flags from the arguments if DisableFlagParsing is not set. + // This is important for commands which have requested to do their own flag completion. + // if !finalCmd.DisableFlagParsing { + // finalArgs = finalCmd.Flags().Args() + // } + + if flag != nil && flagCompletion { + // Check if we are completing a flag value subject to annotations + // if validExts, present := flag.Annotations[BashCompFilenameExt]; present { + // if len(validExts) != 0 { + // // File completion filtered by extensions + // return finalCmd, validExts, ShellCompDirectiveFilterFileExt, nil + // } + + // The annotation requests simple file completion. There is no reason to do + // that since it is the default behavior anyway. Let's ignore this annotation + // in case the program also registered a completion function for this flag. + // Even though it is a mistake on the program's side, let's be nice when we can. + // } + + // if subDir, present := flag.Annotations[BashCompSubdirsInDir]; present { + // if len(subDir) == 1 { + // // Directory completion from within a directory + // return finalCmd, subDir, ShellCompDirectiveFilterDirs, nil + // } + // // Directory completion + // return finalCmd, []string{}, ShellCompDirectiveFilterDirs, nil + // } + } + + var completions []string + var directive ShellCompDirective + + // Enforce flag groups before doing flag completions + // finalCmd.enforceFlagGroupsForCompletion() + + // Note that we want to perform flagname completion even if finalCmd.DisableFlagParsing==true; + // doing this allows for completion of persistent flag names even for commands that disable flag parsing. + // + // When doing completion of a flag name, as soon as an argument starts with + // a '-' we know it is a flag. We cannot use isFlagArg() here as it requires + // the flag name to be complete + if flag == nil && len(toComplete) > 0 && toComplete[0] == '-' && !strings.Contains(toComplete, "=") && flagCompletion { + CompDebugln("Flag name completion", false) + // First check for required flags + completions = completeRequireFlags(finalCmd, toComplete) + + // If we have not found any required flags, only then can we show regular flags + if len(completions) == 0 { + for _, flag := range finalCmd.Flags { + completions = append(completions, getFlagNameCompletions(flag, toComplete)...) + } + } + + directive = ShellCompDirectiveNoFileComp + if len(completions) == 1 && strings.HasSuffix(completions[0], "=") { + // If there is a single completion, the shell usually adds a space + // after the completion. We don't want that if the flag ends with an = + directive = ShellCompDirectiveNoSpace + } + + // if !finalCmd.DisableFlagParsing { + // // If DisableFlagParsing==false, we have completed the flags as known by Cobra; + // // we can return what we found. + // // If DisableFlagParsing==true, Cobra may not be aware of all flags, so we + // // let the logic continue to see if ValidArgsFunction needs to be called. + // return finalCmd, completions, directive, nil + // } + } else { + directive = ShellCompDirectiveDefault + // if flag == nil { + foundLocalNonPersistentFlag := false + // If TraverseChildren is true on the root command we don't check for + // local flags because we can use a local flag on a parent command + // if !finalCmd.Root().TraverseChildren { + if finalCmd.Parent != nil && len(finalCmd.Parent.Children) == 0 { + // Check if there are any local, non-persistent flags on the command-line + // localNonPersistentFlags := finalCmd.LocalNonPersistentFlags() + // finalCmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) { + // if localNonPersistentFlags.Lookup(flag.Name) != nil && flag.Changed { + // foundLocalNonPersistentFlag = true + // } + // }) + } + + // Complete subcommand names, including the help command + if len(finalArgs) == 0 && !foundLocalNonPersistentFlag { + // We only complete sub-commands if: + // - there are no arguments on the command-line and + // - there are no local, non-persistent flags on the command-line or TraverseChildren is true + for _, subCmd := range finalCmd.Children { + // if subCmd.IsAvailableCommand() || subCmd == finalCmd.helpCommand { + if !subCmd.Hidden { + if strings.HasPrefix(subCmd.Name, toComplete) { + completions = append(completions, fmt.Sprintf("%s\t%s", subCmd.Name, subCmd.Help)) + } + directive = ShellCompDirectiveNoFileComp + } + } + } + + // Complete required flags even without the '-' prefix + completions = append(completions, completeRequireFlags(finalCmd, toComplete)...) + + // Always complete ValidArgs, even if we are completing a subcommand name. + // This is for commands that have both subcommands and ValidArgs. + // if len(finalCmd.ValidArgs) > 0 { + // if len(finalArgs) == 0 { + // // ValidArgs are only for the first argument + // for _, validArg := range finalCmd.ValidArgs { + // if strings.HasPrefix(validArg, toComplete) { + // completions = append(completions, validArg) + // } + // } + // directive = ShellCompDirectiveNoFileComp + + // // If no completions were found within commands or ValidArgs, + // // see if there are any ArgAliases that should be completed. + // if len(completions) == 0 { + // for _, argAlias := range finalCmd.ArgAliases { + // if strings.HasPrefix(argAlias, toComplete) { + // completions = append(completions, argAlias) + // } + // } + // } + // } + + // // If there are ValidArgs specified (even if they don't match), we stop completion. + // // Only one of ValidArgs or ValidArgsFunction can be used for a single command. + // return finalCmd, completions, directive, nil + // } + + // Let the logic continue so as to add any ValidArgsFunction completions, + // even if we already found sub-commands. + // This is for commands that have subcommands but also specify a ValidArgsFunction. + // } + } + + // Find the completion function for the flag or command + var completionFn func(cmd *kong.Node, args []string, toComplete string) ([]string, ShellCompDirective) + if flag != nil && flagCompletion { + flagCompletionMutex.RLock() + completionFn = flagCompletionFunctions[flag] + flagCompletionMutex.RUnlock() + } else { + completionFn = nil //finalCmd.ValidArgsFunction + } + if completionFn != nil { + // Go custom completion defined for this flag or command. + // Call the registered completion function to get the completions. + var comps []string + comps, directive = completionFn(finalCmd, finalArgs, toComplete) + completions = append(completions, comps...) + } + + return finalCmd, completions, directive, nil +} + +func completeRequireFlags(finalCmd *kong.Node, toComplete string) []string { + var completions []string + + // We cannot use finalCmd.Flags() because we may not have called ParsedFlags() for commands + // that have set DisableFlagParsing; it is ParseFlags() that merges the inherited and + // non-inherited flags. + // finalCmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) { + // doCompleteRequiredFlags(flag) + // }) + // finalCmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) { + // doCompleteRequiredFlags(flag) + // }) + for _, f := range finalCmd.Flags { + if f.Required { + CompDebugln(fmt.Sprintf("flag: %s help: %s", f.Name, f.Help), false) + completions = append(completions, getFlagNameCompletions(f, toComplete)...) + } + } + + return completions +} + +func getFlagNameCompletions(flag *kong.Flag, toComplete string) []string { + if nonCompletableFlag(flag) { + return []string{} + } + + var completions []string + flagName := "--" + flag.Name + if strings.HasPrefix(flagName, toComplete) { + // Flag without the = + flagHelp := flag.Help + completions = append(completions, fmt.Sprintf("%s\t%s", flagName, flagHelp)) + + // Why suggest both long forms: --flag and --flag= ? + // This forces the user to *always* have to type either an = or a space after the flag name. + // Let's be nice and avoid making users have to do that. + // Since boolean flags and shortname flags don't show the = form, let's go that route and never show it. + // The = form will still work, we just won't suggest it. + // This also makes the list of suggested flags shorter as we avoid all the = forms. + // + // if len(flag.Default) == 0 { + // // Flag requires a value, so it can be suffixed with = + // flagName += "=" + // completions = append(completions, fmt.Sprintf("%s\t%s", flagName, flag.Help)) + // } + } + + flagName = "-" + fmt.Sprintf("%c", flag.Short) + if flag.Short != 0 && strings.HasPrefix(flagName, toComplete) { + completions = append(completions, fmt.Sprintf("%s\t%s", flagName, flag.Help)) + } + + return completions +} + +func isFlagArg(arg string) bool { + return ((len(arg) >= 3 && arg[1] == '-') || + (len(arg) >= 2 && arg[0] == '-' && arg[1] != '-')) +} + +func checkIfFlagCompletion(finalCmd *kong.Node, args []string, lastArg string) (*kong.Flag, []string, string, error) { + var flagName string + trimmedArgs := args + flagWithEqual := false + orgLastArg := lastArg + + // When doing completion of a flag name, as soon as an argument starts with + // a '-' we know it is a flag. We cannot use isFlagArg() here as that function + // requires the flag name to be complete + if len(lastArg) > 0 && lastArg[0] == '-' { + if index := strings.Index(lastArg, "="); index >= 0 { + // Flag with an = + if strings.HasPrefix(lastArg[:index], "--") { + // Flag has full name + flagName = lastArg[2:index] + } else { + // Flag is shorthand + // We have to get the last shorthand flag name + // e.g. `-asd` => d to provide the correct completion + // https://github.com/spf13/cobra/issues/1257 + flagName = lastArg[index-1 : index] + } + lastArg = lastArg[index+1:] + flagWithEqual = true + } else { + // Normal flag completion + return nil, args, lastArg, nil + } + } + + if len(flagName) == 0 { + if len(args) > 0 { + prevArg := args[len(args)-1] + if isFlagArg(prevArg) { + // Only consider the case where the flag does not contain an =. + // If the flag contains an = it means it has already been fully processed, + // so we don't need to deal with it here. + if index := strings.Index(prevArg, "="); index < 0 { + if strings.HasPrefix(prevArg, "--") { + // Flag has full name + flagName = prevArg[2:] + } else { + // Flag is shorthand + // We have to get the last shorthand flag name + // e.g. `-asd` => d to provide the correct completion + // https://github.com/spf13/cobra/issues/1257 + flagName = prevArg[len(prevArg)-1:] + } + // Remove the uncompleted flag or else there could be an error created + // for an invalid value for that flag + trimmedArgs = args[:len(args)-1] + } + } + } + } + + if len(flagName) == 0 { + // Not doing flag completion + return nil, trimmedArgs, lastArg, nil + } + + flag := findFlag(finalCmd, flagName) + if flag == nil { + // Flag not supported by this command, the interspersed option might be set so return the original args + return nil, args, orgLastArg, &flagCompError{subCommand: finalCmd.Name, flagName: flagName} + } + + if !flagWithEqual { + if len(flag.Default) != 0 { + // We had assumed dealing with a two-word flag but the flag is a boolean flag. + // In that case, there is no value following it, so we are not really doing flag completion. + // Reset everything to do noun completion. + trimmedArgs = args + flag = nil + } + } + + return flag, trimmedArgs, lastArg, nil +} + +func shorthandLookup(flags []*kong.Flag, name string) *kong.Flag { + for _, flag := range flags { + if flag.Name == name { + return flag + } + } + return nil +} + +func findFlag(cmd *kong.Node, name string) *kong.Flag { + flagSet := cmd.Flags + for _, flag := range flagSet { + if flag.Name == name { + return flag + } + } + return nil +} + +func nonCompletableFlag(flag *kong.Flag) bool { + return flag.Hidden +} + +// CompDebug prints the specified string to the same file as where the +// completion script prints its logs. +// Note that completion printouts should never be on stdout as they would +// be wrongly interpreted as actual completion choices by the completion script. +func CompDebug(msg string, printToStdErr bool) { + msg = fmt.Sprintf("[Debug] %s", msg) + + // Such logs are only printed when the user has set the environment + // variable BASH_COMP_DEBUG_FILE to the path of some file to be used. + if path := os.Getenv("BASH_COMP_DEBUG_FILE"); path != "" { + f, err := os.OpenFile(path, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err == nil { + defer f.Close() + WriteStringAndCheck(f, msg) + } + } + + if printToStdErr { + // Must print to stderr for this not to be read by the completion script. + fmt.Fprint(os.Stderr, msg) + } +} + +// CompDebugln prints the specified string with a newline at the end +// to the same file as where the completion script prints its logs. +// Such logs are only printed when the user has set the environment +// variable BASH_COMP_DEBUG_FILE to the path of some file to be used. +func CompDebugln(msg string, printToStdErr bool) { + CompDebug(fmt.Sprintf("%s\n", msg), printToStdErr) +} + +// CompError prints the specified completion message to stderr. +func CompError(msg string) { + msg = fmt.Sprintf("[Error] %s", msg) + CompDebug(msg, true) +} + +// CompErrorln prints the specified completion message to stderr with a newline at the end. +func CompErrorln(msg string) { + CompError(fmt.Sprintf("%s\n", msg)) +} diff --git a/completion/zsh.go b/completion/zsh.go new file mode 100644 index 0000000..0fb2a43 --- /dev/null +++ b/completion/zsh.go @@ -0,0 +1,250 @@ +package completion + +import ( + "bytes" + "fmt" + "io" + + "github.com/alecthomas/kong" +) + +type Zsh struct{} + +func (b Zsh) BeforeApply(app *kong.Kong) error { + return GenZshCompletion(app.Model.Node, app.Stdout) +} + +// GenZshCompletion generates zsh completion file including descriptions +// and writes it to the passed writer. +func GenZshCompletion(c *kong.Node, w io.Writer) error { + return genZshCompletion(c, w, true) +} + +// // GenZshCompletionNoDesc generates zsh completion file without descriptions +// // and writes it to the passed writer. +// func (c *Command) GenZshCompletionNoDesc(w io.Writer) error { +// return c.genZshCompletion(w, false) +// } + +func genZshCompletion(c *kong.Node, w io.Writer, includeDesc bool) error { + buf := new(bytes.Buffer) + genZshComp(buf, c.Name, includeDesc) + _, err := buf.WriteTo(w) + return err +} + +func genZshComp(buf io.StringWriter, name string, includeDesc bool) { + compCmd := ShellCompRequestCmd + if !includeDesc { + compCmd = ShellCompNoDescRequestCmd + } + WriteStringAndCheck(buf, fmt.Sprintf(`#compdef %[1]s + +# zsh completion for %-36[1]s -*- shell-script -*- + +__%[1]s_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_%[1]s() +{ + local shellCompDirectiveError=%[3]d + local shellCompDirectiveNoSpace=%[4]d + local shellCompDirectiveNoFileComp=%[5]d + local shellCompDirectiveFilterFileExt=%[6]d + local shellCompDirectiveFilterDirs=%[7]d + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace + local -a completions + + __%[1]s_debug "\n========= starting completion logic ==========" + __%[1]s_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __%[1]s_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __%[1]s_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., %[1]s -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} %[2]s ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __%[1]s_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __%[1]s_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __%[1]s_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%%s\n" "${out[@]}") + __%[1]s_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __%[1]s_debug "No directive found. Setting do default" + directive=0 + fi + + __%[1]s_debug "directive: ${directive}" + __%[1]s_debug "completions: ${out}" + __%[1]s_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __%[1]s_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="%[8]s" + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __%[1]s_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __%[1]s_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __%[1]s_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __%[1]s_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __%[1]s_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __%[1]s_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __%[1]s_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __%[1]s_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __%[1]s_debug "Calling _describe" + if eval _describe "completions" completions $flagPrefix $noSpace; then + __%[1]s_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __%[1]s_debug "_describe did not find completions." + __%[1]s_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __%[1]s_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __%[1]s_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_%[1]s" ]; then + _%[1]s +fi +`, name, compCmd, + ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, + activeHelpMarker)) +} diff --git a/filter/command.go b/filter/command.go index 9bc0452..cf452ee 100644 --- a/filter/command.go +++ b/filter/command.go @@ -5,10 +5,12 @@ import ( "os" "strings" + "github.com/alecthomas/kong" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/files" "github.com/charmbracelet/gum/internal/stdin" + "github.com/charmbracelet/gum/style" ) // Run provides a shell script interface for filtering through options, powered @@ -49,3 +51,8 @@ func (o Options) Run() error { return err } + +// BeforeReset hook. Used to unclutter style flags. +func (o Options) BeforeReset(ctx *kong.Context) error { + return style.HideFlags(ctx) +} diff --git a/gum.go b/gum.go index 952f6f1..95af1a8 100644 --- a/gum.go +++ b/gum.go @@ -2,6 +2,7 @@ package main import ( "github.com/charmbracelet/gum/choose" + "github.com/charmbracelet/gum/completion" "github.com/charmbracelet/gum/filter" "github.com/charmbracelet/gum/format" "github.com/charmbracelet/gum/input" @@ -14,6 +15,8 @@ import ( // Gum is the command-line interface for Gum. type Gum struct { + Completion completion.Completion `cmd:"" hidden:"" help:"Request shell completion"` + // Man is a hidden command that generates Gum man pages. Man man.Man `cmd:"" hidden:"" help:"Generate man pages"` diff --git a/input/command.go b/input/command.go index 3581b5b..ec529b8 100644 --- a/input/command.go +++ b/input/command.go @@ -4,9 +4,11 @@ import ( "fmt" "os" + "github.com/alecthomas/kong" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/stdin" + "github.com/charmbracelet/gum/style" ) // Run provides a shell script interface for the text input bubble. @@ -34,3 +36,8 @@ func (o Options) Run() error { fmt.Println(m.(model).textinput.Value()) return err } + +// BeforeReset hook. Used to unclutter style flags. +func (o Options) BeforeReset(ctx *kong.Context) error { + return style.HideFlags(ctx) +} diff --git a/spin/command.go b/spin/command.go index b752b1c..3968aab 100644 --- a/spin/command.go +++ b/spin/command.go @@ -1,8 +1,10 @@ package spin import ( + "github.com/alecthomas/kong" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/style" ) // Run provides a shell script interface for the spinner bubble. @@ -19,3 +21,8 @@ func (o Options) Run() error { p := tea.NewProgram(m) return p.Start() } + +// BeforeReset hook. Used to unclutter style flags. +func (o Options) BeforeReset(ctx *kong.Context) error { + return style.HideFlags(ctx) +} diff --git a/style/command.go b/style/command.go index 2c7d35b..b208731 100644 --- a/style/command.go +++ b/style/command.go @@ -26,6 +26,8 @@ package style import ( "fmt" "strings" + + "github.com/alecthomas/kong" ) // Run provides a shell script interface for the Lip Gloss styling. @@ -35,3 +37,25 @@ func (o Options) Run() error { fmt.Println(o.Style.ToLipgloss().Render(text)) return nil } + +// BeforeReset hook. Used to unclutter style flags. +func (o Options) BeforeReset(ctx *kong.Context) error { + return HideFlags(ctx) +} + +// HideFlags hides the flags from the usage output. This is used in conjunction +// with BeforeReset hook. +func HideFlags(ctx *kong.Context) error { + n := ctx.Selected() + if n == nil { + return nil + } + for _, f := range n.Flags { + if g := f.Group; g != nil && g.Key == groupName { + if !strings.HasSuffix(f.Name, "foreground") { + f.Hidden = true + } + } + } + return nil +} diff --git a/style/options.go b/style/options.go index 9025f16..b757459 100644 --- a/style/options.go +++ b/style/options.go @@ -1,5 +1,9 @@ package style +const ( + groupName = "Style Flags" +) + // Options is the customization options for the style command. type Options struct { Text []string `arg:"" optional:"" help:"Text to which to apply the style"` @@ -14,24 +18,24 @@ type Options struct { // components, through embedding and prefixing. type Styles struct { // Colors - Background string `help:"Background color of the ${name=element}" default:"${defaultBackground}" hidden:"" group:"Style Flags"` + Background string `help:"Background color of the ${name=element}" default:"${defaultBackground}" group:"Style Flags"` Foreground string `help:"color of the ${name=element}" default:"${defaultForeground}" group:"Style Flags"` // Border - Border string `help:"Border style to apply" enum:"none,hidden,normal,rounded,thick,double" default:"none" hidden:"" group:"Style Flags"` - BorderBackground string `help:"Border background color" hidden:"" group:"Style Flags"` - BorderForeground string `help:"Border foreground color" hidden:"" group:"Style Flags"` + Border string `help:"Border style to apply" enum:"none,hidden,normal,rounded,thick,double" default:"none" group:"Style Flags"` + BorderBackground string `help:"Border background color" group:"Style Flags"` + BorderForeground string `help:"Border foreground color" group:"Style Flags"` // Layout - Align string `help:"Text alignment" enum:"left,center,right,bottom,middle,top" default:"left" hidden:"" group:"Style Flags"` - Height int `help:"Height of output" hidden:"" group:"Style Flags"` - Width int `help:"Width of output" hidden:"" group:"Style Flags"` - Margin string `help:"Margin to apply around the text." default:"0 0" hidden:"" group:"Style Flags"` - Padding string `help:"Padding to apply around the text." default:"0 0" hidden:""` + Align string `help:"Text alignment" enum:"left,center,right,bottom,middle,top" default:"left" group:"Style Flags"` + Height int `help:"Height of output" group:"Style Flags"` + Width int `help:"Width of output" group:"Style Flags"` + Margin string `help:"Margin to apply around the text." default:"0 0" group:"Style Flags"` + Padding string `help:"Padding to apply around the text." default:"0 0"` // Format - Bold bool `help:"Apply bold formatting" hidden:"" group:"Style Flags"` - Faint bool `help:"Apply faint formatting" hidden:"" group:"Style Flags"` - Italic bool `help:"Apply italic formatting" hidden:"" group:"Style Flags"` - Strikethrough bool `help:"Apply strikethrough formatting" hidden:"" group:"Style Flags"` + Bold bool `help:"Apply bold formatting" group:"Style Flags"` + Faint bool `help:"Apply faint formatting" group:"Style Flags"` + Italic bool `help:"Apply italic formatting" group:"Style Flags"` + Strikethrough bool `help:"Apply strikethrough formatting" group:"Style Flags"` } diff --git a/write/command.go b/write/command.go index 01bbb4a..c7244a5 100644 --- a/write/command.go +++ b/write/command.go @@ -4,9 +4,11 @@ import ( "fmt" "os" + "github.com/alecthomas/kong" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/stdin" + "github.com/charmbracelet/gum/style" ) // Run provides a shell script interface for the text area bubble. @@ -47,3 +49,8 @@ func (o Options) Run() error { fmt.Println(m.(model).textarea.Value()) return err } + +// BeforeReset hook. Used to unclutter style flags. +func (o Options) BeforeReset(ctx *kong.Context) error { + return style.HideFlags(ctx) +}