scd 10 KB

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