'Compgen doesn't complete words containing colons correctly

I'm encountering a problem with creating a Bash completion function, when the command is expected to contain colons. When you type a command and press tab, Bash inserts the contents of the command line into an array, only these arrays are broken up by colons. So the command:

dummy foo:apple

Will become: ('dummy' 'foo' ':' 'apple')

I'm aware that one solution is to change COMP_WORDBREAKS, but this isn't an option as it's a team environment, where I could potentially break other code by messing with COMP_WORDBREAKS.

Then this answer suggests using the _get_comp_words_by_ref and __ltrim_colon_completions variables, but it is not remotely clear to me from the answer how to use these.

So I've tried a different solution below. Basically, read the command line as a string, and figure out which word the user's cursor is currently selecting by calculating an "offset". If there is a colon in the command line with text to the left or right of it, it will add 1 each to the offset, and then subtract this from the COMP_CWORD variable.

  1 #!/bin/bash
  2 _comp() {
  3     #set -xv
  4     local a=("${COMP_WORDS[@]}")
  5     local index=`expr $COMP_CWORD`
  6     local c_line="$COMP_LINE"
  7                                                                             
  8     # Work out the offset to change the cursor by
  9     # This is needed to compensate for colon completions
 10     # Because COMP_WORDS splits words containing colons
 11     # E.g. 'foo:bar' becomes 'foo' ':' 'bar'.
 12     
 13     # First delete anything in the command to the right of the cursor
 14     # We only need from the cursor backwards to work out the offset.
 15     for ((i = ${#a[@]}-1 ; i > $index ; i--));
 16     do
 17         regex="*([[:space:]])"${a[$i]}"*([[:space:]])"
 18         c_line="${c_line%$regex}"
 19     done
 20     
 21     # Count instances of text adjacent to colons, add that to offset.
 22     # E.g. for foo:bar:baz, offset is 4 (bar is counted twice.)
 23     # Explanation: foo:bar:baz foo
 24     #              0  12  34   5   <-- Standard Bash behaviour
 25     #              0           1   <-- Desired Bash behaviour
 26     # To create the desired behaviour we subtract offset from cursor index.
 27     left=$( echo $c_line | grep -o "[[:alnum:]]:" | wc -l )
 28     right=$( echo $c_line | grep -o ":[[:alnum:]]" | wc -l )
 29     offset=`expr $left + $right`
 30     index=`expr $COMP_CWORD - $offset`
 31     
 32     # We use COMP_LINE (not COMP_WORDS) to get an array of space-separated
 33     # words in the command because it will treat foo:bar as one string.
 34     local comp_words=($COMP_LINE)
 35     
 36     # If current word is a space, add an empty element to array
 37     if [ "${COMP_WORDS[$COMP_CWORD]}" == "" ]; then
 38         comp_words=("${comp_words[@]:0:$index}" "" "${comp_words[@]:$index}"    )   
 39     fi
 40         
 41     
 42     local cur=${comp_words[$index]}
 43     
 44     local arr=(foo:apple foo:banana foo:mango pineapple)
 45     COMPREPLY=()
 46     COMPREPLY=($(compgen -W "${arr[*]}" -- $cur))
 47     #set +xv
 48 }   
 49 
 50 complete -F _comp dummy 

Problem is, this still doesn't work correctly. If I type:

dummy pine<TAB>

Then it will correctly complete dummy pineapple. If I type:

dummy fo<TAB>

Then it will show the three available options, foo:apple foo:banana foo:mango. So far so good. But if I type:

dummy foo:<TAB>

Then the output I get is dummy foo:foo: And then further tabs don't work, because it interprets foo:foo: as a cur, which doesn't match any completion.

When I test the compgen command on its own, like so:

compgen -W 'foo:apple foo:banana foo:mango pineapple' -- foo:

Then it will return the three matching results:

foo:apple
foo:banana
foo:mango

So what I assume is happening is that the Bash completion sees that it has an empty string and three available candidates for completion, so adds the prefix foo: to the end of the command line - even though foo: is already the cursor to be completed.

What I don't understand is how to fix this. When colons aren't involved, this works fine - "pine" will always complete to pineapple. If I go and change the array to add a few more options:

local arr=(foo:apple foo:banana foo:mango pineapple pinecone pinetree)
COMPREPLY=()
COMPREPLY=($(compgen -W "${arr[*]}" -- $cur))

Then when I type dummy pine<TAB> it still happily shows me pineapple pinecone pinetree, and doesn't try to add a superfluous pine on the end.

Is there any fix for this behaviour?



Solution 1:[1]

One approach that's worked for me in the past is to wrap the output of compgen in single quotes, e.g.:

__foo_completions() {
  COMPREPLY=($(compgen -W "$(echo -e 'pine:cone\npine:apple\npine:tree')" -- "${COMP_WORDS[1]}" \
    | awk '{ print "'\''"$0"'\''" }'))
}

foo() {
  echo "arg is $1"
}

complete -F __foo_completions foo

Then:

$ foo <TAB>
$ foo 'pine:<TAB>
'pine:apple'  'pine:cone'   'pine:tree'
$ foo 'pine:a<TAB>
$ foo 'pine:apple'<RET>
arg is pine:apple
$ foo pi<TAB>
$ foo 'pine:


Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Martin McNulty