19

I would like to create completion rule for comma-separated parameters list. E.g. I have command which receives list of server names:

myscript -s name1,name2,name3

At this moment I've managed to write following completion:

_myscript () {
  local cur prev opts

  _get_comp_words_by_ref cur prev

  opts='-s'

  servers='name1 name2 name3'

  if [[ ${cur} == -* ]] ; then
    COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
  else
    case "${prev}" in
      -s)
        if [[ "$cur" == *,* ]]; then
          local realcur prefix
          realcur=${cur##*,}
          prefix=${cur%,*}
          COMPREPLY=( $(compgen -W "${servers}" -P "${prefix}," -- ${realcur}) )
        else
          COMPREPLY=( $(compgen -W "${servers}" -- ${cur}) )
        fi
        ;;
      *)
        # do nothing
        ;;
    esac
  fi
}

But it has at least 2 problems:

  1. Suggestions for current value includes all previous values in their prefix.
  2. It doesn't consider duplicate values.

What is the best practices for such cases? Maybe bash-completions has some bundled functions for csv-lists?

2
  • 3
    What might help is that you can split comma separated values into an iterable list like this: IFS=, LIST=("$VARIABLE") where $VARIABLE contains your comma separated values. Apr 21, 2014 at 14:56
  • 3
    Nice idea @MichaelEhrenreich, but you mustn't quote the $VARIABLE, otherwise word breaking doesn't happen. just use IFS=, LIST=($VARIABLE).
    – Guss
    Jun 5, 2014 at 6:06

2 Answers 2

7

There's basically no way to fix the problems you describe, because bash uses the values in COMPREPLY directly in the display and then to replace the user's text - while to get what you want, you'd need to first generate the possible completions (just the additional server names, without the prefix) for bash to display, then when bash is about to replace the user text with the longest non-conflicting string, you'd need it to call your script again to generate the text with the prefix - and bash has no facility for that.

The best that I could come up with is to have the COMPREPLY be generated with only the first word having the entire prefix (COMPREPLY=( "${prefix},"$(compgen -W "${servers[@]}" -- ${realcur}) )), so that if there is only one possible completion, it completes automatically correctly, while if there is more than one possible completion, then bash will not remove what was typed so far (because the first word in COMPREPLY has the entire prefix and thus matches the currently typed text and will be selected by bash to replace the user's text) and will display the options without the prefix - except for that one word that already contains the prefix, so the output will look like this:

$ command -s banana,a
ananas     apricot    banana,apple

"apple" as sorted last in the completion options because it carries the prefix which starts with "b" - very confusing. So I don't recommend doing that.

Regarding the duplicates - in order to not show duplicates, you just need to break $prefix into its part (easy: IFS="," prefix_parts=($prefix)) and then iterate over them and only leave in $servers the names that are not already listed. Its tedious to type, so I won't show it here, but relatively trivial so I'm sure you can manage :-).

To summarize, I don't think you should use comma separated values for input options, at least if you expect bash to help you with completion.

You can support an options format that like this: command -s <server> [<server> [..]] and then for completion of entries other than the one immediately after the -s option, just scan back through the $COMP_WORDS array from $COMP_CWORD until you find an option (string that matches -*) and if its "-s" then you need to do the server name completion.

3
  • You were most of the way there; you can suggest non-prefixed options, then add back the prefix before returning if there's only one match (checking with [[ ${#COMREPLY[@]} -eq 1 ]]). And it is possible (if tedious) to iterate the list of already provided options to subtract them from the available ones.
    – Walf
    Nov 1, 2021 at 2:10
  • @Walf thanks for the comment. Would you mind writing it up as a full answer, possibly with some example code?
    – Guss
    Nov 1, 2021 at 6:40
  • I was only half right; answer added.
    – Walf
    Jan 25, 2022 at 7:17
2

There is a way to do this that addresses both listed problems, but it requires using : as the separator. This is because colon gets treated specially by readline or something. Your example would therefore have the syntax:

myscript -s name1:name2:name3

It won't work if any of your names have colons within them.

The completion script for your example would be:

_myscript () {
    local cur prev words cword
    _init_completion -n : || return

    local opts servers
    opts='-s'
    servers=(
        name1
        name2
        name3
    )

    # also assume first argument will be an option
    if [[ ${cur} == -* || $cword -eq 1 ]]; then
        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
    else
        case "${prev}" in
            -s)
                if [[ "$cur" == *:* ]]; then
                    local realcur prefix chosen remaining
                    realcur="${cur##*:}"
                    prefix="${cur%:*}"
                    chosen=()
                    IFS=$':\n' read -ra chosen <<< "$prefix"
                    remaining=()
                    readarray -t remaining <<< "$(printf '%s\n' "${servers[@]}" "${chosen[@]}" | sort | uniq -u)"
                    if [[ ${#remaining[@]} -gt 0 ]]; then
                        COMPREPLY=( $(compgen -W "${remaining[*]}" -- "$realcur") )
                        # add separator if user tabs again after entering a complete name
                        if [[ ${#COMPREPLY[@]} -eq 1 && ${#remaining[@]} -gt 0 && "$realcur" == "${COMPREPLY[0]}" ]]; then
                            COMPREPLY=("${COMPREPLY[0]}:")
                        fi
                        if [[ ${#remaining[@]} -gt 1 ]]; then
                            compopt -o nospace
                        fi
                    fi
                else
                    COMPREPLY=( $(compgen -W "${servers[*]}" -- "$cur") )
                    # add separator if user tabs again after entering a complete name
                    if [[ ${#COMPREPLY[@]} -eq 1 && "$cur" == "${COMPREPLY[0]}" ]]; then
                        COMPREPLY=("${COMPREPLY[0]}:")
                    fi
                    compopt -o nospace
                fi
                ;;
            *)
                # do nothing
                ;;
        esac
    fi
} &&
complete -F _myscript myscript

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .