本文主要是分享一下我切换目录使用的工具和使用场景。


我是一名 zsh 用户。我使用 zsh 而非 bash 主要是因为过去我开始使用终端的时候,正是 oh-my-zsh 和 p10k 的经典搭配在各个 blog 里「甚嚣尘上」的时间。那段时间里,微软甚至还没有发布 Windows Terminal。我还在使用 Windows 平台,使用 cmder 作为自己的默认终端。

我自那时起就很喜欢 Shell,并且很讨厌 PowerShell。我的这一技术倾向主要是得益于 PowerShell 的驼峰式命名。在 Shell 的广袤世界里,大概只有 PowerShell 喜欢把那些特别常用的指令以这种不太好输入的形式呈现出来。偏偏微软为 PowerShell 提供的功能如此得多且繁杂。我那时候还没有养成翻阅文档的好习惯,微软的官方文档对我而言如天书一般。我正是因此转向 zsh 的。

背景故事就是这么多,我今天要介绍的东西是 zsh 一个非常使用的插件,名为 zsh-z。在我的设备上,这一插件由 oh-my-zsh 提供,实际上你可以从 agkozak/zsh-z 这个仓库里找到这个插件的源代码。这是一个相当实用的插件,它最主要的功能就是:为你访问过的路径提供模糊搜索。

zsh-z是我切换目录以及工作流的方式。我工作的目录结构比较复杂,不同的工作被归档在不同的文件夹里,并且过去的工作往往会被归档到我自己也不是很找得到的某个地方去。由于我文件整理的习惯还算良好,加上我使用计算机的时间的确久。我的文件夹里至今仍然保留我当年在 Windows XP 上为学习批处理脚本写下的批处理代码——距今已经十年有余。而我也不过是一个零零后罢了。这意味着我的归档庞大而繁杂。如果动动脑筋,我倒是能花一点时间来找到任何我想要的文件(除了多媒体文件,毕竟我没有精力给每一张照相和每一个视频、音频都做重命名),不过这太麻烦了。有的时候我的生活里会遇到这样的事情:我需要翻阅我过去做过的某个项目,或者翻阅我过去写过的某个课设,然后从这些过去的代码里寻找灵感,或者其他的东西。我就是单纯在未来的某个时间段会用到我过去创造过的一些东西。这个时候,我会在短时间内访问我庞大归档里的一小部分。即便我已经能几乎背出对应文件夹的路径,但每每在终端里拼写起来都仍觉得麻烦。

用例故事的引入就到这里。幸好我们有 zsh-z

zoxide 同样优秀。不过本文主要介绍zsh-z的情况。)

zsh-z 的使用

oh-my-zsh 用户可以使用 omz plugin enable z来启用这一插件。更多的安装方法请参阅仓库的官方文档

启用插件以后,z首先可以作为普通的cd使用。

$ pwd
/home/wold9168
$ z Documents
$ pwd
/home/wold9168/Documents

z可以快速跳转到你最近访问过的路径。比如我最常访问的路径是/home/wold9168/Documents/_WIP。我在这个目录下堆放我正在进行的工作,以及保存我 VSCodium 的工作区(我最近重新开始使用 VSCodium 了,Zed 的 UI 信息密度太低了)。

对于我而言,如果需要跳转到 _WIP,那么只需要输入:

$ z wip
$ pwd
/home/wold9168/Documents/_WIP

我甚至没有输入下划线_,并且没有遵循大小写敏感。

使用 TAB 键触发自动补全,我还可以看到我最近访问的带有 wip 字样的路径有哪些。

# $ z wip<TAB>
# 补全为:
$ z /home/wold9168/Documents/_WIP
/home/wold9168/Documents/_WIP
/home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster
/home/wold9168/Documents/_WIP/_maintain/blog-site          
/home/wold9168/Documents/_WIP/_maintain/operation-logs
# ...
# 上面是候选项

结合一下编辑器

我常用的编辑器是 Zed 和 VSCodium,他们都支持直接从终端启动,并且自动 detach(避免日志刷屏)。

我是 KDE Plasma 用户。即便 kio 为 Zed 和 VSCodium 此类应用提供的「打开文件夹」窗口足够好用,我还是希望能直接敲出文件夹路径来进行快速跳转。Zed 和 VSCodium「打开最近打开过的项目」的功能只记录他们最近打开过的项目路径,而理所应当地不会读取 Shell 停留过的路径。

那么结合 zsh-zcodium。(我们以 VSCodium 为例)

# $ z k8s<tab><enter><enter>
$ z /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster
# $ cod<rightArrow><enter>
$ codium .

这就达到了在指定路径快速打开 VSCodium 的效果。用来开 VSCodium 的这个终端还可以顺带跑 Makefile 或者进行现在又新又酷的 LLM CLI 编程。

微不足道,但是又快又好。

zsh-z 的其他妙用

z -l <关键词>可以查看被zsh-z记录的路径里匹配关键词的匹配列表:

$ z -l k8s
236        /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/lib
441        /home/wold9168/Documents/_WIP/_graduate/code/_3rd/tailscale/docs/k8s
469        /home/wold9168/Documents/_WIP/_graduate/code/_3rd/k8s-client-go
1625       /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/lib/k8sclient
2175       /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/sidecar
6004       /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/sidecar/coredns-config-manager
7045       /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/sidecar/caddy-config-manager
420085     /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/tailscale-manifest/lite-mode
631545     /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster/tailscale-manifest
18930239   /home/wold9168/Documents/_WIP/_graduate/code/k8s-cross-cluster

路径前面是 zsh-z 根据你的终端访问情况计算的权重。

z -t可以按照访问时间来列出最近访问过的路径。其后跟关键词也可以依照访问时间来直接跳转。(感觉加-t和不加-t差不多,不过不加-t应该是根据 zsh-z 计算的权重来跳转)

$ z -t
-27967732  /usr/bin
-26934108  /home/wold9168/Documents/Work Files
-26360309  /home/wold9168/.local/share/navi
-26149055  /home/wold9168/Documents/_WIP/_maintain
# ...

其他的参数建议您用z --help自行查看。

zsh-z 的实现

如果使用 where 来查看 z 指令的话,我们会发现它是zshz指令的一个别名。

$ where z
z: aliased to zshz 2>&1

zshz 是一个巨大的 zsh 函数,足足有 479 行。它在 GitHub 上的链接是:link

