]> src.twobees.de Git - dotfiles.git/blob - stow/oh-my-zsh/.oh-my-zsh/plugins/scd/scd
...
[dotfiles.git] / stow / oh-my-zsh / .oh-my-zsh / plugins / scd / scd
1 #!/bin/zsh -f
2
3 emulate -L zsh
4
5 local RUNNING_AS_COMMAND=
6 local EXIT=return
7 if [[ $(whence -w $0) == *:' 'command ]]; then
8     RUNNING_AS_COMMAND=1
9     EXIT=exit
10 fi
11
12 local DOC='scd -- smart change to a recently used directory
13 usage: scd [options] [pattern1 pattern2 ...]
14 Go to a directory path that matches all patterns.  Prefer recent or
15 frequently visited directories as found in the directory index.
16 Display a selection menu in case of multiple matches.
17
18 Special 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
24 Options:
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
42 local SCD_HISTFILE=${SCD_HISTFILE:-${HOME}/.scdhistory}
43 local SCD_HISTSIZE=${SCD_HISTSIZE:-5000}
44 local SCD_MENUSIZE=${SCD_MENUSIZE:-20}
45 local SCD_MEANLIFE=${SCD_MEANLIFE:-86400}
46 local SCD_THRESHOLD=${SCD_THRESHOLD:-0.005}
47 local SCD_SCRIPT=${RUNNING_AS_COMMAND:+$SCD_SCRIPT}
48 local SCD_ALIAS=~/.scdalias.zsh
49 local SCD_IGNORE=~/.scdignore
50
51 # Minimum logarithm of probability.  Avoids out of range warning in exp().
52 local -r MINLOGPROB=-15
53
54 # When false, use case-insensitive globbing to fix PWD capitalization.
55 local PWDCASECORRECT=true
56 if [[ ${OSTYPE} == darwin* ]]; then
57     PWDCASECORRECT=false
58 fi
59
60 local a d m p i maxrank threshold
61 local opt_help opt_add opt_unindex opt_recursive opt_verbose
62 local opt_alias opt_unalias opt_all opt_push opt_list
63 local -A drank dalias scdignore
64 local dmatching
65 local last_directory
66
67 setopt 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
77 zmodload -i zsh/zutil
78 zmodload -i zsh/datetime
79 zmodload -i zsh/parameter
80 zparseopts -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
88 argv[(i)--]=( )
89
90 if [[ -n $opt_help ]]; then
91     print $DOC
92     $EXIT
93 fi
94
95 # load directory aliases if they exist
96 [[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
97
98 # load scd-ignore patterns if available
99 if [[ -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
112 fi
113
114 # Private internal functions are prefixed with _scd_Y19oug_.
115 # Clean them up when the scd function returns.
116 setopt localtraps
117 trap '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
138 if [[ -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 $?
155 fi
156
157 # undefine one or more directory aliases
158 if [[ -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
203 fi
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.
241 local curhistsize
242 if [[ -z $opt_unindex && -s $SCD_HISTFILE ]] && \
243 curhistsize=$(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     ) &|
269 fi
270
271 # Determine the last recorded directory
272 if [[ -s ${SCD_HISTFILE} ]]; then
273     last_directory=${"$(tail -n 1 ${SCD_HISTFILE})"#*;}
274 fi
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
288 if [[ -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
300 fi
301
302 # take care of removing entries from the directory index
303 if [[ -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
340 fi
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         /usr/bin/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.
490 if [[ ${#dmatching} == 0 ]]; then
491     print -u2 "No matching directory."
492     $EXIT 1
493 fi
494
495 ## build formatted directory aliases for selection menu or list display
496 for 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
502 done
503
504 ## process the --list option
505 if [[ -n $opt_list ]]; then
506     for d in $dmatching; do
507         print -r -- "# ${dalias[$d]}"
508         print -r -- $d
509     done
510     $EXIT
511 fi
512
513 ## handle a single matching directory here.
514 if [[ ${#dmatching} == 1 ]]; then
515     _scd_Y19oug_action $dmatching
516     $EXIT $?
517 fi
518
519 ## Here we have multiple matches.  Let's use the selection menu.
520 a=( {a-z} {A-Z} )
521 a=( ${a[1,${#dmatching}]} )
522 p=( )
523 for i in {1..${#dmatching}}; do
524     [[ -n ${a[i]} ]] || break
525     p+="${a[i]}) ${dalias[${dmatching[i]}]}"
526 done
527
528 print -c -r -- $p
529
530 if read -s -k 1 d && [[ ${i::=${a[(I)$d]}} -gt 0 ]]; then
531     _scd_Y19oug_action ${dmatching[i]}
532     $EXIT $?
533 fi