本文将介绍 zsh 的历史扩展(History Expansion)与修饰符(Modifiers)的用法。

历史扩展允许你复用历史记录中的命令的整体或者某个部分,提高了修改拼写错误和复杂命令时的体验。

修饰符是一套用于修饰扩展结果的规则,它发源于历史扩展,但也适用于参数扩展(parameter expansion)和文件名扩展(filename generation)。

历史扩展

历史扩展只局限于会话内历史记录(internal history list),其大小可以由 HISTSIZE 变量控制。注意区别于 SAVEHIST,后者是指 HISTFILE 中允许保存的历史记录条目数量,这个值可以很大。但是 HISTSIZE 如果设置地非常大的话,在你的 HISTFILE 同样过大时就会在启动 ZSH 的时候出现你怎么测都测不出来但是能明显感受到的卡顿。

每个历史记录条目(event)都有一个编号,利用这个编号可以快速访问某一条历史记录。你可以在 PROMPT 中添加 %h%! 以实时显示这个编号。

速览

zsh 的历史扩展整体上和 bash 是一致的。

一条历史扩展表达式以 histchars 变量中的第一个字符开始(默认是 !),它和变量类似,可以出现在除了单引号和 C 风格转义字符串($'')以外的任何地方,包括双引号内部

histchars 是一个包含三个字符的变量,默认为 !^#,第一个字符用来指示历史扩展表达式的开始,第二个字符用于指示快速历史替换,第三个字符用于注释。为了简化书写,以下都假设你没有修改过这个变量。

! 后可以跟一个可选的条目标识符(event designator)和一个可选的词标识符(word designator)。如果两者都没有出现,那么该表达式不会被扩展。

默认情况下,含有历史扩展的命令在执行之前会先把扩展后的结果打印出来。但是我比较建议如下配置:

1
2
3
4
# 按空格键时自动进行历史扩展
bindkey " " magic-space
# 不直接执行历史扩展的结果
setopt hist_verify

这样可以最大程度避免因为执行了错误的历史命令而导致损失。

默认情况下,一条不包含条目标识符的历史扩展会在历史记录中寻找任何与之前缀匹配的历史记录。

1
2
3
4
5
6
echo hello
hello
❯ print hi
hi
❯ !print # => print hi
❯ !echo  # => echo hello

形如 ^foo^bar 形式的表达式则会将上一条命令中的 foo 替换为 bar 并执行。它等效于 !!:s^foo^bar^,最后一个 ^ 后面还可以跟其他的修饰符,比如用 ^foo^bar^:G 来进行全局替换。

如果输入的命令中含有 !" 的话,历史扩展会被临时禁止,所有的 ! 不再有特殊含义。

1
2
3
4
echo "!233"
zsh: no such event: 233
❯ !" echo "!233"
!233

条目标识符

条目标识符用于在历史记录中指定一条历史记录。以下为可用的事件标识符:

标识符 含义
! 开始一个历史扩展表达式,除非后面是空格、换行、"=" 和 “("。
!! 上一条命令
!n 编号为 n 的命令
!-n 编号为倒数第 n 条的命令
!str 以 str 开头的最近一条命令
!?str[?] 包含 str 的最近一条命令。最后一个 ? 起分隔作用,可以省略。
!# 当前输入的字符串
!{...} 起分隔作用,内部可以放其他标识符。作用类似 ${variable} 中的大括号。

词标识符

标识符 含义
0 第一个词(我们假设它是命令
n 该命令的第 n 个参数
^ 第 1 个参数
$ 最后一个参数
% 最近一个 ?str 匹配的词
x-y 指定一个系列词的范围
* 所有参数
x* 等效于 x-$
x- x* 类似但不包括最后一个词

注意 % 标识符只能以 !%!:%!?str?:% 的形式使用,并且要求先前已经使用过了 !? 表达式。

修饰符

拉低 zsh 脚本可读性但是非常有用的功能之一。

修饰符可以用在历史扩展中的词标识符之后,并且可以以 :x 的形式多次使用。修饰符也应用于文件名扩展(filename generation)和参数扩展(parameter expansion)。(文件名扩展其实就是大家熟悉的 glob,想不到怎么翻译才好,就叫文件名扩展吧,懂这个意思就行

先让我们举几个例子熟悉一下用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 用于历史扩展,取上一条命令的最后一个参数,并作为路径处理,只保留文件名部分。
❯ ls ~/abc/def.xyz 
❯ !$:t # => def.xyz

# 用于参数扩展,将展开结果中转为大写形式
var=abc
echo ${var:u} # => ABC

# 用于文件名扩展,只保留展开结果中的文件名部分
❯ /usr/*(:t)  # => bin etc include ...

很简单,但也很强大。以下为完整的修饰符列表:

修饰符 含义
a 展开为绝对路径,将会移除 ...。注意这个展开不会检测路径是否有效。
A 展开为绝对路径,同时用 realpath(3) 库函数展开符号链接。
c 在 PATH 中搜索并展开为该命令的绝对路径
e 只保留文件扩展名
h 只保留路径(类似 dirname
l 转为小写
p 只打印而不必执行命令。仅用于历史扩展。
P A 功能相同,但是语义和 realpath(3) 更一致
q 将展开结果中的每一个词转义。仅用于历史扩展和变量扩展。
Q 移除一层转义
r 移除文件扩展名
s/l/r[/] 将 l 替换为 r。对于数组和文件展开,这个替换会应用到每个元素上。默认只替换第一个,可以使用 gs/l/rs/l/r/:G 来进行全局替换。
& 重复先前的 s 替换
t 去除路径,只保留文件全名。类似 basename
u 转为大写
x 和 q 类似,不过是按空格分割词。不适用于变量扩展

s/l/r/ 是个有点复杂的玩意儿,在这里详细介绍一下:

首先它的工作方式如下:

  • 默认情况下左侧的字符串不会被解释为模式,而是纯字符串
  • / 可以替换为其他字符,但需要前后保持一致(这点和 sed 类似)
  • 右侧可以用 & 来指代前面匹配到的字符串(这点也和 sed 类似)
  • l 可以为空,此时会使用先前的 l 或者 !?str 匹配到的字符串
  • 最后一个 /!?str[?] 中的最后一个 ?类似,在不会引起歧义的情况下可以省略。

其次,展开顺序会影响 lr 的效果。

当用于历史扩展时,因为历史扩展优先级最高,所以 lr 会作为普通字符串(除非开启了下面两段提到的特殊开关)。

当用于参数扩展时,r 首先被替换到展开结果中,然后展开结果再执行其他的算术展开、大括号展开等等,这意味着 r 的内容如果含有可执行代码的话,它可能会被执行多次。举例如下:

1
2
3
4
n=0
var="aaa"
echo ${var:gs/a/$((n+=1))}
123

当用于文件名扩展时,则是反过来了——其他扩展的优先级是最高的,甚至高于 :s 的解析过程。

除此之外,如果设置了 HIST_SUBST_PATTERN 开关,l 会被识别为模式而不是纯字符串。而且 l 可以以 #/% 开头来限定只能匹配字符串的开头/结尾。两者可以也一起使用。

剩下还有一些仅适用于参数扩展和文件名扩展的修饰符,

修饰词 含义
f 反复应用后面紧跟着的修饰符(中间不需要加 :),直到结果不再改变
F:expr: f 类似但是只重复最多 expr 次。expr 是一个可以进行运算的表达式
w 让后面紧跟着的修饰词作用于字符串中每个词而不是整个字符串
W:sep: w 类似,但是允许指定词分隔符。这里的 : 可以替换为其他字符,但要前后保持一致。