`zshz` 函数的完整代码 ```bash zshz () { setopt LOCAL_OPTIONS NO_KSH_ARRAYS NO_SH_WORD_SPLIT EXTENDED_GLOB UNSET (( ZSHZ_DEBUG )) && setopt LOCAL_OPTIONS WARN_CREATE_GLOBAL local REPLY local -a lines local custom_datafile="${ZSHZ_DATA:-$_Z_DATA}" if [[ -n ${custom_datafile} && ${custom_datafile} != */* ]] then print "ERROR: You configured a custom Zsh-z datafile (${custom_datafile}), but have not specified its directory." >&2 exit fi local datafile=${${custom_datafile:-$HOME/.z}:A} if [[ -d $datafile ]] then print "ERROR: Zsh-z's datafile (${datafile}) is a directory." >&2 exit fi [[ -f $datafile ]] || { mkdir -p "${datafile:h}" && touch "$datafile" } [[ -z ${ZSHZ_OWNER:-${_Z_OWNER}} && -f $datafile && ! -O $datafile ]] && return lines=(${(f)"$(< $datafile)"}) lines=(${(M)lines:#/*\|[[:digit:]]##[.,]#[[:digit:]]#\|[[:digit:]]##}) _zshz_add_or_remove_path () { local action=${1} shift if [[ $action == '--add' ]] then [[ $* == $HOME ]] && return local exclude for exclude in ${(@)ZSHZ_EXCLUDE_DIRS:-${(@)_Z_EXCLUDE_DIRS}} do case $* in (${exclude} | ${exclude}/*) return ;; esac done fi local tempfile="${datafile}.${RANDOM}" if (( ZSHZ[USE_FLOCK] )) then local lockfd zsystem flock -f lockfd "$datafile" 2> /dev/null || return fi integer tmpfd case $action in (--add) exec {tmpfd}>| "$tempfile" _zshz_update_datafile $tmpfd "$*" local ret=$? ;; (--remove) local xdir if (( ${ZSHZ_NO_RESOLVE_SYMLINKS:-${_Z_NO_RESOLVE_SYMLINKS}} )) then [[ -d ${${*:-${PWD}}:a} ]] && xdir=${${*:-${PWD}}:a} else [[ -d ${${*:-${PWD}}:A} ]] && xdir=${${*:-${PWD}}:a} fi local -a lines_to_keep if (( ${+opts[-R]} )) then if [[ $xdir == '/' ]] && ! read -q "?Delete entire Zsh-z database? " then print && return 1 fi lines_to_keep=(${lines:#${xdir}\|*}) lines_to_keep=(${lines_to_keep:#${xdir%/}/**}) else lines_to_keep=(${lines:#${xdir}\|*}) fi if [[ $lines != "$lines_to_keep" ]] then lines=($lines_to_keep) else return 1 fi exec {tmpfd}>| "$tempfile" print -u $tmpfd -l -- $lines local ret=$? ;; esac if (( tmpfd != 0 )) then exec {tmpfd}>&- fi if (( ret != 0 )) then ${ZSHZ[RM]} -f "$tempfile" return $ret fi local owner owner=${ZSHZ_OWNER:-${_Z_OWNER}} if (( ZSHZ[USE_FLOCK] )) then if [[ -r '/proc/1/cgroup' && "$(< '/proc/1/cgroup')" == *docker* ]] then print "$(< "$tempfile")" > "$datafile" 2> /dev/null ${ZSHZ[RM]} -f "$tempfile" else ${ZSHZ[MV]} "$tempfile" "$datafile" 2> /dev/null || ${ZSHZ[RM]} -f "$tempfile" fi if [[ -n $owner ]] then ${ZSHZ[CHOWN]} ${owner}:"$(id -ng ${owner})" "$datafile" fi else if [[ -n $owner ]] then ${ZSHZ[CHOWN]} "${owner}":"$(id -ng "${owner}")" "$tempfile" fi ${ZSHZ[MV]} -f "$tempfile" "$datafile" 2> /dev/null || ${ZSHZ[RM]} -f "$tempfile" fi if [[ $action == '--remove' ]] then ZSHZ[DIRECTORY_REMOVED]=1 fi } _zshz_update_datafile () { integer fd=$1 local -A rank time local add_path=${(q)2} local -a existing_paths local now=$EPOCHSECONDS line dir local path_field rank_field time_field count x rank[$add_path]=1 time[$add_path]=$now for line in $lines do if [[ ! -d ${line%%\|*} ]] then for dir in ${(@)ZSHZ_KEEP_DIRS} do if [[ ${line%%\|*} == ${dir}/* || ${line%%\|*} == $dir || $dir == '/' ]] then existing_paths+=($line) fi done else existing_paths+=($line) fi done lines=($existing_paths) for line in $lines do path_field=${(q)line%%\|*} rank_field=${${line%\|*}#*\|} time_field=${line##*\|} (( rank_field < 1 )) && continue if [[ $path_field == $add_path ]] then rank[$path_field]=$rank_field (( rank[$path_field]++ )) time[$path_field]=$now else rank[$path_field]=$rank_field time[$path_field]=$time_field fi (( count += rank_field )) done if (( count > ${ZSHZ_MAX_SCORE:-${_Z_MAX_SCORE:-9000}} )) then for x in ${(k)rank} do print -u $fd -- "$x|$(( 0.99 * rank[$x] ))|${time[$x]}" || return 1 done else for x in ${(k)rank} do print -u $fd -- "$x|${rank[$x]}|${time[$x]}" || return 1 done fi } _zshz_legacy_complete () { local line path_field path_field_normalized 1=${1//[[:space:]]/*} for line in $lines do path_field=${line%%\|*} path_field_normalized=$path_field if (( ZSHZ_TRAILING_SLASH )) then path_field_normalized=${path_field%/}/ fi if [[ $1 == "${1:l}" && ${path_field_normalized:l} == *${~1}* ]] then print -- $path_field elif [[ $path_field_normalized == *${~1}* ]] then print -- $path_field fi done } _zshz_printv () { if (( ZSHZ[PRINTV] )) then builtin print -v REPLY -f %s $@ else builtin print -z $@ builtin read -rz REPLY fi } _zshz_find_common_root () { local -a common_matches local x short common_matches=(${(@Pk)1}) for x in ${(@)common_matches} do if [[ -z $short ]] || (( $#x < $#short )) || [[ $x != ${short}/* ]] then short=$x fi done [[ $short == '/' ]] && return for x in ${(@)common_matches} do [[ $x != $short* ]] && return done _zshz_printv -- $short } _zshz_output () { local match_array=$1 match=$2 format=$3 local common k x local -a descending_list output local -A output_matches output_matches=(${(Pkv)match_array}) _zshz_find_common_root $match_array common=$REPLY case $format in (completion) for k in ${(@k)output_matches} do _zshz_printv -f "%.2f|%s" ${output_matches[$k]} $k descending_list+=(${(f)REPLY}) REPLY='' done descending_list=(${${(@On)descending_list}#*\|}) print -l $descending_list ;; (list) local path_to_display for x in ${(k)output_matches} do if (( ${output_matches[$x]} )) then path_to_display=$x (( ZSHZ_TILDE )) && path_to_display=${path_to_display/#${HOME}/\~} _zshz_printv -f "%-10d %s\n" ${output_matches[$x]} $path_to_display output+=(${(f)REPLY}) REPLY='' fi done if [[ -n $common ]] then (( ZSHZ_TILDE )) && common=${common/#${HOME}/\~} (( $#output > 1 )) && printf "%-10s %s\n" 'common:' $common fi if (( $+opts[-t] )) then for x in ${(@On)output} do print -- $x done elif (( $+opts[-r] )) then for x in ${(@on)output} do print -- $x done else for x in ${(@on)output} do print $x done fi ;; (*) if (( ! ZSHZ_UNCOMMON )) && [[ -n $common ]] then _zshz_printv -- $common else _zshz_printv -- ${(P)match} fi ;; esac } _zshz_find_matches () { setopt LOCAL_OPTIONS NO_EXTENDED_GLOB local fnd=$1 method=$2 format=$3 local -a existing_paths local line dir path_field rank_field time_field rank dx escaped_path_field local -A matches imatches local best_match ibest_match hi_rank=-9999999999 ihi_rank=-9999999999 for line in $lines do if [[ ! -d ${line%%\|*} ]] then for dir in ${(@)ZSHZ_KEEP_DIRS} do if [[ ${line%%\|*} == ${dir}/* || ${line%%\|*} == $dir || $dir == '/' ]] then existing_paths+=($line) fi done else existing_paths+=($line) fi done lines=($existing_paths) for line in $lines do path_field=${line%%\|*} rank_field=${${line%\|*}#*\|} time_field=${line##*\|} case $method in (rank) rank=$rank_field ;; (time) (( rank = time_field - EPOCHSECONDS )) ;; (*) (( dx = EPOCHSECONDS - time_field )) rank=$(( 10000 * rank_field * (3.75/( (0.0001 * dx + 1) + 0.25)) )) ;; esac local q=${fnd//[[:space:]]/\*} local path_field_normalized=$path_field if (( ZSHZ_TRAILING_SLASH )) then path_field_normalized=${path_field%/}/ fi if [[ $ZSHZ_CASE == 'smart' && ${1:l} == $1 && ${path_field_normalized:l} == ${~q:l} ]] then imatches[$path_field]=$rank elif [[ $ZSHZ_CASE != 'ignore' && $path_field_normalized == ${~q} ]] then matches[$path_field]=$rank elif [[ $ZSHZ_CASE != 'smart' && ${path_field_normalized:l} == ${~q:l} ]] then imatches[$path_field]=$rank fi escaped_path_field=${path_field//'\'/'\\'} escaped_path_field=${escaped_path_field//'`'/'\`'} escaped_path_field=${escaped_path_field//'('/'\('} escaped_path_field=${escaped_path_field//')'/'\)'} escaped_path_field=${escaped_path_field//'['/'\['} escaped_path_field=${escaped_path_field//']'/'\]'} if (( matches[$escaped_path_field] )) && (( matches[$escaped_path_field] > hi_rank )) then best_match=$path_field hi_rank=${matches[$escaped_path_field]} elif (( imatches[$escaped_path_field] )) && (( imatches[$escaped_path_field] > ihi_rank )) then ibest_match=$path_field ihi_rank=${imatches[$escaped_path_field]} ZSHZ[CASE_INSENSITIVE]=1 fi done [[ -z $best_match && -z $ibest_match ]] && return 1 if [[ -n $best_match ]] then _zshz_output matches best_match $format elif [[ -n $ibest_match ]] then _zshz_output imatches ibest_match $format fi } local -A opts zparseopts -E -D -A opts -- -add -complete c e h -help l r R t x if [[ $1 == '--' ]] then shift elif [[ -n ${(M)@:#-*} && -z $compstate ]] then print "Improper option(s) given." _zshz_usage return 1 fi local opt output_format method='frecency' fnd prefix req for opt in ${(k)opts} do case $opt in (--add) [[ ! -d $* ]] && return 1 local dir if [[ $OSTYPE == (cygwin|msys) && $PWD == '/' && $* != /* ]] then set -- "/$*" fi if (( ${ZSHZ_NO_RESOLVE_SYMLINKS:-${_Z_NO_RESOLVE_SYMLINKS}} )) then dir=${*:a} else dir=${*:A} fi _zshz_add_or_remove_path --add "$dir" return ;; (--complete) if [[ -s $datafile && ${ZSHZ_COMPLETION:-frecent} == 'legacy' ]] then _zshz_legacy_complete "$1" return fi output_format='completion' ;; (-c) [[ $* == ${PWD}/* || $PWD == '/' ]] || prefix="$PWD " ;; (-h | --help) _zshz_usage return ;; (-l) output_format='list' ;; (-r) method='rank' ;; (-t) method='time' ;; (-x) if [[ $OSTYPE == (cygwin|msys) && $PWD == '/' && $* != /* ]] then set -- "/$*" fi _zshz_add_or_remove_path --remove $* return ;; esac done req="$*" fnd="$prefix$*" [[ -n $fnd && $fnd != "$PWD " ]] || { [[ $output_format != 'completion' ]] && output_format='list' } zshz_cd () { setopt LOCAL_OPTIONS NO_WARN_CREATE_GLOBAL if [[ -z $ZSHZ_CD ]] then builtin cd "$*" else ${=ZSHZ_CD} "$*" fi } _zshz_echo () { if (( ZSHZ_ECHO )) then if (( ZSHZ_TILDE )) then print ${PWD/#${HOME}/\~} else print $PWD fi fi } if [[ ${@: -1} == /* ]] && (( ! $+opts[-e] && ! $+opts[-l] )) then [[ -d ${@: -1} ]] && zshz_cd ${@: -1} && _zshz_echo && return fi if [[ ! -z ${(tP)opts[-c]} ]] then _zshz_find_matches "$fnd*" $method $output_format else _zshz_find_matches "*$fnd*" $method $output_format fi local ret2=$? local cd cd=$REPLY if (( ZSHZ_UNCOMMON )) && [[ -n $cd ]] then if [[ -n $cd ]] then local q=${fnd//[[:space:]]/\*} q=${q%/} if (( ! ZSHZ[CASE_INSENSITIVE] )) then local q_chars=$(( ${#cd} - ${#${cd//${~q}/}} )) until (( ( ${#cd:h} - ${#${${cd:h}//${~q}/}} ) != q_chars )) do cd=${cd:h} done else local q_chars=$(( ${#cd} - ${#${${cd:l}//${~${q:l}}/}} )) until (( ( ${#cd:h} - ${#${${${cd:h}:l}//${~${q:l}}/}} ) != q_chars )) do cd=${cd:h} done fi ZSHZ[CASE_INSENSITIVE]=0 fi fi if (( ret2 == 0 )) && [[ -n $cd ]] then if (( $+opts[-e] )) then (( ZSHZ_TILDE )) && cd=${cd/#${HOME}/\~} print -- "$cd" else [[ -d $cd ]] && zshz_cd "$cd" && _zshz_echo fi else if ! (( $+opts[-e] || $+opts[-l] )) && [[ -d $req ]] then zshz_cd "$req" && _zshz_echo else return $ret2 fi fi } ```

