#!/bin/sh
#
# cdm.sh -  `cd' command with menu
#
# Fri Sep 12 16:09:16 BST 2008
#

<<'______________D__O__C__U__M__E__N__T__A__T__I__O__N_____________'

Copyright (C) 2008 Peter Scott - p.scott@shu.ac.uk

Licence
=======

   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.

   This program is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with this program.  If not, see <http://www.gnu.org/licenses/>.


User-created hidden files in $HOME
==================================

   There are two optional, user-created, hidden files in $HOME.  If $NAME
   is cdm, they are called: .cdmSeed and .cdmSkip.  Both have one entry
   per line.

   .cdmSeed
   ~~~~~~~~
      .cdmSeed contains a list of absolute path names; cdm adds them to
      the top of the menu.  Example .cdmSeed file:

      /installs
      /usr/local/bin


   .cdmSkip
   ~~~~~~~~
      .cdmSkip contains a list of ignored directory names.  These are
      not pathnames.  Example .cdmSkip file:

      Bin
      SCCS


Other hidden files in $HOME
===========================

   There are three automatically created, hidden files in $HOME called:
   .cdmMenu, .cdmDirs and .cdmLast.

   .cdmLast holds a "cd" command to last selected directory.  .cdmLast can
   be sourced in .bashrc so that new shells automatically start in the
   last selected directory.  cdm's -t option prevents .cdmLast being
   written.


User-created overide files
==========================

   Files called .cdmList can exist in any directory; they act as
   stoppers.

   If a .cdmList exists in a directory, it prevents the inclusion of
   the directory's contents by using the contents of .cdmList instead.
   .cdmList holds an ordered list of relative path names.  An empty
   .cdmList, therefore, hides all the sub-directories.

   .cdmList has one entry per line; it cannot have a leading "./".
   Example .cdmList file:

   directory1
   directory2
   directory2/directory3
   directory4


Installation
============

  If you simply run cdm.sh, it displays installation instructions.

  You have to use:

       eval `cdm.sh -f`

  The eval command will install a function called cdm which calls cdm.sh
  and changes directory.  The eval command should be added to one of your
  shell's startup files (such as .bashrc) to ensure every shell you start
  gets the cdm function defined.


Terminals supported
===================

  Terminals aren't supported as such.  I use cdm on xterms but it seems
  to work on so many terminal types that I haven't yet found one where
  it hasn't worked.  When $DISPLAY isn't set, cdm uses "|", "-", "+" and
  "." to draw the tree.


Problems
========

  (1) Directory names ending in ' t' (space tee) can't be used as
      the reply; however, they can be selected numerically or on the
      command line.

  (2) getopts with /bin/sh on the Solaris I have access to doesn't set
      OPTARG; this means illegal options are not reported completely.


Programs called
===============

   tac is the reverse of cat; it is standard in Linux.

   The awk must allow user-defined functions.  GNU awk is fine.

______________D__O__C__U__M__E__N__T__A__T__I__O__N_____________

NAME='cdm'
EXT='sh'
PRE='CDM_'

SEED="$HOME/.${NAME}Seed"
SKIP="$HOME/.${NAME}Skip"
MENU="$HOME/.${NAME}Menu"
DIRS="$HOME/.${NAME}Dirs"
LAST="$HOME/.${NAME}Last"
LIST=".${NAME}List"
TMP="/tmp/$NAME.$$"
START_LINEMODE='(0'
END_LINEMODE='(B'


# myLs - display the chosen directory (you probably wish to customise this)
#
myLs() {
  pwd="`pwd`/"
  echo $pwd
  echo $pwd | sed 's/./~/g'
  ls -F
  if [ -d Bin ]
  then printf "\nBin/\n"
       ls Bin
  fi
}


