什么是 paredit?

刚开始使用 lisp 类语言的人大多都会觉得 lisp 中的括号不是很容易看清和分辨,经常写着写着不小心删除了一个 ) 就导致不匹配,如果光标还在删除的位置还好,要是不小心碰到了触控板导致光标移动到了别的地方,那可能就要仔细找找才能找到原来的位置了。这种情况频率不高还好,还有种更困扰的情景是写了一大段代码,代码中间的逻辑可能改了好几次,终于满意了,需要最后把没有写完的 ) 补完,这时候就一个一个小心又小心的注意不能写少也不能写多了。第二种情景不止会在lisp类的语言中遇到,任何需要括号来确定作用域的语言其实都会有这个问题,比如 golang 中的 {} ,只是遇到的频率小很多。

为了解决这个问题,我们一般都需要借助于结构化的编辑插件,结构化的编辑插件可以分成两个类型。

非强制的建议性的补齐插件

这是一般的编辑器现在普遍存在的功能,在你输入 ", (, { 之后自动补齐 ", ), } 之类的符号,emacs 24 之后提供的 electric-pair-mode 就是这样的实现。这中状态下用户可以任意删除 ", (), {} 等字符,文档中可能出现括号不匹配的状态,需要用户自己去修改。

强制要求所有括号匹配的插件

类似 paredit, lispy, smartparens-strict-mode 的插件,要求在任何时候括号都必须匹配,如果要删除则需要一对同时删除。着保证了我们不需要再担心括号匹配的问题,但会对文档的修改造成一定的限制。

paredit 应该算是 emacs 上这类插件的鼻祖的了, smartparenslispy 都是借鉴了 paredit 之后增加了一些新功能。我最开始使用的是 smartparens 但是并没有使用强制的模式,使用一段时间过后,其实感觉差别不大,很多功能都没有用到,这样就显得提供的功能太多了。于是我切换到了功能更加纯粹的 paredit 来编辑 lisp 类的代码,而其它模式下使用emacs自带的 electric-pair-mode 就完全满足需求了。

lisp 类语言编程对 S-exp 常用的操作

在使用强制括号匹配的插件之后,之前某些通过现删除后括号增加些内容,再添加后括号的操作是被禁止的,因为会导致括号不匹配。所以需要一些同时修改前后括号的操作来完成编辑动作。总结下来在一般的编辑过程中,会频繁的使用下面提到的四种操作。

  1. Unwrap, 相当于解包,把一对括号中的内容拿到外面来。我们可以通过下面的例子来说明, | 代表光标所在位置, ---> 上代表操作之前的状态,下代表操作结束后的状态。

    1
    2
    3
    
    (foo (bar| baz) quux)
      --->
    (foo bar| baz quux)
    
  2. Wrap,和 Unwrap 对应,相当于封包,把内容封装到一对括号当中,同样使用例子来展示。

    1
    2
    3
    
    (foo |bar baz)
      --->
    (foo (|bar) baz)
    
  3. Slurp, 吃进来,向前或向后扩张当前括号的范围,把括号的前一个或者后一个元素吃到当前的括号当中来。

    • 向后吞并元素
    1
    2
    3
    4
    5
    6
    7
    
    (foo (bar |baz) quux zot)
      --->
    (foo (bar |baz quux) zot)
    
    (a b ((c| d)) e f)
      --->
    (a b ((c| d) e) f)
    
    • 向前吞并元素
    1
    2
    3
    4
    5
    6
    7
    
    (foo bar (baz| quux) zot)
      --->
    (foo (bar baz| quux) zot)
    
    (a b ((c| d)) e f)
      --->
    (a (b (c| d)) e f)
    
  4. Barf, 吐出去,向前或者向后吐出括号内最近的内容,相当于缩小当前的括号范围。

    • 向后吐出元素
    1
    2
    3
    
    (foo (bar |baz quux) zot)
      --->
    (foo (bar |baz) quux zot)
    
    • 向前吐出元素
    1
    2
    3
    
    (foo (bar baz |quux) zot)
      --->
    (foo bar (baz |quux) zot)
    

paredit 的默认按键绑定