学习技术吗,我们肯定是要看一看它的原理的。以下我们介绍zsh-z使用到的三个技术点,分别是 zsh 的 Hook 机制、zsh-z的数据更新方案以及其模糊匹配机制。依我愚见,看这三点大概就够了的,其余部分还请读者移步官仓自行品读。

zsh 的 Hook 机制

在计算机中,Hook 大抵可以被用来形容所有劫持或拦截程序原有执行流程,并在特定时机插入自定义代码的技术。最简单的 Hook 也许就是维护一个函数指针数组,然后写一个所谓「执行器」。在执行器执行传入的函数指针之前,把函数指针数组里的函数先执行了。这些「先执行的函数指针」我们称之为 Hook。我觉得这应该是得名自这些函数被某种东西触发以后,像钓鱼佬把鱼从水里拉出来一样,扯出一大堆的东西来。

zsh 的 Hook 机制是 zsh-z 得以在我们执行cd指令后触发数据更新算法的关键。zsh-z主要使用这样两个 Hook:precmdchpwd。望文生义,这两个 Hook 分别在特定指令之前被触发,以及在切换目录的时候触发。

# 将 _zshz_precmd 和 _zshz_chpwd 两个函数注册给这两个 Hook
# 使得 Hook 被触发的时候,对应的函数被执行
add-zsh-hook precmd _zshz_precmd
add-zsh-hook chpwd _zshz_chpwd

