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