scd 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. #!/bin/zsh -f
  2. emulate -L zsh
  3. local EXIT=return
  4. if [[ $(whence -w $0) == *:' 'command ]]; then
  5. emulate -R zsh
  6. local 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 contains all fixed string patterns. Prefer
  12. recent or frequently visited directories as found in the directory index.
  13. Display a selection menu in case of multiple matches.
  14. Options:
  15. -a, --add add specified directories to the directory index.
  16. --unindex remove current or specified directories from the index.
  17. -r, --recursive apply options --add or --unindex recursively.
  18. --alias=ALIAS create alias for the current or specified directory and
  19. store it in ~/.scdalias.zsh.
  20. --unalias remove ALIAS definition for the current or specified
  21. directory from ~/.scdalias.zsh.
  22. -A, --all include all matching directories. Disregard matching by
  23. directory alias and filtering of less likely paths.
  24. --list show matching directories and exit.
  25. -v, --verbose display directory rank in the selection menu.
  26. -h, --help display this message and exit.
  27. '
  28. local SCD_HISTFILE=${SCD_HISTFILE:-${HOME}/.scdhistory}
  29. local SCD_HISTSIZE=${SCD_HISTSIZE:-5000}
  30. local SCD_MENUSIZE=${SCD_MENUSIZE:-20}
  31. local SCD_MEANLIFE=${SCD_MEANLIFE:-86400}
  32. local SCD_THRESHOLD=${SCD_THRESHOLD:-0.005}
  33. local SCD_SCRIPT=${RUNNING_AS_COMMAND:+$SCD_SCRIPT}
  34. local SCD_ALIAS=~/.scdalias.zsh
  35. local ICASE a d m p i maxrank threshold
  36. local opt_help opt_add opt_unindex opt_recursive opt_verbose
  37. local opt_alias opt_unalias opt_all opt_list
  38. local -A drank dalias
  39. local dmatching
  40. local last_directory
  41. setopt extendedhistory extendedglob noautonamedirs brace_ccl
  42. # If SCD_SCRIPT is defined make sure the file exists and is empty.
  43. # This removes any previous old commands.
  44. [[ -n "$SCD_SCRIPT" ]] && [[ -s $SCD_SCRIPT || ! -f $SCD_SCRIPT ]] && (
  45. umask 077
  46. : >| $SCD_SCRIPT
  47. )
  48. # process command line options
  49. zmodload -i zsh/zutil
  50. zmodload -i zsh/datetime
  51. zparseopts -D -- a=opt_add -add=opt_add -unindex=opt_unindex \
  52. r=opt_recursive -recursive=opt_recursive \
  53. -alias:=opt_alias -unalias=opt_unalias \
  54. A=opt_all -all=opt_all -list=opt_list \
  55. v=opt_verbose -verbose=opt_verbose h=opt_help -help=opt_help \
  56. || $EXIT $?
  57. if [[ -n $opt_help ]]; then
  58. print $DOC
  59. $EXIT
  60. fi
  61. # load directory aliases if they exist
  62. [[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
  63. # Private internal functions are prefixed with _scd_Y19oug_.
  64. # Clean them up when the scd function returns.
  65. setopt localtraps
  66. trap 'unfunction -m "_scd_Y19oug_*"' EXIT
  67. # works faster than the (:a) modifier and is compatible with zsh 4.2.6
  68. _scd_Y19oug_abspath() {
  69. set -A $1 ${(ps:\0:)"$(
  70. unfunction -m "*"; shift
  71. for d; do
  72. cd $d && print -Nr -- $PWD && cd $OLDPWD
  73. done
  74. )"}
  75. }
  76. # define directory alias
  77. if [[ -n $opt_alias ]]; then
  78. if [[ -n $1 && ! -d $1 ]]; then
  79. print -u2 "'$1' is not a directory."
  80. $EXIT 1
  81. fi
  82. a=${opt_alias[-1]#=}
  83. _scd_Y19oug_abspath d ${1:-$PWD}
  84. # alias in the current shell, update alias file if successful
  85. hash -d -- $a=$d &&
  86. (
  87. umask 077
  88. hash -dr
  89. [[ -r $SCD_ALIAS ]] && source $SCD_ALIAS
  90. hash -d -- $a=$d
  91. hash -dL >| $SCD_ALIAS
  92. )
  93. $EXIT $?
  94. fi
  95. # undefine directory alias
  96. if [[ -n $opt_unalias ]]; then
  97. if [[ -n $1 && ! -d $1 ]]; then
  98. print -u2 "'$1' is not a directory."
  99. $EXIT 1
  100. fi
  101. _scd_Y19oug_abspath a ${1:-$PWD}
  102. a=$(print -rD ${a})
  103. if [[ $a != [~][^/]## ]]; then
  104. $EXIT
  105. fi
  106. a=${a#[~]}
  107. # unalias in the current shell, update alias file if successful
  108. if unhash -d -- $a 2>/dev/null && [[ -r $SCD_ALIAS ]]; then
  109. (
  110. umask 077
  111. hash -dr
  112. source $SCD_ALIAS
  113. unhash -d -- $a 2>/dev/null &&
  114. hash -dL >| $SCD_ALIAS
  115. )
  116. fi
  117. $EXIT $?
  118. fi
  119. # The "compress" function collapses repeated directories to
  120. # one entry with a time stamp that gives equivalent-probability.
  121. _scd_Y19oug_compress() {
  122. awk -v epochseconds=$EPOCHSECONDS -v meanlife=$SCD_MEANLIFE '
  123. BEGIN { FS = "[:;]"; }
  124. length($0) < 4096 && $2 > 0 {
  125. tau = 1.0 * ($2 - epochseconds) / meanlife;
  126. if (tau < -6.9078) tau = -6.9078;
  127. prob = exp(tau);
  128. sub(/^[^;]*;/, "");
  129. if (NF) {
  130. dlist[last[$0]] = "";
  131. dlist[NR] = $0;
  132. last[$0] = NR;
  133. ptot[$0] += prob;
  134. }
  135. }
  136. END {
  137. for (i = 1; i <= NR; ++i) {
  138. d = dlist[i];
  139. if (d) {
  140. ts = log(ptot[d]) * meanlife + epochseconds;
  141. printf(": %.0f:0;%s\n", ts, d);
  142. }
  143. }
  144. }
  145. ' $*
  146. }
  147. # Rewrite directory index if it is at least 20% oversized
  148. if [[ -s $SCD_HISTFILE ]] && \
  149. (( $(wc -l <$SCD_HISTFILE) > 1.2 * $SCD_HISTSIZE )); then
  150. # compress repeated entries
  151. m=( ${(f)"$(_scd_Y19oug_compress $SCD_HISTFILE)"} )
  152. # purge non-existent directories
  153. m=( ${(f)"$(
  154. for a in $m; do
  155. if [[ -d ${a#*;} ]]; then print -r -- $a; fi
  156. done
  157. )"}
  158. )
  159. # cut old entries if still oversized
  160. if [[ $#m -gt $SCD_HISTSIZE ]]; then
  161. m=( ${m[-$SCD_HISTSIZE,-1]} )
  162. fi
  163. print -lr -- $m >| ${SCD_HISTFILE}
  164. fi
  165. # Determine the last recorded directory
  166. if [[ -s ${SCD_HISTFILE} ]]; then
  167. last_directory=${"$(tail -1 ${SCD_HISTFILE})"#*;}
  168. fi
  169. # The "record" function adds its arguments to the directory index.
  170. _scd_Y19oug_record() {
  171. while [[ -n $last_directory && $1 == $last_directory ]]; do
  172. shift
  173. done
  174. if [[ $# -gt 0 ]]; then
  175. ( umask 077
  176. p=": ${EPOCHSECONDS}:0;"
  177. print -lr -- ${p}${^*} >>| $SCD_HISTFILE )
  178. fi
  179. }
  180. if [[ -n $opt_add ]]; then
  181. for d; do
  182. if [[ ! -d $d ]]; then
  183. print -u2 "Directory '$d' does not exist."
  184. $EXIT 2
  185. fi
  186. done
  187. _scd_Y19oug_abspath m ${*:-$PWD}
  188. _scd_Y19oug_record $m
  189. if [[ -n $opt_recursive ]]; then
  190. for d in $m; do
  191. print -n "scanning ${d} ... "
  192. _scd_Y19oug_record ${d}/**/*(-/N)
  193. print "[done]"
  194. done
  195. fi
  196. $EXIT
  197. fi
  198. # take care of removing entries from the directory index
  199. if [[ -n $opt_unindex ]]; then
  200. if [[ ! -s $SCD_HISTFILE ]]; then
  201. $EXIT
  202. fi
  203. # expand existing directories in the argument list
  204. for i in {1..$#}; do
  205. if [[ -d ${argv[i]} ]]; then
  206. _scd_Y19oug_abspath d ${argv[i]}
  207. argv[i]=${d}
  208. fi
  209. done
  210. m="$(awk -v recursive=${opt_recursive} '
  211. BEGIN {
  212. for (i = 2; i < ARGC; ++i) {
  213. argset[ARGV[i]] = 1;
  214. delete ARGV[i];
  215. }
  216. }
  217. 1 {
  218. d = $0; sub(/^[^;]*;/, "", d);
  219. if (d in argset) next;
  220. }
  221. recursive {
  222. for (a in argset) {
  223. if (substr(d, 1, length(a) + 1) == a"/") next;
  224. }
  225. }
  226. { print $0 }
  227. ' $SCD_HISTFILE ${*:-$PWD} )" || $EXIT $?
  228. : >| ${SCD_HISTFILE}
  229. [[ ${#m} == 0 ]] || print -r -- $m >> ${SCD_HISTFILE}
  230. $EXIT
  231. fi
  232. # The "action" function is called when there is just one target directory.
  233. _scd_Y19oug_action() {
  234. cd $1 || return $?
  235. if [[ -z $SCD_SCRIPT && -n $RUNNING_AS_COMMAND ]]; then
  236. print -u2 "Warning: running as command with SCD_SCRIPT undefined."
  237. fi
  238. if [[ -n $SCD_SCRIPT ]]; then
  239. print -r "cd ${(q)1}" >| $SCD_SCRIPT
  240. fi
  241. }
  242. # Match and rank patterns to the index file
  243. # set global arrays dmatching and drank
  244. _scd_Y19oug_match() {
  245. ## single argument that is an existing directory or directory alias
  246. if [[ -z $opt_all && $# == 1 ]] && \
  247. [[ -d ${d::=$1} || -d ${d::=${nameddirs[$1]}} ]] && [[ -x $d ]];
  248. then
  249. _scd_Y19oug_abspath dmatching $d
  250. drank[${dmatching[1]}]=1
  251. return
  252. fi
  253. # ignore case unless there is an argument with an uppercase letter
  254. [[ "$*" == *[[:upper:]]* ]] || ICASE='(#i)'
  255. # support "$" as an anchor for the directory name ending
  256. argv=( ${argv/(#m)?[$](#e)/${MATCH[1]}(#e)} )
  257. # calculate rank of all directories in the SCD_HISTFILE and keep it as drank
  258. # include a dummy entry for splitting of an empty string is buggy
  259. [[ -s $SCD_HISTFILE ]] && drank=( ${(f)"$(
  260. print -l /dev/null -10
  261. <$SCD_HISTFILE \
  262. awk -v epochseconds=$EPOCHSECONDS -v meanlife=$SCD_MEANLIFE '
  263. BEGIN { FS = "[:;]"; }
  264. length($0) < 4096 && $2 > 0 {
  265. tau = 1.0 * ($2 - epochseconds) / meanlife;
  266. if (tau < -6.9078) tau = -6.9078;
  267. prob = exp(tau);
  268. sub(/^[^;]*;/, "");
  269. if (NF) ptot[$0] += prob;
  270. }
  271. END { for (di in ptot) { print di; print ptot[di]; } }'
  272. )"}
  273. )
  274. unset "drank[/dev/null]"
  275. # filter drank to the entries that match all arguments
  276. for a; do
  277. p=${ICASE}"*(${a})*"
  278. drank=( ${(kv)drank[(I)${~p}]} )
  279. done
  280. # require at least one argument matches the directory name
  281. p=${ICASE}"*(${(j:|:)argv})[^/]#"
  282. drank=( ${(kv)drank[(I)${~p}]} )
  283. # build a list of matching directories reverse-sorted by their probabilities
  284. dmatching=( ${(f)"$(
  285. for d p in ${(kv)drank}; do
  286. print -r -- "$p $d";
  287. done | sort -grk1 | cut -d ' ' -f 2-
  288. )"}
  289. )
  290. # do not match $HOME or $PWD when run without arguments
  291. if [[ $# == 0 ]]; then
  292. dmatching=( ${dmatching:#(${HOME}|${PWD})} )
  293. fi
  294. # keep at most SCD_MENUSIZE of matching and valid directories
  295. m=( )
  296. for d in $dmatching; do
  297. [[ ${#m} == $SCD_MENUSIZE ]] && break
  298. [[ -d $d && -x $d ]] && m+=$d
  299. done
  300. dmatching=( $m )
  301. # find the maximum rank
  302. maxrank=0.0
  303. for d in $dmatching; do
  304. [[ ${drank[$d]} -lt maxrank ]] || maxrank=${drank[$d]}
  305. done
  306. # discard all directories below the rank threshold
  307. threshold=$(( maxrank * SCD_THRESHOLD ))
  308. if [[ -n ${opt_all} ]]; then
  309. threshold=0
  310. fi
  311. dmatching=( ${^dmatching}(Ne:'(( ${drank[$REPLY]} >= threshold ))':) )
  312. }
  313. _scd_Y19oug_match $*
  314. ## process whatever directories that remained
  315. if [[ ${#dmatching} == 0 ]]; then
  316. print -u2 "No matching directory."
  317. $EXIT 1
  318. fi
  319. ## build formatted directory aliases for selection menu or list display
  320. for d in $dmatching; do
  321. if [[ -n ${opt_verbose} ]]; then
  322. dalias[$d]=$(printf "%.3g %s" ${drank[$d]} $d)
  323. else
  324. dalias[$d]=$(print -Dr -- $d)
  325. fi
  326. done
  327. ## process the --list option
  328. if [[ -n $opt_list ]]; then
  329. for d in $dmatching; do
  330. print -r -- "# ${dalias[$d]}"
  331. print -r -- $d
  332. done
  333. $EXIT
  334. fi
  335. ## process single directory match
  336. if [[ ${#dmatching} == 1 ]]; then
  337. _scd_Y19oug_action $dmatching
  338. $EXIT $?
  339. fi
  340. ## here we have multiple matches - display selection menu
  341. a=( {a-z} {A-Z} )
  342. a=( ${a[1,${#dmatching}]} )
  343. p=( )
  344. for i in {1..${#dmatching}}; do
  345. [[ -n ${a[i]} ]] || break
  346. p+="${a[i]}) ${dalias[${dmatching[i]}]}"
  347. done
  348. print -c -r -- $p
  349. if read -s -k 1 d && [[ ${i::=${a[(I)$d]}} -gt 0 ]]; then
  350. _scd_Y19oug_action ${dmatching[i]}
  351. $EXIT $?
  352. fi