Hook 机制的核心是 add-zsh-hook。zsh 的官方文档对此有很详尽的阐述。

以下语段截取自 zsh 的 info 手册的 26.2.5 节:

add-zsh-hook [ -L | -dD ] [ -Uzk ] HOOK FUNCTION
     Several functions are special to the shell, as described in the
     section Special Functions, *note Functions::, in that they are
     automatically called at specific points during shell execution.
     Each has an associated array consisting of names of functions to be
     called at the same point; these are so-called 'hook functions'.
     The shell function add-zsh-hook provides a simple way of adding or
     removing functions from the array.

     HOOK is one of chpwd, periodic, precmd, preexec, zshaddhistory,
     zshexit, or zsh_directory_name, the special functions in question.
     Note that zsh_directory_name is called in a different way from the
     other functions, but may still be manipulated as a hook.

     FUNCTION is name of an ordinary shell function.  If no options are
     given this will be added to the array of functions to be executed
     in the given context.  Functions are invoked in the order they were
     added.

     If the option -L is given, the current values for the hook arrays
     are listed with typeset.

     If the option -d is given, the FUNCTION is removed from the array
     of functions to be executed.

     If the option -D is given, the FUNCTION is treated as a pattern and
     any matching names of functions are removed from the array of
     functions to be executed.

     The options -U, -z and -k are passed as arguments to autoload for
     FUNCTION.  For functions contributed with zsh, the options -Uz are
     appropriate.