针对上面常用的四种操作,paredit 中都有对应的函数,并且绑定特定的快捷键,但是使用一段时间我发现这些快捷键的设计缺乏合理的设计隐喻,所以需要死记硬背才能熟练,而且如果一段时间没有使用就多半会忘记,重新用又要再重新记忆一遍,而偏偏我还是个记忆不算好的人,十分的别扭。我们来说说默认的快捷键和存在的问题。

  1. Unwrap,对应的快捷键为 M-s, 对应的函数为 paredit-splice-sexp 可以看出这个快捷键的设置完全是根据函数名来设置的,但是对于我来说我和作者对于这一操作的命名不一样,作者认为是把 S-exp 劈开,我认为这个行为更像是把S-exp拿出来,因为在对这个S-exp (foo|) 使用是产生的结果是 foo|, (foo bar |baz quux) 产生的结果是 foo bar |baz quux ,这两种情况下都很难理解 splice 的语义,我认为 unwrap 的描述更准确。这就导致我在使用这个命令时需要转换,我想做一个我认为是 unwrap 的操作,需要想到用的函数是 splice,所以快捷键是 M-s

  2. Wrap,对应的快捷键为 M-( ,对应的函数为 paredit-wrap-round ,看起来没什么问题,用括号包起来嘛。但在使用中会有两个不好的点,第一,( 是需要按住shift才能打出的符号,也就是说我实际是要按三个按键;第二,如果我的光标在这个位置 bar| 快捷键确实 ( ,这里也存在着隐喻的转换, /当然如果你实际在这个位置使用这个命令,目前的paredit会是这样的结果 bar () / 。另外还有一个问题就是这个命名和 unwrap 是有对应关系的,当在快捷键的设计上缺完全没有考虑,造成了更多的记忆负担。

  3. Barf & Slurp,这两个动作实际对应了四个操作,paredit 在这四个操作的快捷键设计上我认为作者是有思考的,有自己的逻辑,原有的键位设置比起一些 paredit 的设置分享上改为 M-{, M-[ 之类的好上非常多,我不是很理解那些设置分享的人是通过怎样的模型来记忆这几个操作的。

    在默认的设置中,作者是按照操作的位置来区分的 C 键代表操作右边的括号:

    • C-<right> 就是向后吞元素,右边括号继续往右移动,调用的函数为 paredit-forward-slurp-sexp , 非常直观,这也是我没有调整之前记得最清楚的操作,有时候其它操作都要转化成这个操作来完成。

    • C-<left> 同样操作右边括号,向后吐出元素,右边括号向左边移动,调用函数 paredit-forward-barf-sexp , 注意这里有个点是函数名里有个 forward,但我们的括号实际在往左边移动,这是 barf 的语义造成的问题,在我们使用快捷键,不用想函数名时不会有困扰。

    然后默认设置中 C-M 键代表了操作左括号:

    • C-M-<left> 就是向前吞并元素,左边的括号继续往左移动,调用的函数为 paredit-backward-slurp-sexp

    • C-M-<right> 就是向前吐出元素,左边的括号向右边移动,调用函数paredit-backward-barf-sexp

    我认为 Barf & Slurp 这组快捷键是作者经过精心设计的,比较的直观并且有自己的操作逻辑,比起 un/wrap 的键位设计好很多,也更容易记住。但是我不满意的点在于,我直觉上更倾向于把 slurp 的操作看作一组,barf 的操作看做另外一组,而不是把针对左括号的操作看作一组,针对右括号的操作看作一组。在实际操作中,左右括号的位置也会随着操作发生改变,虽然和方向键的方向一致,但是还是会有中模型的原点在不断移动的感觉。另外在 emacs 中,CC-M 代表同一层级的操作对象也不符合 emacs 的直觉,举个例子是 C-f 移动一个字符,M-f 移动一个单词,C-M-f 移动一个 sexp,之间是有层级关系的模型存在。

基于 control,meta 和十字键的绑定

说完了默认绑定的键位问题,来说说我的改进方案。

首先把 unwrap & wrap 这两个操作看做一组,分别绑定为 unwrap: M-<up>, wrap: M-<down> 。可以理解为 wrap 就是向下把 sexp 放到括号这个盒子里,uwrap 就是向上把 sexp 从括号这个盒子里拿出来。

其次把 slurp 的操作看作一组,把barf的操作看作另一组,而放弃绑定到左右括号上。

Slurp 的操作全部绑定到 C 上,要吞掉后面的元素,使用 C-<right>C 表示吞,方向指向要吞掉元素。同理向前吞就使用 C-<left>

Barf 的操作全部绑定到 M 上,要吐掉后面的元素,使用 M-<right> , M 表示吐,方向指向要吐出的元素。同理向前吐就使用 M-<left> 。这里的操作方向和括号的移动方向是相反的,但是我们的心理模型并不是绑定到括号的移动方向上,可以这么立即,吐出去肚子会变小,所以括号会往里缩紧。

下面是调整完后的配置代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
(use-package paredit
  :delight " Par"
  :bind
  (:map paredit-mode-map
	("M-<up>" . paredit-splice-sexp)
	("M-<down>" . paredit-wrap-round)
	("C-<right>" . paredit-forward-slurp-sexp)
	("C-<left>" . paredit-backward-slurp-sexp)
	("M-<left>" . paredit-backward-barf-sexp)
	("M-<right>" . paredit-forward-barf-sexp))
  :hook ((emacs-lisp-mode . paredit-mode)
	 (scheme-mode . paredit-mode))
  :config
  (define-key paredit-mode-map (kbd "M-s") nil)
  (define-key paredit-mode-map (kbd "M-?") nil))