# @(#) ucd.ksh 3.3 97/07/13
# 1991 john@armory.com
# 92/12/22 changed to autoloaded function
# 93/12/18 Added conversion of leading $HOME to ~
# 94/01/07 Consider constant part of prompt when deciding how long to
#          allow $PWDS to be.
# 94/10/28 Added lookupdir (directory lookup capability)
# 95/09/17 Fixed bug that prevented a directory less than -20 being given.
#          Added debug capability.
# 95/09/25 Search for only the first component of the given path.
# 96/01/02 Fixed dir index range check.
# 97/02/11 If a filename is given, cd to the directory it is in.
# 97/07/13 3.3 Added search for dir in args if >2 are given.
#          Let various paremeters be customized.
#
# Usage:
# alias cd=ucd
# cd -n		cd to n'th last directory
# cd -s		cd to the last directory that contains substring s
# cd abs-path	If a path beginning in /, ./, or ../ is given, it will be
#		cd'd to if it is a directory.  If it is not a directory but is
#		when the trailing path component is removed, that is cd'd to
#		instead.  This is a convenience to let a filename be given to
#		cd to the directory it is in without having to edit it to
#		remove the trailing component.
# cd non-abs-path	Standard ksh cd behaviour, except that if the path
#		is not found in CDPATH, it is searched for in a set of
#		directory databases (if any are set).  Directory lookup depends
#		on the availability of the 'look' or 'nlook' utilities.  See
#		"Directory Lookup" below for a description.
# cd s s	Standard ksh cd behaviour (substring replacement); see ksh
#		ksh man page.
# cd s s s ...	If 3 or more arguments are given, each is checked in order to
#		determine whether it is a directory.  An attempt is made to cd
#		to the first that is a directory.  To do this for only two
#		directories, give an extra bogus name that is not a directory,
#		e.g. ''   Example: cd '' foo bar
# dirs [num]	List directories in directory history list.  dirs is stored in
#		a separate file (not this one) so that it can be autoloaded
#		when first invoked.
#
# Directory Lookup:
# Database format:
# When this file is first sourced, if the shell variable _dirfiles is set it
# is taken to be the names of a whitespace-separated set of directory
# databases.  If not, a file named .dirs is searched for in the user's home
# directory, and if it exists, is taken to be the name of a directory database.
# The format of the directory databases is one pair of elements on each line:
# trailing-dir-component full-path
# If full-path does not begin with /, it is converted to an absolute path by
# prepending it with the name of the directory that the directory database
# resides in.
# Example:
# ksh lib/ksh
# If the directory database that the above example line came from resides in
# /u/spcecdt, the full-path is taken to be /u/spcecdt/lib/ksh.
# Suitable directory databases can be built with the 'mkdirlist' utility.
# Operation:
# When a single argument that does not begin with /, ./, or ../ is given and
# it is not found by a normal directory search (in the current directory or
# in each component of CDPATH, if set), it will be searched for in the
# directory databases.  The search goes like this:
# If the directory name given exactly matches one trailing-directory-component
# in the first directory database, the full-path will be cd'd to.  If it
# matches more than one trailing-directory-component, the matches (up to 10)
# are offered for interactive selection.  If there is no match, the directory
# name given is searched for as a prefix of any trailing-dir-component, and the
# results are treated the same way (immediate cd if one match, interactive
# selection if more).  If there are still no matches, the next directory
# database (if any) is searched in the same manner.
# Putting a trailing '/' on the directory name to be searched for will
# suppress the search for prefixes; only an exact match (to the name without
# the trailing '/') will be accepted.
# In all cases, the full-path returned by a match is tested for whether it
# is an acceptable cd target; if it does not exist, is not a directory, or
# is not executable it is skipped (not included in the matches or match count).
# If more than one component is given (e.g. foo/bar), the first component is
# looked up and the other components are attached to the full-path of each
# match before it is tested for whether it is an acceptable cd target.
#
# Shell variables:
#      This function sets PWDS to PWD, but with an upper limit on how
# long it is (removing directory components from the left-hand-side as
# neccessary), so that $PWDS can be used in PS1 instead of $PWD.
# The maximum length of PWDS may be set at any time by setting the _ucdmaxpwds
# variable.  The default is 50.  PWDS is trimmed to the trailing _ucdmaxpwds
# characters of PWD, then any remaining piece of the first component is
# removed.  Changes to _ucdmaxpwds affect PWDS the next time ucd is invoked.
#      If UCDDEBUG is set to a non-null value, debugging information is printed.
#      If _ucdnumdirs is set BEFORE this file is sourced (before the first time
# ucd is invoked, if you are using FPATH), it sets the number of history slots.
# The default and maximum is 1023 (due to ksh array limits).
#      _ucdmaxmatch may be set at any time to the maximum number of matches
# that should be offered for interactive selection.  More than this number of
# matches will cause a complaint to be issued and termination of cd processing.
# The default is 10.

