UP | HOME

▼ 本文更新于 [2025-09-26 五 22:39]

emacs-在elisp中(伪)调用正则的交互式特性

Emacs 用户常常会用到强大的交互式替换命令,如 replace-regexpquery-replace-regexp 。除了基本的文本替换和捕获组反向引用(如 \&\1 )之外,交互式替换还支持两个鲜为人知但功能强大的特殊序列:

  1. \,(...) :允许在替换字符串中执行任意 Elisp 表达式,并将其结果插入。
  2. \# :代表当前是第几次替换,从 0 开始计数。

然而,这些便利的特性,在 Elisp 编程中通过非交互式代码调用时,却无法直接使用。因此我通过以下两个自定义函数,部分实现了调用正则的交互式特性。

[2025-09-26 五 22:35]@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)))

代码逻辑:

  1. 函数利用 with-temp-buffer 创建一个临时的工作区,避免污染当前缓冲区。
  2. 通过 re-search-backward 从字符串末尾开始搜索 ,(, 这样做的好处是,每次替换后,不会影响下一次搜索的起始位置。
  3. 找到 ,( 后,使用 forward-sexp 来精确定位匹配的 ) ,从而提取出括号内完整的 Lisp 代码字符串。
  4. 核心的求值操作通过 evalread-from-string 完成。
  5. 最后,将原始的 ,(...) 文本删除,并在原位置插入求值后的结果字符串。

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)))))

代码逻辑:

  1. while 循环和 re-search-forward 会从光标处开始,遍历当前缓冲区中所有匹配 regexp 的地方
  2. 在进入核心处理逻辑之前,首先使用 replace-regexp-in-string 将替换模板 rep 中的 \\# 替换成当前的计数值 count
  3. match-substitute-replacement 函数处理上一步得到的 rep-with-count\\& (整个匹配)和 \\N (捕获组)。
  4. 将处理完其他序列的字符串交给 my/eval-lisp-in-string 函数,完成 Lisp 表达式 ,(...) 的求值和替换。
  5. 调用 replace-match 将最终的替换字符串 rep-with-func 应用到缓冲区,完成单次匹配的替换。
  6. 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 

这个例子综合了多种特性:

  1. \\# 被替换为匹配次数,从0开始。
  2. ,(* 2 \\1) 表达式中, \\1match-substitute-replacement 替换为捕获到的数字符号。
  3. Lisp 代码 (+ (* 1000 \\#) (* 2 \\1)) 完成计算。

© Published by Emacs 31.0.50 (Org mode 9.8-pre) | RSS 评论