psearch: 基于 pcase 的 elisp 代码搜索工具

基于 pcase 的 elisp 代码搜索工具包。有了它就可以更方便实现 elisp 代码的查找/替换/重构了。

本包最初构想来自于之前写的一个函数 pcase-refactor (详见链接[2])。虽然没有 el-search 那么强大,实现上也简陋,但是更容易使用。目前提供的三个借口 forward / backward / replace 都是可以以 programmatically 方式使用的,psearch-replace 函数本身就是一个例子。

虽然 el-search 很强大,但我一直没找到如何通过编程的方式使用它。它的代码很复杂,主要的逻辑放在一个巨大的函数中,想扩展无从下手。虽然可以把常用的查找 → 替换 pattern 定义成 semi-automatic 形式,但也只是减少了一些输入量(作用有限,相当于别名),终究不是 programmatically,仍然需要一遍一遍回答 Yes/No。

相关讨论:


补充:

包名 psearch 来自于第一次见到 el-search 时的感叹:

7 个赞

用 psearch 重写上边链接[2]的 pcase-refactor-at-point

pcase-refactor-at-point 有个非常明显的缺点,就是光标必须位于 sexp 头部

  • |(sexp) :white_check_mark:
  • (se|xp) :no_entry:

然而大多数时候我们可能正处于 sexp 内部,想要重构就得先移动光标。如果用 psearch 重写就可以省却移动光标的麻烦:

(defun psearch-refactor-setq-local-at-point ()
  (interactive)
  (psearch-search-at-point '`(set (make-local-variable ',sym) . ,val)
                           '`(setq-local ,sym ,@val)))

;;        修改前                        修改后
;;    |(match a (b c))     =>     |(replace a (b c))
;;     (match a (b|c))     =>     |(replace a (b c))

这样无论光标处于 |(sexp) 还是 (se|xp) 都能应对。同时也保证不会无限 backward,改到其他的内容。

另一个衍生项目的名称已经想好想了:pefactor (谐音 perfact,其中 p 又表示 pcase/psearch)

借鉴大佬 @wilfredGitHub - Wilfred/emacs-refactor: language-specific refactoring in Emacs 包。探查光标所在位置匹配的 refactor 操作。比如当前处于 let 语句中:

(let ((a 1)
      (b (1+ a|)))
  ...)

此时呼叫 refactor,只会列举匹配的操作 let -> let*,而不会出现 when -> unless 的重构操作。比从 M-x 万千函数中查找一个 refactor-xxx 要便捷得多。

基于 pcase 的优点是查找定位方便,pattern 直观易写。缺点是得到的替代项是重新打印格式化过的,会破坏原有的代码排版。如果这个问题解决了,就真的 perfact 了。

写了一对 setq 合并/拆分的函数:

(defun psearch-refactor-combine-setq-in-region (beg end)
  (interactive "r")
  (save-excursion
    (save-restriction
      (narrow-to-region beg end)
      (goto-char (point-max))
      (unwind-protect
          (let ((sym-val-list nil))
            (while (psearch-backward
                    '`(setq ,sym ,val)
                    '`(,sym ,val)
                    (lambda (result _bounds)
                      (setq sym-val-list (append result sym-val-list)))))
            (delete-region beg end)
            (insert (psearch--print-to-string `(setq ,@sym-val-list))))))))

拆分函数的 parttern 直接取自一楼链接[1]中 @xuchunyang 的代码例子,增加了 filter 部分用于消除拆分后最外层的多余 ()

(defun psearch-refactor-split-setq-at-point ()
  (interactive)
  (psearch-replace-at-point '`(setq . ,(and r (guard (> (length r) 2))))
                            '(->> (psearch--print-to-string
                                   (mapcar
                                    (lambda (pair)
                                      (cons 'setq pair))
                                    (seq-partition r 2)))
                              (string-remove-prefix "(")
                              (string-remove-suffix ")"))))

不知 el-search 如何消除外层括号?


EDIT: 其实可以直接在 pattern 内部完成去 () 的步骤(原先使用的 :filter 关键字已不再提供),el-search 应该也一样。不过这样会使得 pattern 变臃肿。

有个 splice 模式

比如把 (setq a 1 b 2) 改成 (setq a 1) (setq b 2),替换的时候按 s

`(setq ,s1 ,v1 ,s2 ,v2) -> `((setq ,s1 ,v1) (setq ,s2 ,v2))

C-h f el-search-query-replace

It is possible to replace matches with an arbitrary number of expressions (even with zero expressions, effectively deleting matches) by using the “splicing” submode that can be toggled from the prompt with “s”. When splicing mode is on (default off), the replacement expression must evaluate to a list, and all of the list’s elements are inserted in order.

1 个赞

也考虑加入对 splice (对这个单词的字面意思和它在 el-search 中的涵义感到有点困惑) 的支持。

同时也内置一个与之相反的【合并】操作。这样在二次封装的时候就可以少些很多诸如 save-excursion 以及手工打印的步骤。

对于这个合并的 pattern 应该怎么写还没有思路,以合并多个 setq 为例。匹配每一项的规则是 `(setq ,sym ,val),合并规则不能立即应用,必须等到收集完所有匹配项之后再应用,此时合并规则中已无法引用 sym/val 变量,因为已经跳出上下文环境了。


EDIT1: 暂且通过增加一个 collect 概念来实现合并操作吧。

(psearch-replace '`(setq ,sym ,val)
                 ;;  collect   ->  replace (at the end)
                 '(`(,sym ,val) `(setq ,@(-flattern-n 1 its))))

当第二个参数是一对 pattern 的时候,前半部分就变成 collect (原本单个的时候是立即执行 replace)操作,最终由后半部分 pattern 来完成替换操作,其中 its 表示收集到的内容列表。


EDIT2: 引入 collect 带来的新问题:如果每个匹配项之间不是连续的,就不能删除 (beg end) 之间的整块空间,只能删除每个匹配项的 bounds 区域。面临几种选择:

  1. 删除 (beg end) 之间的内容,不考虑非连续的情况
  2. 支持不连续的场景,只删除 bounds 区域,但也留下删除后的大量空洞。
  3. 给个 custom 变量,让用户决定 1 还是 2。

,@ 就是 unquote-splicing

1 个赞

实现了函数热补丁功能。适用于不便 advice 又不想改源码的场景,例如:一个巨长的函数,你只想改中间一两句。

el-patch 更简便易用,没那么多复杂的概念,心智负担更小,只要会有用 pcase 就能理解。

我自己先用几天再发布。

2 个赞

添加了命令行 cli/psearch,可以直接在命令行输入想要匹配的模式进行查找:

image

:warning:文件多了速度有点慢。

1 个赞

这是包里带的例子,用 psearch-replace 把 nil 变成 t 成功了。

但如果反过来把 t 变成 nil,就会失败。不知道是不是我使用得不对?

(psearch-with-function-patch test-patch
  (psearch-replace '`(if nil ,body)
                   '`(if t ,body)))
;; (defun test-patch ()         =>    (defun test-patch ()
;;   (list '(1 2 3)                     (list '(1 2 3)
;;         (if nil '(4 5 6))                  (if t '(4 5 6))
;;         '(7 8 9)))                         '(7 8 9)))

测试过程:

  1. 对象函数:
(defun test-patch ()
  (list '(1 2 3)
        (if t '(4 5 6))
        '(7 8 9)))
  1. 执行替换
(psearch-replace '`(if t ,body) '`(if nil ,body))
  1. 替换结果及光标位置:

image


环境:emacs-plus@30, 昨天编译; macOS BigSur 11.6。


更新:刚找到个workaround, 用 (null t) 替换 nil 可行。

补充个使用 psearch 给 elisp 缩进打补丁的办法:

  1. 缩进问题:Wrong indent for :bind · Issue #887 · jwiegley/use-package · GitHub
  2. 参考方案:indentation - How to indent keywords aligned? - Emacs Stack Exchange (最高赞回答,回答的作者表示要给上游发补丁,但一直没打?)
(with-eval-after-load 'lisp-mode
  (require 'psearch)
  (psearch-patch calculate-lisp-indent
    (psearch-replace
     '`(if (or ,or1 ,_) ,then ,else)
     '`(if (or ,or1
            ;; First sexp after `containing-sexp' is a keyword. This
            ;; condition is more debatable. It's so that I can have
            ;; unquoted plists in macros. It assumes that you won't
            ;; make a function whose name is a keyword.
            (when-let (char-after (char-after (1+ containing-sexp)))
             (char-equal char-after ?:))

            ;; Check for quotes or backquotes around.
            (let* ((positions (elt state 9))
                   (last (car (last positions)))
                   (rest (reverse (butlast positions)))
                   (any-quoted-p nil)
                   (point nil))
             (or
              (when-let (char (char-before last))
               (or (char-equal char ?')
                (char-equal char ?`)))
              (progn
                (while (and rest (not any-quoted-p))
                 (setq point (pop rest))
                 (setq any-quoted-p
                  (or
                   (when-let (char (char-before point))
                    (or (char-equal char ?')
                     (char-equal char ?`)))
                   (save-excursion
                     (goto-char (1+ point))
                     (looking-at-p
                      "\\(?:back\\)?quote[\t\n\f\s]+(")))))
                any-quoted-p))))
           (null t) ;; 这儿本应使用 ",then", 但此处 ",then" 值为 nil,
                    ;; 而 psearch 对应的 nil 功能是删除该匹配,所以用
                    ;; (null t) 替代
         ,else))))

打完补丁前

`(:token ,token
         :token-quality ,quality)  , 
(use-package org
  :bind (:map org-mode-map
              ("C-c C-'" . org-edit-special)
              :map org-src-mode-map
              ("C-c C-'" . org-edit-src-exit)))

打完补丁后

`(:token ,token
  :token-quality ,quality) . 
(use-package org
  :bind (:map org-mode-map
         ("C-c C-'" . org-edit-special)
         :map org-src-mode-map
         ("C-c C-'" . org-edit-src-exit)))

psearch-patch 目前版本的副作用是,打完补丁后的原函数的位置找不到了:

(symbol-file 'calculate-lisp-indent)
;; >> nil

这个问题我也注意到了。因为 nil 存在歧义,目前没有想到好的解决方案。

因为是在一个临时的 buffer 里进行 patch 和 eval,所以位置信息丢失了,这个好解决。

另,缩进问题可以看看:

2 个赞

原来你们已经打了包了,我用上了。 :grinning:

嗯,盯着nil的值就好了,我先用 (null t) 绕一下。

psearch 是真的好用啊,无痛打补丁,打了好几个复杂的功能了,感谢大佬

这个包真是宝藏!非常简单方便的打补丁。之前研究了一段时间el-patch,实在没看出来跟直接重写一遍函数有什么区别。。。

我希望能一次性改动多个函数,但我不知道在dolist里面该怎么用psearch-patch。。请问有什么办法解决这个吗

(dolist (func '(eglot--TextDocumentItem
                eglot--signal-textDocument/didSave
                eglot--signal-textDocument/didChange))
        (psearch-patch 这里不知道该写什么
          (psearch-replace '`(buffer-substring-no-properties (point-min) (point-max))
                           '`(zw/buffer-content (point-min) (point-max)))))

请问下面这个是特性还是Bug呀?

如果执行:

(psearch-replace '`(foo . ,args)
                 '`(bar () ,@args))

那么 (foo 'a 'b) 会变成 (bar ),而不是 (bar () 'a 'b)

是 Bug,已经在 Fix psearch--prin1 · twlz0ne/psearch.el@25e7dc8 · GitHub 修复了。

其实是先前 #10 一直悬而未决的问题。

不过仍然要注意的是,() 会被转为 nil (两者等价),如果想要保持 () 原样输出,可以用转义符号:

(psearch--print-to-string '(bar nil 'a 'b))  ;; => "(bar nil 'a 'b)"
(psearch--print-to-string '(bar () 'a 'b))   ;; => "(bar nil 'a 'b)"
(cl-prin1-to-string       '(bar () 'a 'b))   ;; => "(bar nil 'a 'b)"
(psearch--print-to-string '(bar \(\) 'a 'b)) ;; => "(bar () 'a 'b)"
2 个赞

试一下:

(eval `(psearch-patch ,func

最近用 psearch-patch 发现了一个新的问题:

有些函数并不是用 defun 宏定义的,而是用其他的宏,比如在包 doom-modeline 中,用 doom-modeline-def-segment 宏来定义一些函数,而这个宏的第一个参数在宏展开后,其实只是函数名的一个后缀:

(doom-modeline-def-segment TEST "doc" 'nil)

展开为

(defun doom-modeline-segment--TEST nil "doc" 'nil)

而在 psearch-patch 内部所调用的宏 psearch-patch-define 中,会替换掉这个“函数名”:

;; Modifiy function name
(unless (eq ',name (nth 1 func-def))
  (setcdr func-def (cons ',name (nthcdr 2 func-def))))

导致最终执行的(延续上面的例子)实际上是:

(doom-modeline-def-segment doom-modeline-segment--TEST "doc" 'nil)

因而最终定义的函数有了双重前缀:doom-modeline-segment--doom-modeline-segment--TEST

请问这个有什么好的办法吗?

抱歉。我很久没摸电脑了。有点反应不过来。 能展示一下你的 patch 代码怎么写的吗?