2

I have a command called up (as a function in my bashrc) that allows me to easily go up to specified directories above the one I am in. The command works well, however adding bash tab completion would make it even more useful. I have written a completion script that identifies the directories in the current path and lists them for completion. The problem I am having is that whenever I change the directory, the completion script seems to not be reloaded, giving me the same results as if I were still in the previous directory. I was able to fix this for the up command by adding exec bash to the command function, however I am unsure whether I can do this for an arbitrary directory changing command. I suspect this could possibly be accomplished by just adding exec bash for whenever the cd command is run, but I am unsure what the best way to do this would be.

As an example:

If I open a terminal, I am in my home directory:

~$

If I type up <TAB><TAB>, I get:

~$ up
username home

where username is my username on my pc. I then cd into dir1/dir2/, and type up <TAB><TAB>, and get:

~/dir1/dir2$ up
username home

again, even though I have changed my directory.

The code for my completion script and up script are as follows:

Completion script:

#/usr/bin/env bash
LS=$(echo $PWD)
LSARRAY=${LS//\// }
complete -W "$LSARRAY" up

Up command (in .bashrc)

LS=$(echo $PWD)
LSARRAY=(${LS//\// })
i=1
if [[ $LS == *$1/* ]]; then
  FIND=$1
  if [[ $1 == */* ]]; then
    ARR=(${FIND//\// })
    FIND=${ARR[${#ARR[@]}-1]}
  fi
  while [ "${LSARRAY[${#LSARRAY[@]}-$i]}" != "$FIND" ] && [ $i != ${#LSARRAY[@]} ]; do
    cd ../
    i=$(($i+1))
  done
  exec bash
else
  echo "Did not find directory $1"
fi
1
  • What OS are you on? On (at least) one of my Ubuntu 18.04.2 LTS systems, this is the default behaviour, and does not require any special configuration. Are you sure you're not re-inventing the wheel? My cd tab-completes to show only directories.
    – Jim L.
    Nov 18, 2021 at 21:45

3 Answers 3

4

Here is an alternative implementation of your up function (including its programmable completion counterpart) which tries to:

  • work with arbitrary paths (including space characters and newlines);
  • avoid altering the IFS variable, which is not trivial to properly restore;
  • explicitly take care of some special directories (., ..; handled in an arbitrary way here, you may want to adapt the code based on your preference);

The up function:

up () {
  local path=$PWD
  case $1 in
    (''|'..')
      cd ..; return ;;
    (.)
      cd .; return ;;
    (/)
      cd /; return ;;
    (*/*)
      printf '"%s" %s\n' "$1" 'contains forward slashes, up will not work' >&2
      return 1 ;;
  esac
  if [[ $path != /* ]]; then
    printf '%s\n' 'PWD is not an absolute path, up will not work' >&2
    return 1
  fi
  until [[ $path = / ]]; do
    path=${path%/*}
    path=${path:-/}
    if [[ ${path%"$1"} != "$path" ]] # You may add && [[ -d ${path%"$1"} ]] to
                            # make "up oo" fail when you are in `/foo/bar`,
                            # while "up foo" would succeed anyway
    then
      cd -- "$path"
      return
    fi
  done
  printf '%s "%s" %s\n' 'Directory' "$1" 'not found' >&2
  return 1
}

And its _up completion function and specification (the complete command):

_up () {
  local cur last path
  declare -A ancestors
  cur=${COMP_WORDS[$COMP_CWORD]}
  path=$PWD
  if [[ $path != /* ]]; then
    return 1
  fi
  until [[ -z $path ]]; do
    path=${path%/*}
    last=${path##*/}
    last=${last:-/}
    if [[ $last == "${cur}"* ]]; then
      ancestors+=( ["$last"]= )
    fi
  done
  COMPREPLY=( "${!ancestors[@]}" )
  return
}
complete -o nospace -o filenames -F _up up

Both of these code snippets are meant to be placed in your .bashrc. If you are using bash-completion you may prefer storing completion functions and specifications in $XDG_DATA_HOME/bash-completion or ~/.local/share/bash-completion or ~/.bash_completion (refer to the FAQ at the inked page for more on this).

Arguments containing forward slashes are ruled out on purpose because, otherwise (in my view), besides foo/bar we should take care of, for instance, foo///bar/./../baz (i.e. perform some form of canonicalization).

Using associative arrays (declare -A) requires Bash >= 4.0.

5
  • Nice and clean code, but why not use compgen in the completion function? It would make it a bit easier.
    – aviro
    Nov 18, 2021 at 22:23
  • @aviro compgen does not work well with newlines. Nov 18, 2021 at 22:47
  • @aviro As Hauke Laging said. I am not aware of any way to safely read file paths as written by compgen on its standard output. Anyway, more a theoretical than a practical issue, I guess, since many other things will likely break if someone starts putting newlines in their file paths.
    – fra-san
    Nov 18, 2021 at 22:54
  • Thanks for the detailed answer, really appreciate it. I'm not an experienced bash programmer, so functions like these are more of a learning curve for me. The one other thing I wanted to support with my function was up ~, which would obviously go back to the home directory. That was the reason for the handling of forward slashes, however I will see if I can't find another way. Nov 19, 2021 at 8:33
  • 1
    @DylanCallaghan For that I would adjust the options in the case statement; for instance, adding ('') cd; return ;; on top of them (which would rely on the default behavior of cd). ("$HOME") cd -- "$HOME"; return ;; should also be an option.
    – fra-san
    Nov 19, 2021 at 8:45
3

You problem is that the evaluation of the LS and LSARRAY variables happens only once, so it will always complete the up command with the parent folders of the PWD you had when you defined the completion.

In order to extract the parent folders dynamically you'll need to create a function for the completion. That way the LS and LSARRAY variables will be evaluated every time you would attempt to complete your up command base on your actual current PWD.

_complete_up()
{
  local cur=${COMP_WORDS[COMP_CWORD]}
  local LS=$(echo $PWD)
  local LSARRAY=${LS//\// }
  COMPREPLY=( $(compgen -W "${LSARRAY}" -- $cur) )
  return 0
}

complete -F _complete_up up
2
  • Why LS=$(echo $PWD) and not LS=$PWD? I can't see any downsides and it also avoids breaking directory paths that contain spaces Nov 18, 2021 at 21:53
  • 1
    @roaima, you're absolutely right, I just copied those lines from the original question without putting too much thought into it.
    – aviro
    Nov 18, 2021 at 22:10
2

You can do this:

_up () {
        local oldIFS="$IFS" LS="${PWD%/*}"
        IFS='/
'
        if [ -n "$2" ]; then
                COMPREPLY=($(compgen -W "${LS#/}" "$2"))
        else
                COMPREPLY=(${LS#/})
        fi
        IFS="$oldIFS"
}

complete -F _up up

up () {
        local oldIFS="$IFS" i=1 LSARRAY LS="$PWD" FIND ARR
        IFS='/
'
        LSARRAY=(${PWD#/})
        if [[ $LS == */$1/* ]]; then
                FIND=$1
                while [ "${LSARRAY[${#LSARRAY[@]}-$i]}" != "$FIND" ] && [ $i != ${#LSARRAY[@]} ]; do
                        cd ..
                        i=$(($i+1))
                done
        else
                echo "Did not find directory '$1'"
        fi
        IFS="$oldIFS"
}

The main problem was (as mentioned in the previous answer) that your completion possibilities were not generated dynamically.

In addition your code does not work with whitespace in the directory name. Mine does not work with newlines in the directory name.

I also removed the current directory from the selection list because that does not make sense.

I don't understand what your if [[ $1 == */* ]] is supposed to do (should be if [[ $FIND == */* ]] anyway) as directory names can never contain / so I removed it.

1
  • The reason for the if [[ $1 == */* ]] was to replace paths containing forward slashes with just the closest directory in that path. So for example, if given up foo/bar, the command will just navigate to the directory bar. This was necessary because I wanted functionality for up ~, which would be replaced by up /home/username. Nov 19, 2021 at 8:39

You must log in to answer this question.

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