|
@@ -0,0 +1,353 @@
|
|
|
+#!/bin/zsh -f
|
|
|
+
|
|
|
+emulate -L zsh
|
|
|
+local EXIT=return
|
|
|
+if [[ $(whence -w $0) == *:' 'command ]]; then
|
|
|
+ emulate -R zsh
|
|
|
+ local RUNNING_AS_COMMAND=1
|
|
|
+ EXIT=exit
|
|
|
+fi
|
|
|
+
|
|
|
+local DOC='scd -- smart change to a recently used directory
|
|
|
+usage: scd [options] [pattern1 pattern2 ...]
|
|
|
+Go to a directory path that contains all fixed string patterns. Prefer
|
|
|
+recently visited directories and directories with patterns in their tail
|
|
|
+component. Display a selection menu in case of multiple matches.
|
|
|
+
|
|
|
+Options:
|
|
|
+ -a, --add add specified directories to the directory index
|
|
|
+ --unindex remove specified directories from the index
|
|
|
+ -r, --recursive apply options --add or --unindex recursively
|
|
|
+ --alias=ALIAS create alias for the current or specified directory and
|
|
|
+ store it in ~/.scdalias.zsh
|
|
|
+ --unalias remove ALIAS definition for the current or specified
|
|
|
+ directory from ~/.scdalias.zsh
|
|
|
+ --list show matching directories and exit
|
|
|
+ -v, --verbose display directory rank in the selection menu
|
|
|
+ -h, --help display this message and exit
|
|
|
+'
|
|
|
+
|
|
|
+local SCD_HISTFILE=${SCD_HISTFILE:-${HOME}/.scdhistory}
|
|
|
+local SCD_HISTSIZE=${SCD_HISTSIZE:-5000}
|
|
|
+local SCD_MENUSIZE=${SCD_MENUSIZE:-20}
|
|
|
+local SCD_MEANLIFE=${SCD_MEANLIFE:-86400}
|
|
|
+local SCD_THRESHOLD=${SCD_THRESHOLD:-0.005}
|
|
|
+local SCD_SCRIPT=${RUNNING_AS_COMMAND:+$SCD_SCRIPT}
|
|
|
+local SCD_ALIAS=~/.scdalias.zsh
|
|
|
+
|
|
|
+local ICASE a d m p i tdir maxrank threshold
|
|
|
+local opt_help opt_add opt_unindex opt_recursive opt_verbose
|
|
|
+local opt_alias opt_unalias opt_list
|
|
|
+local -A drank dalias
|
|
|
+local dmatching
|
|
|
+local last_directory
|
|
|
+
|
|
|
+setopt extendedhistory extendedglob noautonamedirs brace_ccl
|
|
|
+
|
|
|
+# If SCD_SCRIPT is defined make sure the file exists and is empty.
|
|
|
+# This removes any previous old commands.
|
|
|
+[[ -n "$SCD_SCRIPT" ]] && [[ -s $SCD_SCRIPT || ! -f $SCD_SCRIPT ]] && (
|
|
|
+ umask 077
|
|
|
+ : >| $SCD_SCRIPT
|
|
|
+)
|
|
|
+
|
|
|
+# process command line options
|
|
|
+zmodload -i zsh/zutil
|
|
|
+zmodload -i zsh/datetime
|
|
|
+zparseopts -D -- a=opt_add -add=opt_add -unindex=opt_unindex \
|
|
|
+ r=opt_recursive -recursive=opt_recursive \
|
|
|
+ -alias:=opt_alias -unalias=opt_unalias -list=opt_list \
|
|
|
+ v=opt_verbose -verbose=opt_verbose h=opt_help -help=opt_help \
|
|
|
+ || $EXIT $?
|
|
|
+
|
|
|
+if [[ -n $opt_help ]]; then
|
|
|
+ print $DOC
|
|
|
+ $EXIT
|
|
|
+fi
|
|
|
+
|
|
|
+# load directory aliases if they exist
|
|
|
+[[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
|
|
|
+
|
|
|
+# works faster than the (:a) modifier and is compatible with zsh 4.2.6
|
|
|
+_scd_Y19oug_abspath() {
|
|
|
+ set -A $1 ${(ps:\0:)"$(
|
|
|
+ unfunction -m "*"; shift
|
|
|
+ for d; do
|
|
|
+ cd $d && print -Nr -- $PWD && cd $OLDPWD
|
|
|
+ done
|
|
|
+ )"}
|
|
|
+}
|
|
|
+
|
|
|
+# define directory alias
|
|
|
+if [[ -n $opt_alias ]]; then
|
|
|
+ if [[ -n $1 && ! -d $1 ]]; then
|
|
|
+ print -u2 "'$1' is not a directory."
|
|
|
+ $EXIT 1
|
|
|
+ fi
|
|
|
+ a=${opt_alias[-1]#=}
|
|
|
+ _scd_Y19oug_abspath d ${1:-$PWD}
|
|
|
+ # alias in the current shell, update alias file if successful
|
|
|
+ hash -d -- $a=$d &&
|
|
|
+ (
|
|
|
+ umask 077
|
|
|
+ hash -dr
|
|
|
+ [[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
|
|
|
+ hash -d -- $a=$d
|
|
|
+ hash -dL >| $SCD_ALIAS
|
|
|
+ )
|
|
|
+ $EXIT $?
|
|
|
+fi
|
|
|
+
|
|
|
+# undefine directory alias
|
|
|
+if [[ -n $opt_unalias ]]; then
|
|
|
+ if [[ -n $1 && ! -d $1 ]]; then
|
|
|
+ print -u2 "'$1' is not a directory."
|
|
|
+ $EXIT 1
|
|
|
+ fi
|
|
|
+ _scd_Y19oug_abspath a ${1:-$PWD}
|
|
|
+ a=$(print -rD ${a})
|
|
|
+ if [[ $a != [~][^/]## ]]; then
|
|
|
+ $EXIT
|
|
|
+ fi
|
|
|
+ a=${a#[~]}
|
|
|
+ # unalias in the current shell, update alias file if successful
|
|
|
+ if unhash -d -- $a 2>/dev/null && [[ -r $SCD_ALIAS ]]; then
|
|
|
+ (
|
|
|
+ umask 077
|
|
|
+ hash -dr
|
|
|
+ source $SCD_ALIAS
|
|
|
+ unhash -d -- $a 2>/dev/null &&
|
|
|
+ hash -dL >| $SCD_ALIAS
|
|
|
+ )
|
|
|
+ fi
|
|
|
+ $EXIT $?
|
|
|
+fi
|
|
|
+
|
|
|
+# Rewrite directory index if it is at least 20% oversized
|
|
|
+if [[ -s $SCD_HISTFILE ]] && \
|
|
|
+(( $(wc -l <$SCD_HISTFILE) > 1.2 * $SCD_HISTSIZE )); then
|
|
|
+ m=( ${(f)"$(<$SCD_HISTFILE)"} )
|
|
|
+ print -lr -- ${m[-$SCD_HISTSIZE,-1]} >| ${SCD_HISTFILE}
|
|
|
+fi
|
|
|
+
|
|
|
+# Determine the last recorded directory
|
|
|
+if [[ -s ${SCD_HISTFILE} ]]; then
|
|
|
+ last_directory=${"$(tail -1 ${SCD_HISTFILE})"#*;}
|
|
|
+fi
|
|
|
+
|
|
|
+# Internal functions are prefixed with "_scd_Y19oug_".
|
|
|
+# The "record" function adds its arguments to the directory index.
|
|
|
+_scd_Y19oug_record() {
|
|
|
+ while [[ -n $last_directory && $1 == $last_directory ]]; do
|
|
|
+ shift
|
|
|
+ done
|
|
|
+ if [[ $# -gt 0 ]]; then
|
|
|
+ ( umask 077
|
|
|
+ p=": ${EPOCHSECONDS}:0;"
|
|
|
+ print -lr -- ${p}${^*} >>| $SCD_HISTFILE )
|
|
|
+ fi
|
|
|
+}
|
|
|
+
|
|
|
+if [[ -n $opt_add ]]; then
|
|
|
+ for d; do
|
|
|
+ if [[ ! -d $d ]]; then
|
|
|
+ print -u2 "Directory '$d' does not exist."
|
|
|
+ $EXIT 2
|
|
|
+ fi
|
|
|
+ done
|
|
|
+ _scd_Y19oug_abspath m ${*:-$PWD}
|
|
|
+ _scd_Y19oug_record $m
|
|
|
+ if [[ -n $opt_recursive ]]; then
|
|
|
+ for d in $m; do
|
|
|
+ print -n "scanning ${d} ... "
|
|
|
+ _scd_Y19oug_record ${d}/**/*(-/N)
|
|
|
+ print "[done]"
|
|
|
+ done
|
|
|
+ fi
|
|
|
+ $EXIT
|
|
|
+fi
|
|
|
+
|
|
|
+# take care of removing entries from the directory index
|
|
|
+if [[ -n $opt_unindex ]]; then
|
|
|
+ if [[ ! -s $SCD_HISTFILE ]]; then
|
|
|
+ $EXIT
|
|
|
+ fi
|
|
|
+ # expand existing directories in the argument list
|
|
|
+ for i in {1..$#}; do
|
|
|
+ if [[ -d ${argv[i]} ]]; then
|
|
|
+ _scd_Y19oug_abspath d ${argv[i]}
|
|
|
+ argv[i]=${d}
|
|
|
+ fi
|
|
|
+ done
|
|
|
+ m="$(awk -v recursive=${opt_recursive} '
|
|
|
+ BEGIN {
|
|
|
+ for (i = 2; i < ARGC; ++i) {
|
|
|
+ argset[ARGV[i]] = 1;
|
|
|
+ delete ARGV[i];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 1 {
|
|
|
+ d = $0; sub(/^[^;]*;/, "", d);
|
|
|
+ if (d in argset) next;
|
|
|
+ }
|
|
|
+ recursive {
|
|
|
+ for (a in argset) {
|
|
|
+ if (substr(d, 1, length(a) + 1) == a"/") next;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ { print $0 }
|
|
|
+ ' $SCD_HISTFILE ${*:-$PWD} )" || $EXIT $?
|
|
|
+ : >| ${SCD_HISTFILE}
|
|
|
+ [[ ${#m} == 0 ]] || print -r -- $m >> ${SCD_HISTFILE}
|
|
|
+ $EXIT
|
|
|
+fi
|
|
|
+
|
|
|
+# The "action" function is called when there is just one target directory.
|
|
|
+_scd_Y19oug_action() {
|
|
|
+ cd $1 || return $?
|
|
|
+ if [[ -z $SCD_SCRIPT && -n $RUNNING_AS_COMMAND ]]; then
|
|
|
+ print -u2 "Warning: running as command with SCD_SCRIPT undefined."
|
|
|
+ fi
|
|
|
+ if [[ -n $SCD_SCRIPT ]]; then
|
|
|
+ print -r "cd ${(q)1}" >| $SCD_SCRIPT
|
|
|
+ fi
|
|
|
+}
|
|
|
+
|
|
|
+# Match and rank patterns to the index file
|
|
|
+# set global arrays dmatching and drank
|
|
|
+_scd_Y19oug_match() {
|
|
|
+ ## single argument that is an existing directory or directory alias
|
|
|
+ if [[ $# == 1 ]] && \
|
|
|
+ [[ -d ${d::=$1} || -d ${d::=${nameddirs[$1]}} ]] && [[ -x $d ]];
|
|
|
+ then
|
|
|
+ _scd_Y19oug_abspath dmatching $d
|
|
|
+ drank[${dmatching[1]}]=1
|
|
|
+ return
|
|
|
+ fi
|
|
|
+
|
|
|
+ # ignore case unless there is an argument with an uppercase letter
|
|
|
+ [[ "$*" == *[[:upper:]]* ]] || ICASE='(#i)'
|
|
|
+
|
|
|
+ # calculate rank of all directories in the SCD_HISTFILE and keep it as drank
|
|
|
+ # include a dummy entry for splitting of an empty string is buggy
|
|
|
+ [[ -s $SCD_HISTFILE ]] && drank=( ${(f)"$(
|
|
|
+ print -l /dev/null -10
|
|
|
+ <$SCD_HISTFILE \
|
|
|
+ awk -v epochseconds=$EPOCHSECONDS -v meanlife=$SCD_MEANLIFE '
|
|
|
+ BEGIN { FS = "[:;]"; }
|
|
|
+ length($0) < 4096 && $2 > 0 {
|
|
|
+ tau = 1.0 * ($2 - epochseconds) / meanlife;
|
|
|
+ if (tau < -4.61) tau = -4.61;
|
|
|
+ prec = exp(tau);
|
|
|
+ sub(/^[^;]*;/, "");
|
|
|
+ if (NF) ptot[$0] += prec;
|
|
|
+ }
|
|
|
+ END { for (di in ptot) { print di; print ptot[di]; } }'
|
|
|
+ )"}
|
|
|
+ )
|
|
|
+ unset "drank[/dev/null]"
|
|
|
+
|
|
|
+ # filter drank to the entries that match all arguments
|
|
|
+ for a; do
|
|
|
+ p=${ICASE}"*${a}*"
|
|
|
+ drank=( ${(kv)drank[(I)${~p}]} )
|
|
|
+ done
|
|
|
+
|
|
|
+ # build a list of matching directories reverse-sorted by their probabilities
|
|
|
+ dmatching=( ${(f)"$(
|
|
|
+ for d p in ${(kv)drank}; do
|
|
|
+ print -r -- "$p $d";
|
|
|
+ done | sort -grk1 | cut -d ' ' -f 2-
|
|
|
+ )"}
|
|
|
+ )
|
|
|
+
|
|
|
+ # if some directory paths match all patterns in order, discard all others
|
|
|
+ p=${ICASE}"*${(j:*:)argv}*"
|
|
|
+ m=( ${(M)dmatching:#${~p}} )
|
|
|
+ [[ -d ${m[1]} ]] && dmatching=( $m )
|
|
|
+ # if some directory names match last pattern, discard all others
|
|
|
+ p=${ICASE}"*${(j:*:)argv}[^/]#"
|
|
|
+ m=( ${(M)dmatching:#${~p}} )
|
|
|
+ [[ -d ${m[1]} ]] && dmatching=( $m )
|
|
|
+ # if some directory names match all patterns, discard all others
|
|
|
+ m=( $dmatching )
|
|
|
+ for a; do
|
|
|
+ p=${ICASE}"*/[^/]#${a}[^/]#"
|
|
|
+ m=( ${(M)m:#${~p}} )
|
|
|
+ done
|
|
|
+ [[ -d ${m[1]} ]] && dmatching=( $m )
|
|
|
+ # if some directory names match all patterns in order, discard all others
|
|
|
+ p=${ICASE}"/*${(j:[^/]#:)argv}[^/]#"
|
|
|
+ m=( ${(M)dmatching:#${~p}} )
|
|
|
+ [[ -d ${m[1]} ]] && dmatching=( $m )
|
|
|
+
|
|
|
+ # do not match $HOME or $PWD when run without arguments
|
|
|
+ if [[ $# == 0 ]]; then
|
|
|
+ dmatching=( ${dmatching:#(${HOME}|${PWD})} )
|
|
|
+ fi
|
|
|
+
|
|
|
+ # keep at most SCD_MENUSIZE of matching and valid directories
|
|
|
+ m=( )
|
|
|
+ for d in $dmatching; do
|
|
|
+ [[ ${#m} == $SCD_MENUSIZE ]] && break
|
|
|
+ [[ -d $d && -x $d ]] && m+=$d
|
|
|
+ done
|
|
|
+ dmatching=( $m )
|
|
|
+
|
|
|
+ # find the maximum rank
|
|
|
+ maxrank=0.0
|
|
|
+ for d in $dmatching; do
|
|
|
+ [[ ${drank[$d]} -lt maxrank ]] || maxrank=${drank[$d]}
|
|
|
+ done
|
|
|
+
|
|
|
+ # discard all directories below the rank threshold
|
|
|
+ threshold=$(( maxrank * SCD_THRESHOLD ))
|
|
|
+ dmatching=( ${^dmatching}(Ne:'(( ${drank[$REPLY]} >= threshold ))':) )
|
|
|
+}
|
|
|
+
|
|
|
+_scd_Y19oug_match $*
|
|
|
+
|
|
|
+## process whatever directories that remained
|
|
|
+if [[ ${#dmatching} == 0 ]]; then
|
|
|
+ print -u2 "No matching directory."
|
|
|
+ $EXIT 1
|
|
|
+fi
|
|
|
+
|
|
|
+## build formatted directory aliases for selection menu or list display
|
|
|
+for d in $dmatching; do
|
|
|
+ if [[ -n ${opt_verbose} ]]; then
|
|
|
+ dalias[$d]=$(printf "%.3g %s" ${drank[$d]} $d)
|
|
|
+ else
|
|
|
+ dalias[$d]=$(print -Dr -- $d)
|
|
|
+ fi
|
|
|
+done
|
|
|
+
|
|
|
+## process the --list option
|
|
|
+if [[ -n $opt_list ]]; then
|
|
|
+ for d in $dmatching; do
|
|
|
+ print -r -- "# ${dalias[$d]}"
|
|
|
+ print -r -- $d
|
|
|
+ done
|
|
|
+ $EXIT
|
|
|
+fi
|
|
|
+
|
|
|
+## process single directory match
|
|
|
+if [[ ${#dmatching} == 1 ]]; then
|
|
|
+ _scd_Y19oug_action $dmatching
|
|
|
+ $EXIT $?
|
|
|
+fi
|
|
|
+
|
|
|
+## here we have multiple matches - display selection menu
|
|
|
+a=( {a-z} {A-Z} )
|
|
|
+p=( )
|
|
|
+for i in {1..${#dmatching}}; do
|
|
|
+ [[ -n ${a[i]} ]] || break
|
|
|
+ p+="${a[i]}) ${dalias[${dmatching[i]}]}"
|
|
|
+done
|
|
|
+
|
|
|
+print -c -r -- $p
|
|
|
+
|
|
|
+if read -s -k 1 d && [[ ${i::=${a[(I)$d]}} -gt 0 ]]; then
|
|
|
+ _scd_Y19oug_action ${dmatching[i]}
|
|
|
+ $EXIT $?
|
|
|
+fi
|