# This file (and ucd) can be put in $FPATH/ucd so that they will be autoloaded
# instead of sourcing them directly from .profile or $ENV (e.g. .kshrc).
# See the description of FPATH in the ksh man page.


typeset -i _ucdnumdirs _dind _ucdmaxmatch _ucdmaxpwds _ucdoldmp=0
	typeset -i _ucdi=0
# even after typeset -i, value will be null until assigned to;
# also, if it had a non-numeric value, will become null after typeset -i
[ -z "$_ucdnumdirs" ] && _ucdnumdirs=1023
[ -z "$_ucdmaxmatch" ] && _ucdmaxmatch=10
[ -z "$_ucdmaxpwds" ] && _ucdmaxpwds=50

function ucd {
    typeset pwdhead debug

    [ -n "$UCDDEBUG" ] && debug=true || debug=false
    # Retrieve dir from history
    if [[ $# -eq 1 && "$1" = -?* ]]; then
	$debug && print -r -u2 "Searching directory history..."
	if [[ "$1" = -+([0-9]) ]]; then
	    typeset -i ind=$1
	    if [ ind -lt -_ucdnumdirs -o ind -gt -1 ]; then
		print -u2 \
	"Bad dir history index ($ind); must be in the range -1..-$_ucdnumdirs."
		return 1
	    fi
	    let ind=_dind+$1+1
	    if [ ind -lt 0 ]; then let ind+=_ucdnumdirs; fi
	    if [ -z "${_olddirs[ind]}" ]; then
		print -u2 "No such old dir."
		return 1
	    fi
	    $debug && print -u2 "Trying to cd to dir #$ind (${_olddirs[ind]})"
	    \cd "${_olddirs[ind]}" || return $?
	else
	    typeset -i i=1 ind=_dind
	    typeset dir pat=${1#-}

	    $debug && print -u2 \
	    "Searching previous dir list for a directory matching '$pat'"
	    while [ i -le _ucdnumdirs ]; do
		dir=${_olddirs[ind]}
		$debug && print -n -u2 "#$ind:$dir "
		if [[ "$dir" = ?(*$pat*|) ]]; then break; fi
		let ind-=1
		if [ ind -lt 0 ]; then ind=_ucdnumdirs-1; fi
		let i+=1
	    done
	    $debug && print -u2 ""
	    if [ i -gt _ucdnumdirs -o -z "$dir" ]; then
		print -u2 "No old dir matches that pattern."
	    else
		\cd "$dir" || return $?
	    fi
	fi
    elif [ $# -gt 2 ]; then
	$debug && print -r -u2 "Searching args for a directory..."
	# If more than 2 args are given, cd to the first arg that is a dir.
	typeset d
	for d; do
	    [ -d "$d" ] && break
	done
	[ -d "$d" ] || return 1
	\cd "$d" || return 1
    elif [[ $# -gt 1 || "$1" = ?(.|..)/* || -z "$_dirfiles" || \
    ! -r "$_dirfiles" ]]; then
	# if a substitution is being done, an absolute path is given,
	# or there is no dirlist file, do cd w/o errors discarded because it is
	# the only thing that will be tried.
	# If a single arg that is an absolute path is given and it is not
	# a directory, but it is if the trailing component is stripped,
	# cd to that instead.  This is a convenience to let a filename be
	# given to cd to the directory it is in without having to edit it
	# to remove the trailing component.
	if [[ $# -eq 1 && "$1" = ?(.|..)/* && ! -d "$1" && -d "${1%%*([!/])}" ]]
	then
	    set -- "${1%%*([!/])}"
	    print -u2 "ucd: changing to parent dir: $1"
	fi
	\cd "$@" || return 1
    else
	# Try plain cd first.  Discard errors because dir lookup will be
	# attempted if dir is not in path (saving error output w/pipe uses an
	# exec).
	\cd "$@" 2>|/dev/null || {
	    # If dir was not in path, try looking it up; if found, try to
	    # cd to it.  If not found, do original cd again w/o error
	    # output redirected, to get an error message.  Only return 1 if
	    # this final cd fails, just in case it succeeds even though it
	    # didn't last time.
	    [ -n "$_look" ] && lookupdir "$1" "$debug" &&
	    \cd "$lookupdir_ret" || \cd "$@" || return 1
	}
    fi
    _dind='(_dind+1)%_ucdnumdirs'
    _olddirs[_dind]=$OLDPWD
    # Strip off leading $HOME
    [[ $HOME != / && "$PWD" = $HOME?(/*) ]] &&
    pwd="~${PWD#$HOME}" || pwd=$PWD
    # If prompt would be too long with constant part of prompt added,
    # get rid of some of the leading part of the path.
    pwdhead=$pwd$cprompt
    # If this is the first invokation of ucd, or if _ucdmaxpwds has changed,
    # generate _ucdtail from it.
    if [ _ucdmaxpwds -ne _ucdoldmp ]; then
	typeset -i _ucdi=0
	_ucdtail=
	while [ _ucdi -lt _ucdmaxpwds ]; do
	    _ucdtail="$_ucdtail?"
	    let _ucdi=_ucdi+1
	done
	_ucdoldmp=_ucdmaxpwds
	unset _ucdi
    fi
    eval pwdhead=\${pwdhead%%$_ucdtail}
    [ -n "$pwdhead" ] && PWDS=${pwd##$pwdhead*([!/])/} || PWDS=$pwd
    return 0
}

[ -z "$_dirfiles" -a -r $HOME/.dirs ] && _dirfiles=$HOME/.dirs

# nlook is faster & less buggy than look
if type nlook >|/dev/null; then
    _look=nlook
elif type look >|/dev/null; then
    _look=look
else
    print -ru2 -- "ucd: look: not found.  Directory lookup disabled."
fi

# Usage: lookupdir dirname/ debugstatus  or   lookupdir dirprefix debugstatus
# If dirname/ is given, dirname as the full last component of a path is
# searched for.  If dirname is given, dirname is first searched for as above,
# and if it isn't found, any last component of a path that begins with dirname
# is searched for.  This is done for each dirlist file in turn.  If _dirfiles
# is not set or dir is not found, failure status is returned.
# Note that the ambiguity check is only done on a single-file basis.
# The dir file is built by 'mkdirlist' and has this format:
# trailing-dir-component full-path
# lookupdir should not be called with an absolute path (one that starts with /)
function lookupdir {
    typeset SearchDir=$1 DirTail= debug=$2 Srch S dir file found exact=false
    typeset -i i=0 matches

    if [[ "$SearchDir" = */ ]]; then
	exact=true
	SearchDir=${SearchDir%/}
    fi
    # If dir has more than one component, split off the first component so it
    # can be looked up.  Keep the rest (inc. / at front) so it can be added.
    if [[ "$SearchDir" = */* ]]; then
	DirTail=${SearchDir##*([!/])}
	SearchDir=${SearchDir%%/*}
	exact=true
    fi
    # If dirname/ was given, get rid of the / and instead use a trailing space
    # since that is what the file contains.  If dirname is given, make the args
    # be: first, the dirname with a trailing space (so that an exact match will
    # be preferred), then just the dirname.
    $exact && set -A S -- "$SearchDir " ||
    set -A S -- "$SearchDir " "$SearchDir"

    for file in $_dirfiles; do
	for Srch in "${S[@]}"; do
	    set -- $($_look "$Srch" "$file")
	    i=0
	    $debug && print -u2 "Searching dirlist '$file' for '$Srch'; got: $*"
	    # Canonize dirs & get rid of dirs not accessible or which no longer
	    # exist.  If the given path had more then one component, check for
	    # accessibility of the full path.
	    while [ $# -gt 0 ]; do
		# If dir is relative, prefix it with dir that dir file resides
		# in.  Discard 1 or more /'s after the last dir component since
		# file might be //file (e.g. $HOME=/, file=//.dirs).
		# Get rid of any ./ prefix in dir.
		[[ "$2" = /* ]] && dir=$2 || dir=${file%%+(/)!(/)}/${2#./}
		dir="$dir$DirTail"
		$debug && print -u2 "Checking $dir"
		if [ -d "$dir" -a -x "$dir" ]; then
		    found[i]=$dir
		    let i+=1
		fi
		shift 2
	    done
	    # If we got anything, do not search for any other search strings,
	    # because those that come earlier are preferred.
	    [ i -gt 0 ] && break 2
	done
    done
    $debug && print -u2 "Got $i matches."
    [ i -eq 0 ] && return 1
    matches=i
    if [ matches -gt 1 ]; then
	# Adjust the number below to set the max number of dirs presented to
	# select from.
	if [ matches -le _ucdmaxmatch ]; then
	    print -u2 -- \
	"$SearchDir: ambiguous.  Enter a number to select a dir or q to skip."
	    unset lookupdir_ret
	    select lookupdir_ret in "${found[@]}"; do
		break
	    done
	    [ -n "$lookupdir_ret" ]
	    return $?
	else
	    print -u2 -- "$SearchDir: ambiguous ($matches matches)."
	    return 1
	fi
    else
	lookupdir_ret=${found[0]}
	return 0
    fi
}
