Parent directory

scd

16165 bytes
  1#!/bin/zsh -f
  2
  3emulate -L zsh
  4
  5local RUNNING_AS_COMMAND=
  6local EXIT=return
  7if [[ $(whence -w $0) == *:' 'command ]]; then
  8    RUNNING_AS_COMMAND=1
  9    EXIT=exit
 10fi
 11
 12local DOC='scd -- smart change to a recently used directory
 13usage: scd [options] [pattern1 pattern2 ...]
 14Go to a directory path that matches all patterns.  Prefer recent or
 15frequently visited directories as found in the directory index.
 16Display a selection menu in case of multiple matches.
 17
 18Special patterns:
 19  ^PAT      match at the path root, "^/home"
 20  PAT$      match paths ending with PAT, "man$"
 21  ./        match paths under the current directory
 22  :PAT      require PAT to span the tail, ":doc", ":re/doc"
 23
 24Options:
 25  -a, --add         add current or specified directories to the index.
 26  --unindex         remove current or specified directories from the index.
 27  -r, --recursive   apply options --add or --unindex recursively.
 28  --alias=ALIAS     create alias for the current or specified directory and
 29                    store it in ~/.scdalias.zsh.
 30  --unalias         remove ALIAS definition for the current or specified
 31                    directory from ~/.scdalias.zsh.
 32                    Use "OLD" to purge aliases to non-existent directories.
 33  -A, --all         display all directories even those excluded by patterns
 34                    in ~/.scdignore.  Disregard unique match for a directory
 35                    alias and filtering of less likely paths.
 36  -p, --push        use "pushd" to change to the target directory.
 37  --list            show matching directories and exit.
 38  -v, --verbose     display directory rank in the selection menu.
 39  -h, --help        display this message and exit.
 40'
 41
 42local SCD_HISTFILE=${SCD_HISTFILE:-${HOME}/.scdhistory}
 43local SCD_HISTSIZE=${SCD_HISTSIZE:-5000}
 44local SCD_MENUSIZE=${SCD_MENUSIZE:-20}
 45local SCD_MEANLIFE=${SCD_MEANLIFE:-86400}
 46local SCD_THRESHOLD=${SCD_THRESHOLD:-0.005}
 47local SCD_SCRIPT=${RUNNING_AS_COMMAND:+$SCD_SCRIPT}
 48local SCD_ALIAS=~/.scdalias.zsh
 49local SCD_IGNORE=~/.scdignore
 50
 51# Minimum logarithm of probability.  Avoids out of range warning in exp().
 52local -r MINLOGPROB=-15
 53
 54# When false, use case-insensitive globbing to fix PWD capitalization.
 55local PWDCASECORRECT=true
 56if [[ ${OSTYPE} == darwin* ]]; then
 57    PWDCASECORRECT=false
 58fi
 59
 60local a d m p i maxrank threshold
 61local opt_help opt_add opt_unindex opt_recursive opt_verbose
 62local opt_alias opt_unalias opt_all opt_push opt_list
 63local -A drank dalias scdignore
 64local dmatching
 65local last_directory
 66
 67setopt extendedglob noautonamedirs brace_ccl
 68
 69# If SCD_SCRIPT is defined make sure that that file exists and is empty.
 70# This removes any old previous commands from the SCD_SCRIPT file.
 71[[ -n "$SCD_SCRIPT" ]] && [[ -s $SCD_SCRIPT || ! -f $SCD_SCRIPT ]] && (
 72    umask 077
 73    : >| $SCD_SCRIPT
 74)
 75
 76# process command line options
 77zmodload -i zsh/zutil
 78zmodload -i zsh/datetime
 79zmodload -i zsh/parameter
 80zparseopts -D -E -- a=opt_add -add=opt_add -unindex=opt_unindex \
 81    r=opt_recursive -recursive=opt_recursive \
 82    -alias:=opt_alias -unalias=opt_unalias \
 83    A=opt_all -all=opt_all p=opt_push -push=opt_push -list=opt_list \
 84    v=opt_verbose -verbose=opt_verbose h=opt_help -help=opt_help \
 85    || $EXIT $?
 86
 87# remove the first instance of "--" from positional arguments
 88argv[(i)--]=( )
 89
 90if [[ -n $opt_help ]]; then
 91    print $DOC
 92    $EXIT
 93fi
 94
 95# load directory aliases if they exist
 96[[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
 97
 98# load scd-ignore patterns if available
 99if [[ -s $SCD_IGNORE ]]; then
100    setopt noglob
101    <$SCD_IGNORE \
102    while read p; do
103        [[ $p != [\#]* ]] || continue
104        [[ -n $p ]] || continue
105        # expand leading tilde if it has valid expansion
106        if [[ $p == [~]* ]] && ( : ${~p} ) 2>/dev/null; then
107            p=${~p}
108        fi
109        scdignore[$p]=1
110    done
111    setopt glob
112fi
113
114# Private internal functions are prefixed with _scd_Y19oug_.
115# Clean them up when the scd function returns.
116setopt localtraps
117trap 'unfunction -m "_scd_Y19oug_*"' EXIT
118
119# works faster than the (:a) modifier and is compatible with zsh 4.2.6
120_scd_Y19oug_abspath() {
121    set -A $1 ${(ps:\0:)"$(
122        setopt pushdsilent
123        unfunction -m "*"
124        unalias -m "*"
125        unset CDPATH
126        shift
127        for d; do
128            pushd $d || continue
129            $PWDCASECORRECT &&
130                print -Nr -- $PWD ||
131                print -Nr -- (#i)$PWD
132            popd 2>/dev/null
133        done
134        )"}
135}
136
137# define directory alias
138if [[ -n $opt_alias ]]; then
139    if [[ -n $1 && ! -d $1 ]]; then
140        print -u2 "'$1' is not a directory."
141        $EXIT 1
142    fi
143    a=${opt_alias[-1]#=}
144    _scd_Y19oug_abspath d ${1:-$PWD}
145    # alias in the current shell, update alias file if successful
146    hash -d -- $a=$d &&
147    (
148        umask 077
149        hash -dr
150        [[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
151        hash -d -- $a=$d
152        hash -dL >| $SCD_ALIAS
153    )
154    $EXIT $?
155fi
156
157# undefine one or more directory aliases
158if [[ -n $opt_unalias ]]; then
159    local -U uu
160    local ec=0
161    uu=( ${*:-${PWD}} )
162    if (( ${uu[(I)OLD]} && ${+nameddirs[OLD]} == 0 )); then
163        uu=( ${uu:#OLD} ${(ps:\0:)"$(
164            hash -dr
165            if [[ -r $SCD_ALIAS ]]; then
166                source $SCD_ALIAS
167            fi
168            for a d in ${(kv)nameddirs}; do
169                [[ -d $d ]] || print -Nr -- $a
170            done
171            )"}
172        )
173    fi
174    m=( )
175    for p in $uu; do
176        d=$p
177        if [[ ${+nameddirs[$d]} == 0 && -d $d ]]; then
178            _scd_Y19oug_abspath d $d
179        fi
180        a=${(k)nameddirs[$d]:-${(k)nameddirs[(r)$d]}}
181        if [[ -z $a ]]; then
182            ec=1
183            print -u2 "'$p' is neither a directory alias nor an aliased path."
184            continue
185        fi
186        # unalias in the current shell and remember to update the alias file
187        if unhash -d -- $a 2>/dev/null; then
188            m+=( $a )
189        fi
190    done
191    if [[ $#m != 0 && -r $SCD_ALIAS ]]; then
192        (
193            umask 077
194            hash -dr
195            source $SCD_ALIAS
196            for a in $m; do
197                unhash -d -- $a 2>/dev/null
198            done
199            hash -dL >| $SCD_ALIAS
200        ) || ec=$?
201    fi
202    $EXIT $ec
203fi
204
205# The "compress" function collapses repeated directories into
206# a single entry with a time-stamp yielding an equivalent probability.
207_scd_Y19oug_compress() {
208    awk -v epochseconds=$EPOCHSECONDS \
209        -v meanlife=$SCD_MEANLIFE \
210        -v minlogprob=$MINLOGPROB \
211        '
212        BEGIN {
213            FS = "[:;]";
214            pmin = exp(minlogprob);
215        }
216        /^: deleted:0;/ { next; }
217        length($0) < 4096 && $2 > 1000 {
218            df = $0;
219            sub("^[^;]*;", "", df);
220            if (!df)  next;
221            tau = 1.0 * ($2 - epochseconds) / meanlife;
222            prob = (tau < minlogprob) ? pmin : exp(tau);
223            dlist[last[df]] = "";
224            dlist[NR] = df;
225            last[df] = NR;
226            ptot[df] += prob;
227        }
228        END {
229            for (i = 1; i <= NR; ++i) {
230                d = dlist[i];
231                if (d) {
232                    ts = log(ptot[d]) * meanlife + epochseconds;
233                    printf(": %.0f:0;%s\n", ts, d);
234                }
235            }
236        }
237        ' $*
238}
239
240# Rewrite directory index if it is at least 20% oversized.
241local curhistsize
242if [[ -z $opt_unindex && -s $SCD_HISTFILE ]] && \
243curhistsize=$(wc -l <$SCD_HISTFILE) && \
244(( $curhistsize > 1.2 * $SCD_HISTSIZE )); then
245    # Compress repeated entries in a background process.
246    (
247        m=( ${(f)"$(_scd_Y19oug_compress $SCD_HISTFILE)"} )
248        # purge non-existent and ignored directories
249        m=( ${(f)"$(
250            for a in $m; do
251                d=${a#*;}
252                [[ -z ${scdignore[(k)$d]} ]] || continue
253                [[ -d $d ]] || continue
254                $PWDCASECORRECT || d=( (#i)${d} )
255                t=${a%%;*}
256                print -r -- "${t};${d}"
257            done
258            )"}
259        )
260        # cut old entries if still oversized
261        if [[ $#m -gt $SCD_HISTSIZE ]]; then
262            m=( ${m[-$SCD_HISTSIZE,-1]} )
263        fi
264        # Checking existence of many directories could have taken a while.
265        # Append any index entries added in meantime.
266        m+=( ${(f)"$(sed "1,${curhistsize}d" $SCD_HISTFILE)"} )
267        print -lr -- $m >| ${SCD_HISTFILE}
268    ) &|
269fi
270
271# Determine the last recorded directory
272if [[ -s ${SCD_HISTFILE} ]]; then
273    last_directory=${"$(tail -n 1 ${SCD_HISTFILE})"#*;}
274fi
275
276# The "record" function adds its arguments to the directory index.
277_scd_Y19oug_record() {
278    while [[ -n $last_directory && $1 == $last_directory ]]; do
279        shift
280    done
281    if [[ $# -gt 0 ]]; then
282        ( umask 077
283          p=": ${EPOCHSECONDS}:0;"
284          print -lr -- ${p}${^*} >>| $SCD_HISTFILE )
285    fi
286}
287
288if [[ -n $opt_add ]]; then
289    m=( ${^${argv:-$PWD}}(N-/) )
290    _scd_Y19oug_abspath m ${m}
291    _scd_Y19oug_record $m
292    if [[ -n $opt_recursive ]]; then
293        for d in $m; do
294            print -n "scanning ${d} ... "
295            _scd_Y19oug_record ${d}/**/*(-/N)
296            print "[done]"
297        done
298    fi
299    $EXIT
300fi
301
302# take care of removing entries from the directory index
303if [[ -n $opt_unindex ]]; then
304    if [[ ! -s $SCD_HISTFILE ]]; then
305        $EXIT
306    fi
307    argv=( ${argv:-$PWD} )
308    # expand existing directories in the argument list
309    for i in {1..$#}; do
310        if [[ -d ${argv[i]} ]]; then
311            _scd_Y19oug_abspath d ${argv[i]}
312            argv[i]=${d}
313        fi
314    done
315    # strip trailing slashes, but preserve the root path
316    argv=( ${argv/(#m)?\/##(#e)/${MATCH[1]}} )
317    m="$(awk -v recursive=${opt_recursive} '
318        BEGIN {
319            for (i = 2; i < ARGC; ++i) {
320                argset[ARGV[i]] = 1;
321                delete ARGV[i];
322            }
323            unindex_root = ("/" in argset);
324        }
325        1 {
326            d = $0;  sub(/^[^;]*;/, "", d);
327            if (d in argset)  next;
328        }
329        recursive {
330            if (unindex_root)  exit;
331            for (a in argset) {
332                if (substr(d, 1, length(a) + 1) == a"/")  next;
333            }
334        }
335        { print $0 }
336        ' $SCD_HISTFILE $* )" || $EXIT $?
337    : >| ${SCD_HISTFILE}
338    [[ ${#m} == 0 ]] || print -r -- $m >> ${SCD_HISTFILE}
339    $EXIT
340fi
341
342# The "action" function is called when there is just one target directory.
343_scd_Y19oug_action() {
344    local cdcmd=cd
345    [[ -z ${opt_push} ]] || cdcmd=pushd
346    builtin $cdcmd $1 || return $?
347    if [[ -z $SCD_SCRIPT && -n $RUNNING_AS_COMMAND ]]; then
348        print -u2 "Warning: running as command with SCD_SCRIPT undefined."
349    fi
350    if [[ -n $SCD_SCRIPT ]]; then
351        local d=$1
352        if [[ $OSTYPE == cygwin && ${(L)SCD_SCRIPT} == *.bat ]]; then
353            d=$(cygpath -aw .)
354        fi
355        print -r "${cdcmd} ${(qqq)d}" >| $SCD_SCRIPT
356    fi
357}
358
359# Select and order indexed directories by matching command-line patterns.
360# Set global arrays dmatching and drank.
361_scd_Y19oug_match() {
362    ## single argument that is an existing directory or directory alias
363    if [[ -z $opt_all && $# == 1 ]] && \
364        [[ -d ${d::=${nameddirs[$1]}} || -d ${d::=$1} ]] && [[ -x $d ]];
365    then
366        _scd_Y19oug_abspath dmatching $d
367        drank[${dmatching[1]}]=1
368        return
369    fi
370
371    # quote brackets when PWD is /Volumes/[C]/
372    local qpwd=${PWD//(#m)[][]/\\${MATCH}}
373
374    # support "./" as an alias for $PWD to match only subdirectories.
375    argv=( ${argv/(#s).\/(#e)/(#s)${qpwd}(|/*)(#e)} )
376
377    # support "./pat" as an alias for $PWD/pat.
378    argv=( ${argv/(#m)(#s).\/?*/(#s)${qpwd}${MATCH#.}} )
379
380    # support "^" as an anchor for the root directory, e.g., "^$HOME".
381    argv=( ${argv/(#m)(#s)\^?*/(#s)${${~MATCH[2,-1]}}} )
382
383    # support "$" as an anchor at the end of directory name.
384    argv=( ${argv/(#m)?[$](#e)/${MATCH[1]}(#e)} )
385
386    # support prefix ":" to match over the tail component.
387    argv=( ${argv/(#m)(#s):?*/${MATCH[2,-1]}[^/]#(#e)} )
388
389    # calculate rank of all directories in SCD_HISTFILE and store it in drank.
390    # include a dummy entry to avoid issues with splitting an empty string.
391    [[ -s $SCD_HISTFILE ]] && drank=( ${(f)"$(
392        print -l /dev/null -10
393        <$SCD_HISTFILE \
394        awk -v epochseconds=$EPOCHSECONDS \
395            -v meanlife=$SCD_MEANLIFE \
396            -v minlogprob=$MINLOGPROB \
397            '
398            BEGIN {
399                FS = "[:;]";
400                pmin = exp(minlogprob);
401            }
402            /^: deleted:0;/ {
403                df = $0;
404                sub("^[^;]*;", "", df);
405                delete ptot[df];
406                next;
407            }
408            length($0) < 4096 && $2 > 0 {
409                df = $0;
410                sub("^[^;]*;", "", df);
411                if (!df)  next;
412                dp = df;
413                while (!(dp in ptot)) {
414                    ptot[dp] = pmin;
415                    sub("//*[^/]*$", "", dp);
416                    if (!dp)  break;
417                }
418                if ($2 <= 1000)  next;
419                tau = 1.0 * ($2 - epochseconds) / meanlife;
420                prob = (tau < minlogprob) ? pmin : exp(tau);
421                ptot[df] += prob;
422            }
423            END { for (di in ptot)  { print di; print ptot[di]; } }
424            '
425        )"}
426    )
427    unset "drank[/dev/null]"
428
429    # filter drank to the entries that match all arguments
430    for a; do
431        p="(#l)*(${a})*"
432        drank=( ${(kv)drank[(I)${~p}]} )
433    done
434    # require that at least one argument matches in directory tail name.
435    p="(#l)*(${(j:|:)argv})[^/]#"
436    drank=( ${(kv)drank[(I)${~p}]} )
437
438    # discard ignored directories
439    if [[ -z ${opt_all} ]]; then
440        for d in ${(k)drank}; do
441            [[ -z ${scdignore[(k)$d]} ]] || unset "drank[$d]"
442        done
443    fi
444
445    # build a list of matching directories reverse-sorted by their probabilities
446    dmatching=( ${(f)"$(
447        builtin printf "%s %s\n" ${(Oakv)drank} |
448        command sort -grk1 )"}
449    )
450    dmatching=( ${dmatching#*[[:blank:]]} )
451
452    # do not match $HOME or $PWD when run without arguments
453    if [[ $# == 0 ]]; then
454        dmatching=( ${dmatching:#(${HOME}|${PWD})} )
455    fi
456
457    # keep at most SCD_MENUSIZE of matching and valid directories
458    # mark up any deleted entries in the index
459    local -A isdeleted
460    m=( )
461    isdeleted=( )
462    for d in $dmatching; do
463        [[ ${#m} == $SCD_MENUSIZE ]] && break
464        (( ${+isdeleted[$d]} == 0 )) || continue
465        [[ -d $d ]] || { isdeleted[$d]=1; continue }
466        [[ -x $d ]] && m+=$d
467    done
468    dmatching=( $m )
469    if [[ -n ${isdeleted} ]]; then
470        print -lr -- ": deleted:0;"${^${(k)isdeleted}} >> $SCD_HISTFILE
471    fi
472
473    # find the maximum rank
474    maxrank=0.0
475    for d in $dmatching; do
476        [[ ${drank[$d]} -lt maxrank ]] || maxrank=${drank[$d]}
477    done
478
479    # discard all directories below the rank threshold
480    threshold=$(( maxrank * SCD_THRESHOLD ))
481    if [[ -n ${opt_all} ]]; then
482        threshold=0
483    fi
484    dmatching=( ${^dmatching}(Ne:'(( ${drank[$REPLY]} >= threshold ))':) )
485}
486
487_scd_Y19oug_match $*
488
489## process matching directories.
490if [[ ${#dmatching} == 0 ]]; then
491    print -u2 "No matching directory."
492    $EXIT 1
493fi
494
495## build formatted directory aliases for selection menu or list display
496for d in $dmatching; do
497    if [[ -n ${opt_verbose} ]]; then
498        dalias[$d]=$(printf "%.3g %s" ${drank[$d]} $d)
499    else
500        dalias[$d]=$(print -Dr -- $d)
501    fi
502done
503
504## process the --list option
505if [[ -n $opt_list ]]; then
506    for d in $dmatching; do
507        print -r -- "# ${dalias[$d]}"
508        print -r -- $d
509    done
510    $EXIT
511fi
512
513## handle a single matching directory here.
514if [[ ${#dmatching} == 1 ]]; then
515    _scd_Y19oug_action $dmatching
516    $EXIT $?
517fi
518
519## Here we have multiple matches.  Let's use the selection menu.
520a=( {a-z} {A-Z} )
521a=( ${a[1,${#dmatching}]} )
522p=( )
523for i in {1..${#dmatching}}; do
524    [[ -n ${a[i]} ]] || break
525    p+="${a[i]}) ${dalias[${dmatching[i]}]}"
526done
527
528print -c -r -- $p
529
530if read -s -k 1 d && [[ ${i::=${a[(I)$d]}} -gt 0 ]]; then
531    _scd_Y19oug_action ${dmatching[i]}
532    $EXIT $?
533fi