译文 ``` add-zsh-hook [ -L | -dD ] [ -Uzk ] HOOK FUNCTION 有几个函数对 shell 来说是特殊的,如 note Functions:: 章节中的 Special Functions 所述,它们会在 shell 执行期间的特定点被自动调用。每个特殊函数都有一个关联的数组,其中包含要在同一点调用的函数名称;这些就是所谓的"钩子函数"。shell 函数 add-zsh-hook 提供了一种简单的方法来向数组中添加或移除函数。 HOOK 是 chpwd、periodic、precmd、preexec、zshaddhistory、zshexit 或 zsh_directory_name 之一,即上述的特殊函数。注意,zsh_directory_name 的调用方式与其他函数不同,但仍可作为钩子进行操作。 FUNCTION 是一个普通 shell 函数的名称。如果没有给出选项,该函数将被添加到在给定上下文中要执行的函数数组中。函数按照添加的顺序被调用。 如果给出 -L 选项,将使用 typeset 列出钩子数组的当前值。 如果给出 -d 选项,将从要执行的函数数组中移除该 FUNCTION。 如果给出 -D 选项,该 FUNCTION 将被视为一个模式,并从要执行的函数数组中移除所有匹配的名称。 选项 -U、-z 和 -k 将作为参数传递给 FUNCTION 的 autoload。对于随 zsh 一起提供的函数,-Uz 选项是合适的。 ```