# usage - display usage on standard error
#
usage() {
  cat <<-! >&2
	Usage: $NAME [ -t ]         # select a directory from the menu
	       $NAME [ -t ] choice  # select a directory without seeing a menu
	       $NAME -i             # select from the current directory
	       $NAME -r [ -a ]      # rebuild the menu
	       eval \`$NAME.$EXT -f\`   # install the calling function in the shell

	Options:
	       -a   include all directories in the menu, ignoring $LIST etc
	       -f   generate the calling function
	       -i   generate a temporary menu from "."    (implies -a and -t)
	       -r   rebuild menu
	       -t   don't remember the choice for later shells
	!
  exit 1
}


# mkTmp - make temp dir and delete it automatically on exit
#         Eg: ... > "$TMP/temp"
#
mkTmp() {
  trap 'rm -r "$TMP" 2> /dev/null' 0 1 2 3 4 5 6 7 8 9 10    12 13 14 15
  mkdir "$TMP"
}


# toMenu - send user dirs list in menu format to standard output
#
toMenu() {
  if [ "$immediate" ]
  then root=pwd
  else root=home
  fi
  sed '/^\.\//s///
       /^\.$/s//('$root')/
       \?[^/]*/?s//+-/g
       :loop
            /+-+/s//|_+/
       t loop

       # this added line is dropped later; it ensures the tree is terminated
       $a\
.
  ' "$TMP/user_dirs" |
    "$TAC" |
      "$AWK" '
            NR == 1 { prev = $0
                          prevSlashes = split( prev, a, "|")
                          if (index( prev, "+-") != 0)
                              prevSlashes++
            }
            NR != 1 { slashes = split( $0, a, "|")
                      if (index( $0, "+-") != 0)
                          slashes++
                      change = prevSlashes - slashes
                      mask = doit( prev, change, mask)
                      prev = $0
                      prevSlashes = slashes
            }
            END {     doit( prev, "0", mask) }

            function set(str, pos, char) {
              while (length( str) < pos)
                   str = str " "
              if (pos == 1)
                   str = char substr( str, 2)
              else
                   str = substr( str, 1, pos - 1) char substr( str, pos + 1)
              return str
            }

            function doit( line, change, mask) {
              plus = index( mask, "+")
              if ( plus != 0) {
                   line = set( line, plus, ".")
                   mask = set( mask, plus, " ")
              }
              maskCopy = mask
              x = index( maskCopy, "x")
              while (x != 0) {
                   line = set( line, x, " ")
                   line = set( line, x + 1, " ")
                   maskCopy = set( maskCopy, x, " ")
                   x = index( maskCopy, "x")
              }
              if (change < 0) {
                   cols = -change
                   plus = match( line, "[+.]-")
                   if (plus == 0)
                        plus = -1
                   file = plus + 2
                   for (i = 1; i < cols; i++) {
                        mask = set( mask, file, "x")
                        file += 2
                   }
                   mask = set( mask, file, "+")
              }
              if (change == 1) {
                   newPlus = match( line, "[+.]-") - 2
                   if ((newPlus > 0) && (substr( mask, newPlus, 1) == "x"))
                        mask = set( mask, newPlus, "+")
              }
              print line
              return mask
            }
         ' |
          sed '1d                # delete added line
               s/|_/| /g
          ' |
            "$TAC"
}


# mkDirs - re-build directory list and menu for later use
#
mkDirs() {

  mkTmp

  # build list of dirs, preserving leading "./" for handy delimiter
  #
  ls -R1 |
    sed '\?:$?!d
         \?^\.:$?s/://
         \?/.*:$?s/:$//' > "$TMP/all_dirs"

  # build list of edits from user's customisation files and apply them
  #
  > "$TMP/edits"
  if [ -z "$all" ]
  then test -f "$SKIP" && sed 's/.*/\\?\/&$?d/' "$SKIP" >> "$TMP/edits"
       find . -name "$LIST" | sed 's/^/\\?^\\/
                                   s/\'"$LIST"'$/.?d/' >> "$TMP/edits"
       find . -name "$LIST" -size +1c -print -exec cat {} \; |
         "$AWK" '/^\.\// {    if (savedLine)
                                   print( savedLine)
                              sub( "/[^/]*$", "")
                              path = $0
                              savedLine = sprintf( "\\?^\\%s$?a", path)
                              sub( "^..", "", path)
                   }
                   !/^\.\// { print( savedLine "\\")
                              savedLine = sprintf( "%s/%s", path, $0)
                   }
                   END {      print( savedLine)
                   }
         ' >> "$TMP/edits"
  fi
  test -f "$TMP/edits" && \
    sed -f "$TMP/edits" "$TMP/all_dirs" > "$TMP/user_dirs"

  > "$dirs"
  test -z "$immediate" && test -f "$SEED" && cat "$SEED" >> "$dirs"
  cat "$TMP/user_dirs" >> "$dirs"

  # calculate lines for pr
  #
  entries=`wc -l < "$dirs"`
  remainder=`expr $entries % 3`
  case $remainder in
       0)   extra=0
            ;;
       1 | 2)
            extra=1
            ;;
  esac
  lines=`expr $entries / 3 + $extra`

  # put seeds and formatted user dirs into menu
  #
  {
    test -z "$immediate" && test -f "$SEED" && cat "$SEED"
    toMenu "$TMP/user_dirs"
  } |
    pr -3 -t -l $lines -n' '3 -w 80 -i' '1 > "$menu"
}


# showMenu - and get reply
#
showMenu() {
  if [ "$cmdLineChoice" ]
  then reply=$cmdLineChoice
  else if [ -z "$DISPLAY" ]
       then
            cat "$menu"
       else
            sed '/|/s//'$START_LINEMODE'x'$END_LINEMODE'/g
                 /+-/s//'$START_LINEMODE'tq'$END_LINEMODE'/g
                 /\.-/s//'$START_LINEMODE'mq'$END_LINEMODE'/g' "$menu"
       fi
       printf '\nWhich? '
       read reply || exit  2
       test "$reply" || exit 3

       # check for ' t' at end of reply, treat as a '-t' option
       #
       case "$reply" in
            *\ t )
                 reply=`echo $reply | sed 's/ t$//'`
                 saveCd=
                 ;;
       esac

  fi
}


# showFunction - show function definition for eval by shell startup script
#
showFunction() {
  printf 'function %s() { %s=`%s %s $*` && cd "$%s"; }\n' \
                                     $NAME ${PRE}DIR $NAME.$EXT $NAME ${PRE}DIR
  exit 0
}


# instruct - tell user how to install it
#
instruct() {
  cat <<-! >&2
	$NAME.$EXT shouldn't be run directly as doing so doesn't allow it to
	change directory.  Use:

	     eval \`$NAME.$EXT -f\`

	That will install a function called $NAME which calls $NAME.$EXT and
	changes directory.  The eval statement should be added to one of your
	shell's startup files to ensure every shell you start gets the $NAME
	function defined.

	!
    exit 4
}


# badOpt option - report bad option
#
badOpt() {
  option=$1
  case $option in
       f)   echo "$NAME: -f must be used with eval" >&2 ;;
       *)   echo "$NAME: bad option -- $option" >&2 ;;
 esac
 usage
}


# vetOptions - set up implied options etc
#
vetOptions() {
  if [ "$all" ] && [ -z "$build" ]
  then echo "$NAME: warning: -a ignored without -r" >&2
  fi
  if [ "$immediate" ]
  then all=true         # -i implies: -a -t and no -r
       saveCd=
       build=
       menu="$TMP/menu"
       dirs="$TMP/dirs"
  fi
}


# set up particular programs used
#
case $OSTYPE in
     solaris)
          AWK=nawk
          TAC=/homedir/cms/ps/bin/tac      # reverse cat
          ;;
     linux-gnu)
          AWK=gawk
          TAC=tac
          ;;
esac

# show installation function if '-f' is the only parameter
#
if [ "$1" = -f ]
then if [ $# -eq 1 ]
     then showFunction    # exits
     else usage
     fi
fi

# disallow any other direct runs by looking for useless first parameter
#
if [ "$1" != $NAME ]
then instruct           # exits
else shift
fi

# set defaults
#
menu="$MENU"
dirs="$DIRS"
saveCd=true

# handle remaining options
#
while getopts ':airt' option
do   case $option in
         '?')   badOpt $OPTARG ;;      # /bin/sh on Solaris doesn't set OPTARG!
          a )   all=true ;;
          i )   immediate=true ;;
          r )   build=true ;;
          t )   saveCd= ;;
     esac 
done
shift `expr $OPTIND - 1`
cmdLineChoice="$*"
vetOptions

# cause menu to be built menu if first run
#
if [ ! -f "$menu" ] && [ -z "$immediate" ] && [ -z "$build" ]
then echo "$NAME: $menu: not found" >&2
     echo "building it -- may take some time" >&2
     build=true
fi

#  build menu if needed, in $HOME if not '-i'
#
if [ "$immediate" ] || [ "$build" ]
then test "$build" && cd
     mkDirs
fi

showMenu > /dev/tty         # the script runs in `` normally!

# handle reply
#
case "$reply" in
     0)   echo "$NAME: $reply: not a positive non-zero integer" >&2
          exit 5
          ;;
     '(home)' | '(pwd)' )
          choice=.
          ;;
     *[!0-9]*)
          matches=`grep -c "/$reply"'$' "$dirs"`
          case $matches in
               1)   choice=`grep "/$reply"'$' "$dirs"`
                    ;;
               0)   echo "$NAME: $reply: not found" >&2
                    exit 6
                    ;;
               *)   echo "$NAME: $reply: ambiguous" >&2
                    exit 7
                    ;;
          esac
          ;;
     *)
          entries=`wc -l < "$dirs"`
          if [ "$reply" -le $entries ]
          then choice=`sed -n -e ${reply}p "$dirs"`
          else echo "$NAME: $reply: too big" >&2
               exit 8
          fi
          ;;
esac

# stick $HOME/ before choice if needed
#
case "$choice" in
     /*) ;;
     *)  test "$immediate" || choice="$HOME/$choice" ;;
esac

# cd to choice if it exists
#
if [ ! -d "$choice" ]
then echo "$NAME: $choice: no such directory" >&2
else cd "$choice"
     myLs > /dev/tty
     test "$saveCd" && echo "cd '$choice'" > "$LAST"

     # the echo is for the cdm function to do its cd with!
     #
     echo $choice
fi
