Manipulating the Directory Stack

The cd command remembers the previous working directory, and cd - will return to it. There is another command that will change the directory and remember an unlimited number of directories: pushd. The directories are stored in an array, DIRSTACK. To return to a previous directory, popd pulls the top entry off DIRSTACK and makes that the current directory. I use two functions that make handling DIRSTACK easier, and I’ve added a third one here just for the sake of completeness.

 Note  The names of some of the functions that are created in this chapter are similar to the commands available in Bash. The reason for this is to use your existing shell scripts without making any changes to them and still availing of some additional functionality.

cd

The cd function replaces the built-in command of the same name. The function uses the built-in command pushd to change the directory and store the new directory on DIRSTACK. If no directory is given, pushd uses $HOME. If changing the directory fails, cd prints an error message, and the function returns with a failing exit code (Listing 11-1).

Listing 11-1. cd, Change Directory, Saving Location on the Directory Stack

cd() #@ Change directory, storing new directory on DIRSTACK
{
  local dir error          ## variables for directory and return code

  while :                  ## ignore all options
  do
    case $1 in
      --) break ;;
      -*) shift ;;
      *) break ;;
    esac
  done

  dir=$1

  if [ -n "$dir" ]         ## if a $dir is not empty
  then
    pushd "$dir"           ## change directory
  else
    pushd "$HOME"          ## go HOME if nothing on the command line
  fi 2>/dev/null           ## error message should come from cd, not pushd

  error=$?     ## store pushd's exit code

  if [ $error -ne 0 ]      ## failed, print error message
  then
    builtin cd "$dir"      ## let the builtin cd provide the error message
  fi
  return "$error"          ## leave with pushd's exit code
} > /dev/null

The standard output is redirected to the bit bucket because pushd prints the contents of DIRSTACK, and the only other output is sent to standard error (>&2).

 Note  A replacement for a standard command such as cd should accept anything that the original accepts. In the case of cd, the options -L and -P are accepted, even though they are ignored. That said, I do sometimes ignore options without even making provisions for them, especially if they are ones I never use.

pd

The pd function is here for the sake of completeness (Listing 11-2). It is a lazy man’s way of calling popd; I don’t use it.

Listing 11-2. pd, Return to Previous Directory with popd

pd ()
{
    popd
} >/dev/null ### for the same reason as cd

cdm

The reason I don’t use pd isn’t because I’m not lazy. Far from it, but I prefer to leave DIRSTACK intact so I can move back and forth between directories. For that reason, I use a menu that presents all the directories in DIRSTACK.

The cdm function sets the input field separator (IFS) to a single newline (NL or LF) to ensure that the output of the dirs built-in command keeps file names together after word splitting (Listing 11-3). File names containing a newline would still cause problems; names with spaces are an annoyance, but names with newlines are an abomination.

The function loops through the names in DIRSTACK (for dir in $(dirs -l -p)), adding each one to an array, item, unless it is already there. This array is then used as the argument to the menu function (discussed below), which must be sourced before cdm can be used.

DIRS BUILT-IN COMMAND

The dirs built-in command lists the directories in the DIRSTACK array. By default, it lists them on a single line with the value of HOME represented by a tilde. The -l option expands ~ to $HOME, and -p prints the directories, one per line.

Listing 11-3. cdm, Select New Directory from a Menu of Those Already Visited

cdm() #@ select new directory from a menu of those already visited
{
  local dir IFS=$'\n' item
  for dir in $(dirs -l -p)             ## loop through diretories in DIRSTACK[@]
  do
    [ "$dir" = "$PWD" ] && continue    ## skip current directory
    case ${item[*]} in
      *"$dir:"*) ;;                    ## $dir already in array; do nothing
      *) item+=( "$dir:cd '$dir'" ) ;; ## add $dir to array
    esac
  done
  menu "${item[@]}" Quit:              ## pass array to menu function
}

When run, the menu looks like this:

$ cdm

    1. /public/music/magnatune.com
    2. /public/video
    3. /home/jayant
    4. /home/jayant/tmp/qwe rty uio p
    5. /home/jayant/tmp
    6. Quit

 (1 to 6) ==>

menu

The calling syntax for the menu function comes from 9menu, which was part of the Plan 9 operating system. Each argument contains two colon-separated fields: the item to be displayed and the command to be executed. If there is no colon in an argument, it is used both as the display and as the command:

$ menu who date "df:df ."

    1. who
    2. date
    3. df

 (1 to 3) ==> 3
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/hda5             48070472  43616892   2011704  96% /home
$ menu who date "df: df ."

    1. who
    2. date
    3. df

 (1 to 3) ==> 1
jayant    tty8         Jun 18 14:00 (:1) 
jayant    tty2         Jun 21 18:10

A for loop numbers and prints the menu; read gets the response; and a case statement checks for the exit characters q, Q, or 0 in the response. Finally, indirect expansion retrieves the selected item, further expansion extracts the command, and eval executes it: eval "${!num#*:}" (Listing 11-4).

Listing 11-4. menu, Print Menu, and Execute-Associated Command

menu()
{
  local IFS=$' \t\n'                        ## Use default setting of IFS
  local num n=1 opt item cmd
  echo

  ## Loop though the command-line arguments
  for item
  do
    printf "  %3d. %s\n" "$n" "${item%%:*}"
    n=$(( $n + 1 ))
  done
  echo

  ## If there are fewer than 10 items, set option to accept key without ENTER
  if [ $# -lt 10 ]
  then
    opt=-sn1
  else
    opt=
  fi
  read -p " (1 to $#) ==> " $opt num         ## Get response from user

  ## Check that user entry is valid
  case $num in
    [qQ0] | "" ) return ;;                   ## q, Q or 0 or "" exits
    *[!0-9]* | 0*)                           ## invalid entry
       printf "\aInvalid response: %s\n" "$num" >&2
       return 1
       ;;
  esac
  echo

  if [ "$num" -le "$#" ]   ## Check that number is <= to the number of menu items
  then
    eval "${!num#*:}"      ## Execute it using indirect expansion
  else
    printf "\aInvalid response: %s\n" "$num" >&2
    return 1
  fi
}