scd 16 KB

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