Bash completion for filename patterns or directories

bashbash-scriptingtab completion

I'm trying to get a bash completion script set up and having some trouble.

I would like to set it up so the completions listed are either files matching a particular extension, or directories (which may or may not contain files of that extension).

The trouble I'm having is that the only way I can get the completions to contain files and directories is by using something like -o plusdirs -f -X '!*.txt', but when I let bash complete one of the directories, it just adds a space onto the end, rather than a slash.

_xyz()
{
  local cur=${COMP_WORDS[COMP_CWORD]}
  local prev=${COMP_WORDS[COMP_CWORD-1]}

  #COMPREPLY=( $( compgen -f -X '!*.txt' -- $cur ) )
  #COMPREPLY=( $( compgen -f -G '*.txt' -- $cur ) )
  #COMPREPLY=( $( compgen -o filenames -f -X '!*.txt' -- $cur ) )
  #COMPREPLY=( $( compgen -o dirnames  -f -X '!*.txt' -- $cur ) )
  COMPREPLY=( $( compgen -o plusdirs  -f -X '!*.txt' -- $cur ) )
  return 0
}

complete -F _xyz xyz

I've tried all the commented-out lines too, but they don't even expand the directories.

For testing, I've been running this in a directory with one .txt file and one directory "dir" (with a .txt file inside it, though that doesn't matter yet). Typing xyz <TAB> with this function lists the directory and the .txt file, but typing xyz d<TAB> expands to xyz dir (well, with a space after "dir").

Best Answer

If you look at the function _cd() in /etc/bash_completion, you'll see that it appends the trailing slash itself and that complete gets called with the option -o nospace for cd.

You can do the same for xyz, but to have to verify separately if the found match is a directory (if so, append slash) or a file (if so, append space). This should be done in a for loop to process all found matches.

Also, to properly handle paths that contain spaces, you have to set the internal file separator to only newline and escape the spaces. Using IFS=$'\n' in combination with printf %q makes completion work with almost all characters.1 Special care has to be taken to not escape the trailing space.

The following should work:

_xyz ()
{
    local IFS=$'\n'
    local LASTCHAR=' '

    COMPREPLY=($(compgen -o plusdirs -f -X '!*.txt' \
        -- "${COMP_WORDS[COMP_CWORD]}"))

    if [ ${#COMPREPLY[@]} = 1 ]; then
        [ -d "$COMPREPLY" ] && LASTCHAR=/
        COMPREPLY=$(printf %q%s "$COMPREPLY" "$LASTCHAR")
    else
        for ((i=0; i < ${#COMPREPLY[@]}; i++)); do
            [ -d "${COMPREPLY[$i]}" ] && COMPREPLY[$i]=${COMPREPLY[$i]}/
        done
    fi

    return 0
}

complete -o nospace -F _xyz xyz

1 The newline character is the obvious exception here, since it is an internal file separator.