作为补充,zsh 的 info 手册的 9.3.1 节详细介绍了 zsh 的钩子函数这一机制:

9.3.1 Hook Functions
--------------------

For the functions below, it is possible to define an array that has the
same name as the function with '_functions' appended.  Any element in
such an array is taken as the name of a function to execute; it is
executed in the same context and with the same arguments and same
initial value of $? as the basic function.  For example, if
$chpwd_functions is an array containing the values 'mychpwd',
'chpwd_save_dirstack', then the shell attempts to execute the functions
'chpwd', 'mychpwd' and 'chpwd_save_dirstack', in that order.  Any
function that does not exist is silently ignored.  A function found by
this mechanism is referred to elsewhere as a _hook function_.  An error
in any function causes subsequent functions not to be run.  Note further
that an error in a precmd hook causes an immediately following periodic
function not to run (though it may run at the next opportunity).
译文 ``` 9.3.1 钩子函数 -------------------- 对于以下函数,可以定义一个与函数同名但追加了 '_functions' 后缀的数组。该数组中的任何元素都将被 视为要执行的函数名;它将在与基本函数相同的上下文、相同的 参数以及相同的 $? 初始值下执行。例如,如果 $chpwd_functions 是一个包含值 'mychpwd'、 'chpwd_save_dirstack' 的数组,则 shell 将尝试按顺序执行函数 'chpwd'、'mychpwd' 和 'chpwd_save_dirstack'。任何 不存在的函数都会被静默忽略。通过此机制找到的函数在其他 地方被称为 _钩子函数_。任一函数发生错误都会导致后续函数不 再运行。此外还需注意,precmd 钩子发生错误会导致紧随其后的周期性 函数无法运行(尽管它可能会在下一次机会时运行)。 ```

数据更新

这个 zsh 函数的核心内容是_zshz_update_datafile,该函数更新 zshz 所记录的路径的权重。(值得注意的是,_zshz_update_datafile的权重和z -l输出的权重不是同一个权重。这两者都不是一个量级的。)

该函数的主要流程有三:

  • 对于新路径:初始 rank=1, time=now
  • 对已有路径:若命中则 rank++ 且 time=now;否则保持原值
  • 若总权重超过阈值(9000),所有权重乘以 0.99 做衰减
