Puni: 通用、可定制的语法删除

Parentheses Universalistic!

创作动机

相信很多 Emacser 编程的时候都离不开 paredit 或类似插件。我在之前的几年中试过两个这类插件:

  • awesome-pair:

    • 优点:在很多编程语言,特别是 HTML 模板中表现非常好。

    • 缺点:部分行为不是很符合我的习惯,而且缺少软删除一个单词的命令。

  • smartparens:

    • 优点:支持的语言很多,而且有 sp-forward/backward-kill-word,这样就覆盖了日常用的删除一行/一个词/一个字的命令,而且在删除选区的时候也会做判断,基本保证了怎么都不会搞坏语法。

    • 缺点:在多年的架构变迁中,HTML 相关的功能慢慢坏掉了,并且一直都没有人修,导致基本无法在 web-mode 中使用。其他的工作也积压了很多而不去处理。我认为这体现出这个项目的状态不是很健康。

经过长期使用,我发现我只需要两个功能:

  1. 自动配对括号
  2. 软删除一个字、一个词、一行、一个选区等的命令。

其他那些操作表达式的花哨命令(slurp、barf 之类)就无所谓了。Emacs 内置的 electric-pair-mode 已经解决了 1,所以我就做了 Puni 来解决 2。

项目地址:GitHub - AmaiKinono/puni: Structured editing (soft deletion, expression navigating & manipulating) that supports many major modes out of the box.

效果展示

  • 在 Lisp 中软删除行:

    lisp

  • 在 LaTeX 文档中软删除行:

    tex

  • 在 HTML 模板中软删除行(web-mode):

    web

  • 在 shell 脚本中软删除行(sh-mode):

    shell

    注意 Puni 知道那个右括号其实不需要配对,所以很愉快地把它删了。

定制性

所有 Puni 的内置命令都是用 puni-soft-delete-by-move 这个 API 定义的。它很容易使用,你可以用它定制符合自己使用习惯和品味的软删除命令。

我们以 Lisp 为例,| 表示光标位置:

|foo (bar 
      baz)

你可以定制一个「软删除一行」的命令,里面像这样调用 API:

(puni-soft-delete-by-move
 #'end-of-line 'strict-sexp 'within 'kill 'delete-one)

它的意思是:软删除到 end-of-line 会把我们带到的位置,但不要超过它(within),删除的部分存到 kill-ring 中(kill)。如果在 end-of-line 之前没有完整的表达式可以删,那就删掉一个表达式 (delete-one)。

敲一次以后变成:

|(bar
      baz)

再敲一次以后删干净。

或者也可以换换风格:

(puni-soft-delete-by-move
 #'end-of-line 'strict-sexp 'beyond 'kill)

beyond 表示「删到 end-of-line 或着超过它的位置」,这样就可以一次删干净。

又例如:

baz (foo bar)|

你可以定义不同风格的「向后删除一个单词」的命令,可以做到下面所示的所有效果:

baz (foo |bar)
baz (foo |)
baz |

详细请读 README。

通用性

Puni 最大的秘密是它没有包含任何与特定语言相关的逻辑

也就是说,我没有为你刚刚看到的那些 web-mode, tex-modesh-mode 中的神奇效果写任何代码。

那么 Puni 是怎么工作的呢?事实上,Emacs 内建了 forward-sexp 函数。理想地,这个函数会带我们前进(或者后退,如果参数是 -1)一个平衡表达式,并且在无法继续移动(碰到表达式边界)时停下来。于是,我们可以很轻松地知道不破坏语法的那个边界在哪里,Puni 的关键问题就解决了。

Major mode 可以通过定制 forward-sexp-function 来提供它们自己的 forward-sexp。不幸的是,这些函数的实现千奇百怪,很多都只能部分地工作。最显著的问题就是它们可能只会寻找「下一个平衡表达式的开始/结束」,而不管从这里是不是能到达那个地方。比如在 web-mode 中:

<|a href="example.com">click me</a>
<!-- 使用 `forward-sexp` -->
<a href="example.com">|click me</a>

可以看到它直接找到了 tag 的结尾。而且从光标到这个地方需要跨过一层尖括号,所以实际上是不能删除到那里的。

Puni 的主要工作是以系统、通用的方式修正了它们的行为,通过:

  • 连续在不同方向上调用 forward-sexp
  • syntax table
  • 常识
  • 不做语言特定的处理

基于此,Puni 提供了一对通用的 puni-strict-forward-sexppuni-strict-backward-sexp 函数,这些函数和我们期望的一样:只会跨过面前的平衡表达式,在不能继续前进时会停下来。Puni 就是在这两个函数上建造起来的。

由于 Puni 采用了这样一种通用的方案,它自动支持许多 major mode。只要 major mode 提供的 forward-sexp-function 不算太差,Puni 都可以工作。在最差的情况下,major mode 没有提供这个函数,那么 Puni 会用 Emacs 内置的那个,可以在 ()[]{} 这样的括号以及字符串中正常工作,这对很多语言来说也很够用了。

结语

我在这篇帖子里讲了很多枯燥的技术细节。所以如果你能读到这里,感谢你 :wink:

不过话说回来,它们也并不是全无必要。因为 Puni 和所有已有的插件都不一样,我希望能清楚地说明它们究竟有何不同之处,这样可以吸引和我有相同品味的人来用 Puni。

我已经认识到我编程时需要的核心工具就是两个:一个帮我理解代码,一个帮我结构化地编辑代码。这两件事我都用自己的轮子解决了,Citre 解决了前者,Puni 解决了后者。所以我现在感觉很开心 :wink:

25 个赞

哇,看着好棒!不过似乎只提供了删除和移动两个选项?可否提供一个 mark 的选项,这样似乎可以更通用一些(比如只复制,或针对选中内容执行函数之类的)

//(具体实现细节还没看,可能是个傻问题)

我个人需要 mark 的时候,就走到一个位置,按一下 set-mark-command,然后用我的 puni-forward/backward-sexp 走到另一个位置。可能开手动档开惯了,对一键 mark 类命令不是太感冒。

我希望我正确地理解了你的意思。我觉得这种命令有个问题就是,假如我在这按一下 mark 当前 sexp 的命令:

<|p>something</p>

我是希望选中那个 p 呢,还是那个 opening tag,还是这一整行?首先这个歧义就是个问题,其次是基于 Puni 的架构应该只能做到选中 p

我现在的一个心愿就是找个好方法替代掉 expand-region :rofl:,所以针对你的问题,我觉得两种逻辑都行:

①每按一次,扩大一下。

②做成一个 keymap ,比如 m 进入这个 keymap ,按 j 选择括号,k 选择方括号之类的。

expand-region 我其实也用过,后来不用了,选一个东西要按好多次,还不如手动挡爽。

这个 Puni 是能做出来的,并不复杂,只是我不太喜欢 :rofl: 我可以给你写一个然后放到 wiki 里。

1 个赞

好耶!感谢感谢。

昨天还寻思楼主怎么创建了一个空的 repo

觉得楼主和狗哥的插件都非常好用!

代码:Home · AmaiKinono/puni Wiki · GitHub

效果:

expand-region-1

expand-region-2

2 个赞

太牛了!这么快。

另外我还顺便想问个其他的问题:

(foo (bar| a) b)

如果我想做的 kill-line 是第一次删除 a ,再按时如果没有可以删的了,就把前面的 bar 给删了,也即 (foo () b)

(如果能把空括号也删了就更好。)

感觉直观上想实现这个,是加一个 delete-onefail-action :rofl:,或者就是执行命令前,先看下是否当前 pointpuni-strict-backward-sexp 一样,如果一样,就往前删。大佬觉得如何实现比较好?

你读文档的速度也很快 :wink:

其实我理解你想要的是失败的话就执行 puni-backward-kill-line,这个已经不是「call 一次 puni-soft-delete-by-move」这个层面上的事情了。应该这么写:

(defun my-kill-line ()
  "Kill a line forward while keeping expressions balanced.
If nothing can be deleted, kill backward."
  (interactive)
  (if (use-region-p)
      (puni-kill-active-region)
    (let ((forward-line (lambda ()
                          (if (eolp) (forward-line) (end-of-line))))
          (backward-line (lambda ()
                           (if (bolp) (forward-line -1) (beginning-of-line)))))
      (and
       (or (puni-soft-delete-by-move forward-line 'strict-sexp 'beyond 'kill)
           (puni-soft-delete-by-move backward-line 'strict-sexp 'beyond 'kill))
       (when (not (puni--line-empty-p))
         ;; Sometimes `indent-according-to-mode' causes the point to move, like
         ;; in `markdown-mode'.
         (save-excursion (indent-according-to-mode)))))))

删空括号这个有点难,暂时没想到做法,而且我发现 puni-expand-region 在一对空括号里面不工作。

原来如此,学习到了 :+1:

关于判定空括号,可否用 puni--bounds-of-sexp-at-point 判断返回的 begend 是否相等来实现?然后在 my-kill-line 最后加个 (when (empty-sexp-p) (delete-region …)) 之类的东西,不过如果是多个符号组成的 pair 不知道会不会有问题。

我已更新了 wiki,解决了空括号的问题。

你想要的 puni-kill-line 删除空括号的效果,应该可以这样:尝试向两边 kill 均未果以后,说明光标在空括号里面,此时用 (puni--find-bigger-balanced-region (point) (point)) 获取空括号开始和结束的位置,并且 kill 掉它。

我尝试在 web-mode 中,这样的情况下按下 puni-expand-region

<p>|</p>

可以选中整对 tag,看来应该是没有问题。

有道理,感谢!

forward-sexp 很多时候是不够用的,特别是一些奇奇怪怪的语言,如果不平衡的时候很不好用。

awesome-pair 的主要场景是在 Lisp, 比如命令 awesome-pair-jump-out-pair-and-newline, 简直就是及拯救括号语言编写的利器。

当然 awesome-pair 也花了很多力气在 Web-Mode 的支持上,比如可以智能的识别 vue.js 这种混合 js/css/html 的场景。

Emacs内置的 sexp 其实非常脆弱。

这种简单的两个 tag 内部的情况都好处理,web-mode 其实最棘手的是很多JS框架,比如 vue.js 有这几层:

  1. 最外部的 tag
  2. 里面的vue属性
  3. vue属性里面还有语法
  4. vue语法里面还有字符串属性

在这些复杂场景下,能够识别语意的一个单词一个单词的删除要写很多特定场景的分析代码。 而在Tag内部属性的场景下, Emacs内置的 sexp 完全没法工作。

简单来说, Emacs内置的函数无法处理复杂的JS语法,通过手动选中删除太痛苦了,没有生产力。

expand-region其实够用了, 那么问题来了:最关键的问题还是小指按ctrl的问题, 这种只能靠换按键来完成, 也就是, 与其用小指按ctrl, 还不如换成大拇指按alt的位置, 小拇指会疼? 不会的, 永远也不会了

情况太多, 快捷键太多, 不好记, 其实用搜索(C-s)来跳转挺方便直观的, mark->搜索跳转->删除区域

我不了解 vue,可以给个例子吗?我来测一下 Puni 工作不工作。

你会发现 forward-sexp 只能识别Tag,还只是平衡的Tag,但是对于Tag里面的属性没法识别,这也是 awesome-pair 创建一直想解决的问题之一。

puni-on-vue

试了一下,其实还行吧,字符串里面工作都正常,只是字符串里有语法的时候不认识。

Puni 并不是直接用 forward-sexp,它在上面做了很多工作来修正它的行为。