emacs-在elisp中(伪)调用正则的交互式特性
Emacs 用户常常会用到强大的交互式替换命令,如 replace-regexp
和 query-replace-regexp
。除了基本的文本替换和捕获组反向引用(如 \&
和 \1
)之外,交互式替换还支持两个鲜为人知但功能强大的特殊序列:
\,(...)
:允许在替换字符串中执行任意 Elisp 表达式,并将其结果插入。\#
:代表当前是第几次替换,从 0 开始计数。
然而,这些便利的特性,在 Elisp 编程中通过非交互式代码调用时,却无法直接使用。因此我通过以下两个自定义函数,部分实现了调用正则的交互式特性。
@Kana 在回帖中指出,可以直接用以下函数达到相同效果,还可以沿用 \,(...)
的格式:
(defun my/replace-regexp (regexp replacement)
"`replace-regexp', non-interactively."
(perform-replace
regexp
(query-replace-compile-replacement replacement t)
nil t nil))
经测试确实可行,还可以沿用 \,(...)
的求值格式,下面两个函数可忽略。
1. 求值函数 my/eval-lisp-in-string
这个函数是实现 \,(...)
功能的核心。它的任务是在一个字符串中找出所有 ,(...)
形式的表达式,对括号内的 Lisp 代码求值,然后用求值结果替换掉 ,(...)
本身。
(defun my/eval-lisp-in-string (str)
"在字符串 STR 中查找所有 `,(...)` 形式。
对括号内的 S-表达式求值,并用其结果替换原始形式。"
(with-temp-buffer
(insert str)
(goto-char (point-max)) ; 从缓冲区末尾开始
(save-match-data
;; 从后向前搜索,这样替换操作不会影响后续搜索的位置
(while (re-search-backward ",(" nil t)
(let* ((match-start (match-beginning 0)) ; "," 的位置
(sexp-start (match-end 0))) ; "(" 的位置
;; 使用 save-excursion 来查找 S-表达式的结束位置,而不会永久移动光标
(save-excursion
(goto-char sexp-start) ; 移动到 "(" 之后
(backward-char) ; 移动到 "(" 之上
(let* ((sexp-string ; 提取 S-表达式的字符串
(buffer-substring-no-properties
(point) ; S-表达式的起始点
(progn (forward-sexp) (point)))) ; S-表达式的结束点
(match-end (point)) ; 记录下 S-表达式的结束位置
(replacement-text ; 计算替换用的文本
(condition-case err ; 捕获可能发生的错误
;; 核心求值逻辑
(format "%s" (eval (car (read-from-string (format "(progn %s)" sexp-string)))))
;; 如果求值出错,将错误信息作为替换文本
(error (format "<求值错误: %s>" (error-message-string err))))))
;; 执行替换操作
(delete-region match-start match-end)
(goto-char match-start)
(insert replacement-text))))))
;; 返回整个缓冲区被修改后的最终结果
(buffer-string)))
代码逻辑:
- 函数利用
with-temp-buffer
创建一个临时的工作区,避免污染当前缓冲区。 - 通过
re-search-backward
从字符串末尾开始搜索,(
, 这样做的好处是,每次替换后,不会影响下一次搜索的起始位置。 - 找到
,(
后,使用forward-sexp
来精确定位匹配的)
,从而提取出括号内完整的 Lisp 代码字符串。 - 核心的求值操作通过
eval
和read-from-string
完成。 - 最后,将原始的
,(...)
文本删除,并在原位置插入求值后的结果字符串。
2. 替换函数 my/replace-regexp
现在,我们构建 my/replace-regexp
函数。它会从光标开始循环搜索给定的正则表达式,并对每一次匹配,处理替换字符串中的所有特殊序列,最后完成替换。
(defun my/replace-regexp (regexp rep)
"替换字符串 (REP) 支持以下特殊序列:
\\& - 代表整个匹配到的文本。
\\1, \\2 ... - 代表正则表达式中第N个捕获组匹配到的文本。
,(...) - 括号中的内容会被当作 Lisp 表达式来求值,其结果会作为替换内容。
\\# - 代表这是第几次替换(从1开始计数)。
例如:
- 用 `foo(\\d+)` 替换 `bar\\1` 会将 `foo123` 变为 `bar123`。
- 用 `\\w+` 替换 `\\,(upcase \\&)` 会将所有单词转为大写。
- 用 `^` 替换 `\\# ` 会在每一行的行首插入行号。"
(let ((count 0))
(while (re-search-forward regexp nil t)
(let* ((rep-with-count
(replace-regexp-in-string "\\\\#" (number-to-string count) rep t t))
(rep-with-args
(substring-no-properties (match-substitute-replacement rep-with-count)))
(rep-with-func
(my/eval-lisp-in-string rep-with-args)))
(replace-match rep-with-func t))
(setq count (1+ count)))))
代码逻辑:
while
循环和re-search-forward
会从光标处开始,遍历当前缓冲区中所有匹配regexp
的地方- 在进入核心处理逻辑之前,首先使用
replace-regexp-in-string
将替换模板rep
中的\\#
替换成当前的计数值count
。 match-substitute-replacement
函数处理上一步得到的rep-with-count
中\\&
(整个匹配)和\\N
(捕获组)。- 将处理完其他序列的字符串交给
my/eval-lisp-in-string
函数,完成 Lisp 表达式,(...)
的求值和替换。 - 调用
replace-match
将最终的替换字符串rep-with-func
应用到缓冲区,完成单次匹配的替换。 count
在每次循环后加一,为下一次处理\\#
做好准备。
注意,这里如果我们沿用交互式正则替换的符号,用 \\,(...)
来引导表达式,则 match-substitute-replacement
函数会报错,故改用 ,(...)
3. 使用例
假设我们缓冲区中有以下内容:
hello world
foo123
bar456
3.1. 示例 1:小写转大写
假设我们想将缓冲区中所有英文字母转换为大写。
(with-temp-buffer
(insert "hello world
foo123
bar456")
(goto-char (point-min))
(my/replace-regexp "[a-z]+" ",(upcase (symbol-name '\\&))")
(buffer-string))
运行结果:
HELLO WORLD
FOO123
BAR456
注意, \\&
会被 match-substitute-replacement
替换为匹配到的英文字母本身,是一个符号。所以需要通过 (symbol-name '\\&)
将其转化为其他函数可识别的字符串。
3.2. 示例 2:为每一行添加行号
(with-temp-buffer
(insert "hello world
foo123
bar456")
(goto-char (point-min))
(my/replace-regexp "^" "\\# ")
(buffer-string))
运行结果:
0 hello world
1 foo123
2 bar456
my/replace-regexp
会在每一行的开头进行匹配。第一次匹配时, \\#
被替换为 0
;第二次为 1
,以此类推,轻松实现行号的添加。
为保持与交互式函数的一致性,这里计数依然从0开始。需要计数从1开始,请看下一个例子。
3.3. 示例 3:数学运算
我们希望行数从1开始。
(with-temp-buffer
(insert "hello world
foo123
bar456")
(goto-char (point-min))
(my/replace-regexp "^" ",(1+ \\#) ")
(buffer-string))
运行结果:
1 hello world
2 foo123
3 bar456
3.4. 示例 4: 综合示例
我们想将数字都乘以2再加上1000乘以替换次数的值。
(with-temp-buffer
(insert "hello world
foo123
bar456")
(goto-char (point-min))
(my/replace-regexp "\\([0-9]+\\)" ",(+ (* 1000 \\#) (* 2 \\1)) ")
(buffer-string))
运行结果:
hello world
foo246
bar1912
这个例子综合了多种特性:
\\#
被替换为匹配次数,从0开始。- 在
,(* 2 \\1)
表达式中,\\1
被match-substitute-replacement
替换为捕获到的数字符号。 - Lisp 代码
(+ (* 1000 \\#) (* 2 \\1))
完成计算。