`_zshz_update_datafile` 附注 ```zsh _zshz_update_datafile () { integer fd=$1 local -A rank time local add_path=${(q)2} local -a existing_paths local now=$EPOCHSECONDS line dir local path_field rank_field time_field count x rank[$add_path]=1 # 新路径初始分数为1 time[$add_path]=$now # 时间设为当前 # 过滤已经不存在的目录,但保留 ZSHZ_KEEP_DIRS 中的 for line in $lines do if [[ ! -d ${line%%\|*} ]] then for dir in ${(@)ZSHZ_KEEP_DIRS} do if [[ ${line%%\|*} == ${dir}/* || ${line%%\|*} == $dir || $dir == '/' ]] then existing_paths+=($line) fi done else existing_paths+=($line) fi done lines=($existing_paths) # 遍历历史,更新 rank 和 time for line in $lines do path_field=${(q)line%%\|*} # 路径(第一个 | 之前) rank_field=${${line%\|*}#*\|} # 分数(中间字段) time_field=${line##*\|} # 时间戳(最后一个 | 之后) (( rank_field < 1 )) && continue if [[ $path_field == $add_path ]] then rank[$path_field]=$rank_field (( rank[$path_field]++ )) # 命中:分数 +1 time[$path_field]=$now # 更新时间戳 else rank[$path_field]=$rank_field time[$path_field]=$time_field fi (( count += rank_field )) # 计算总分 done # 若总分超过阈值,全部衰减 ×0.99(防止无限增长) if (( count > ${ZSHZ_MAX_SCORE:-${_Z_MAX_SCORE:-9000}} )) then for x in ${(k)rank} do print -u $fd -- "$x|$(( 0.99 * rank[$x] ))|${time[$x]}" || return 1 done else for x in ${(k)rank} do print -u $fd -- "$x|${rank[$x]}|${time[$x]}" || return 1 done fi } ```

模糊匹配

zsh-z的模糊匹配我觉得也有必要介绍一番。模糊搜索的逻辑被写在_zshz_find_matches里,该函数被用来遍历所有历史路径,计算每条路径的分数,然后根据对用户输入的模糊匹配来返回最佳结果。

该函数使用的模糊匹配策略主要体现在这部分代码中:

# 模糊匹配用户输入
local q=${fnd//[[:space:]]/\*}     # 进行预处理,将空格转为通配符
local path_field_normalized=$path_field
if (( ZSHZ_TRAILING_SLASH ))
then
  path_field_normalized=${path_field%/}/ # 还是预处理,如果启用 ZSHZ_TRAILING_SLASH,那么强制给路径尾部加上斜杠
fi

# 如若输入是全小写的,则以忽略大小写的形式处理;
# 否则以区分大小写的形式处理

# smart 模式 且 输入全小写 且 路径(转小写后)匹配通配符
if [[ $ZSHZ_CASE == 'smart' && ${1:l} == $1 && ${path_field_normalized:l} == ${~q:l} ]]
then
  imatches[$path_field]=$rank
# ignore 模式未开启 且 路径直接匹配通配符(区分大小写)
elif [[ $ZSHZ_CASE != 'ignore' && $path_field_normalized == ${~q} ]]
then
  matches[$path_field]=$rank
# smart 模式未开启 且 路径(转小写后)匹配通配符
elif [[ $ZSHZ_CASE != 'smart' && ${path_field_normalized:l} == ${~q:l} ]]
then
  imatches[$path_field]=$rank
fi

# 对特殊字符转义
escaped_path_field=${path_field//'\'/'\\'}
escaped_path_field=${escaped_path_field//'`'/'\`'}
escaped_path_field=${escaped_path_field//'('/'\('}
escaped_path_field=${escaped_path_field//')'/'\)'}
escaped_path_field=${escaped_path_field//'['/'\['}
escaped_path_field=${escaped_path_field//']'/'\]'}

# 先以大小写敏感的方式进行匹配
if (( matches[$escaped_path_field] )) && (( matches[$escaped_path_field] > hi_rank ))
then
  best_match=$path_field
  hi_rank=${matches[$escaped_path_field]}
# 然后以大小写不敏感的方式匹配大小写不敏感的结果
elif (( imatches[$escaped_path_field] )) && (( imatches[$escaped_path_field] > ihi_rank ))
then
  ibest_match=$path_field
  ihi_rank=${imatches[$escaped_path_field]}
  ZSHZ[CASE_INSENSITIVE]=1
fi