Bash – Custom autocomplete: deal with spaces in filenames

autocompletebash

A bit of context, this question is a follow-up of this one: Bash remote autocompletion : change the 'starting' directory

Anyway, I'm writing my custom autocompletion bash script: I want the autocompletion to work just like cd does, except that I want to get the names from a specific directory, not necessarily the current one. It works great except when filenames have spaces in them.

A quick example. Let's say the directory I'm getting the names from has two files in it: a_file and another file (notice the space). This happens:

my_commandTABTAB
a_file
file
another

The presentation isn't perfect but the idea is that I'm prompted with 3 choices, another file being split in another and file. The desired output would be:
file_1 another file. I would also like spaces to be escaped automatically:

my_command anoTAB
my_command another\ file

Here's what my script looks like:

#!/bin/bash

_get_file_list()
{
    dir="/some/path/"
    cd $dir
    find * -maxdepth 0
}

_GetOptMyCommand()
{
    local cur

    COMPREPLY=()
    cur=${COMP_WORDS[COMP_CWORD]}

    case "$cur" in
    -*)
        COMPREPLY=( $( compgen -W "-h -l --help --list --" -- "$cur" ) );;
    *)
        COMPREPLY=( $( compgen -W "$(_get_file_list)" -- "$cur" ) );;
    esac

    return 0
}

complete -F _GetOptMyCommand my_command

How do I deal with spaces in filenames and make my autocompletion script like cd ?

Best Answer

Believe it might be better to use compgen instead of find in this case.

You probably already have a completion script with system. Try e.g.

locate bash_completion

On Debian variants this is probably:

/usr/share/bash-completion/bash_completion

where you find e.g. _filedir. So the simplest way then would be something in the direction of:

*)
    pushd "/some/path" >/dev/null
    _filedir
    popd >/dev/null

If that is not an option this could be a starter:

_comp_by_path()
{
    local opt cur dir
    local IFS=$'\n' x tmp
    local -a tokens

    opt="$1"
    cur="$2"
    dir="$3"

    # Enter target directory
    pushd "$dir" >/dev/null

    # Get directories, filtered against current
    [[ "$opt" != "-f" ]] && \
    x=$( compgen -d -- "$cur" ) &&
    while read -r tmp; do
        tokens+=( "$tmp" )
    done <<< "$x"

    # Get files, filtered against current
    [[ "$opt" != "-d" ]] && \
    x=$( compgen -f -- "$cur" ) &&
    while read -r tmp; do
        tokens+=( "$tmp" )
    done <<< "$x"

    # If anything found
    if [[ ${#tokens[@]} -ne 0 ]]; then
        # Make sure escaping is OK
        compopt -o filenames 2>/dev/null
        COMPREPLY+=( "${tokens[@]}" )
    fi

    # Go back
    popd >/dev/null
}

_GetOptMyCommand()
{
    local cur

    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"

    case "$cur" in
    -*)
        COMPREPLY=( $( compgen -W "-h -l --help --list --" -- "$cur" ) );;
    *)
        _comp_by_path "any" "$cur" "/some/path"
    esac
}

complete -F _GetOptMyCommand my_command

A variant using find could be something in direction of this:

_zaso()
{
    local dir="$1"

    pushd "$dir" >/dev/null
    find * -maxdepth 0 2>/dev/null
    popd >/dev/null
}

_comp_with_find()
{
    local cur dir      
    local IFS=$'\n'

    cur="$1"
    dir="$2"

    compopt -o filenames 2>/dev/null
    COMPREPLY=( $( compgen -W "$(_zaso "$dir")" -- "$cur" ) );
}

Also note that printf in Bash has a %q option. So to generate quoted strings this is an option to play with:

find * -maxdepth 0 2>/dev/null && \
while read -r tmp; do
    printf "%q\n" "$tmp"
done <<< "$x"

Also not that file names can have newline characters in which a lot of this will break. Have not found a way to use \0 with compgen.