Showing preview only (1,428K chars total). Download the full file or copy to clipboard to get everything.
Repository: slegetank/WGEECN
Branch: master
Commit: fef2035e87e4
Files: 38
Total size: 1.0 MB
Directory structure:
gitextract_lzy1usx8/
├── 0.org
├── 1.org
├── 10.org
├── 2.org
├── 3.org
├── 4.org
├── 5.org
├── 6.org
├── 7.org
├── 8.org
├── 9.org
├── A.org
├── B.org
├── C.org
├── D.org
├── E.org
├── LICENSE
├── README.org
├── html/
│ ├── 0.html
│ ├── 1.html
│ ├── 10.html
│ ├── 2.html
│ ├── 3.html
│ ├── 4.html
│ ├── 5.html
│ ├── 6.html
│ ├── 7.html
│ ├── 8.html
│ ├── 9.html
│ ├── A.html
│ ├── B.html
│ ├── C.html
│ ├── D.html
│ ├── E.html
│ ├── README.html
│ └── html.el
└── resource/
├── org.css
└── template.org
================================================
FILE CONTENTS
================================================
================================================
FILE: 0.org
================================================
#+TITLE: Writing GNU Emacs Extensions
#+SETUPFILE: ./resource/template.org
Bob Glickstein
O'REILLY
献给我的父母, 如果没有他们……
好吧,我宁愿不去想这件事。
* 前言
在你能够扩展Emacs之前,它已经是世界上功能最强大的文本编辑器了。它不仅能满足你平常所能想到的一切需求(段落排版,行居中对齐,正则搜索,将一整块文本设置为大写),不仅是它具有很多高级的特性(源代码中的括号匹配跳转,语法高亮,以及每个键位以及其他命令的在线帮助),而且它还具有许许多多你连做梦都想不到的特性。你可以使用Emacs来阅读和编写email以及浏览网页;你可以在里面使用FTP来自由的编写远程文件;你可以让它提醒你即将到来的会议、约会以及纪念日。假如这还不够的话,Emacs还可以陪你玩五子棋(大多数情况下它会赢);它可以告诉你今天是玛雅历的哪一天;它甚至还能分解质因数。
看上去Emacs用户花费这么多时间来做出如此多种多样的功能简直是疯了。大多数程序员将文本编辑器看成是编写其他软件的工具;为什么要花费这么多时间来改变编辑器本身呢?木匠不会耗费精力改造自己的锤子;管钳工不会耗费时间改造自己的钳子;他们只是使用他们的工具来完成自己手头的工作而已。Emacs用户为什么要不同呢?
答案是如果木匠和管钳工知道如何使自己的工具变得更好用的话,他们也会这么做。谁比他们自己更明白自己需要什么样的工具呢?但他们毕竟不是工具制作专家。另一方面,Emacs是一种特殊的工具:它是软件,它和使用它编写的软件并没有什么本质上的不同。Emacs的用户通常是程序员,而编写Emacs其实也就是编程。Emacs用户完全可以做自己的工具专家。
这本书将使用一系列的实例由浅入深的教给你如何编写Emacs Lisp。我们将以向Emacs启动配置文件中添加简单的配置作为开始,在最后我们将会着手编写“主模式(major modes)”以及修改Emacs自己的“命令循环(command loop)”。在这个过程中我们将会学到变量,键位表,交互式命令,buffers,窗口,处理I/O等。本书中所提到的Emacs特指GNU Emacs。有许多编辑器自称为Emacs。以下是权威的On-line Hacker Jargon File(version 4.0.0, 24-Jul-1996)所叙述的一点历史:
#+BEGIN_SRC text
[Emacs] was originally written by Richard Stallman in TECO under ITS at the MIT Al lab; AI Memo
554 described it as "an advanced, self-documenting, customizable, extensible real-time display
editor." It has since been re-implemented any number of times, by various hackers, and versions exist
that run under most major operating systems. Perhaps the most widely used version, also written by
Stallman and now called "GNU EMACS" or GNUMACS, runs principally under UNIX. It includes
facilities to run compilation subprocesses and send and receive mail; many hackers spend up to 80%
of their tube time inside it. Other variants include GOSMACS, CCA EMACS, UniPress EMACS,
Montgomery EMACS, jove, epsilon, and MicroEMACS.
#+END_SRC
书中的示例全部在GNU Emacs 19.34以及20.1版本的一个预发行版本上编写以及测试通过。参看[[file:E.org][附录E]],获取从哪里找到Emacs版本的信息。
我以自身使用Emacs的开发经历作为书中示例选择的指导。书中的示例实际上讲述了我自己的Emacs使用经历的演变。例如,我在最初使用Emacs的时候就知道一定要让该死的*BACKSPACE*键不要触发在线帮助!也许你也有这个问题。解决这个问题将作为下一章的第一个例子。
在我使用了Emacs一小段时间之后,我发现自己需要许多处理光标移动的快捷键。就我所知,使用Emacs提供的原生功能就可以轻松对其定制。我们将会在[[file:2.org][第二章]]中看到一些这样的例子。这之后我需要一种方法来避免我经常遇到的输入错误:我想要按下*CONTROL-b*,但实际上常常按下的却是*CONTROL-v*。我本想让光标向左移动一点,却向下移动了好几屏。在[[file:3.org][第三章]]中你将会看到这也很容易进行优化。当我开始处理记录着很多巧妙的谚语的文件时,我需要特定的工具来处理特定格式的文件。我们将会在[[file:9.org][第九章]]中看到一些这样的例子。
除了每章开始解决问题时提出的最初的例子之外,我们还会看到一些别的简单例子,每个例子都有自己的章节。每个章节都会提出一些需要使用Emacs Lisp解决的问题,然后展示出一个或一些方法来解决这些问题。然后,就像现实中的扩展都要变得更实用、更通用,我们通常会在前往下一个主题前对其进行一到两次改进。
每个例子都基于前面的例子,并且带入一些自己的新东西。在本书的最后,我们将覆盖大部分Emacs Lisp编程的概念并且讨论如何使用在线文档和其他信息来帮你更快的使用Emacs Lisp来满足你自己的需求。俗话说得好:授人以鱼不如授人以渔。
这本书假设你熟悉编程并且正在使用Emacs。如果你熟悉Lisp编程语言中的一些方言(Emacs Lisp也是其中一种)将会有帮助,但不是必要。通过我们每个章节中使用的例子,Lisp编程的要领将会很快很容易的理清。你也可以参考附录B,它简要的描述了Lisp的基本知识。
如果你不熟悉Emacs的基本概念,请阅读Debra Cameron,Bill Rosenblatt,和Eric Raymond所写的《Learning GNU Emacs, 2nd edition》。你也可以使用Emacs自带的在线帮助,特别是info手册,也就是《The GNU Emacs Manual》。如果你希望更全面的理解Lisp编程,我推荐阅读David Touretzky的《Common Lisp: A Gentle Introduction to Symbolic Computation》。
本书不是Emacs Lisp的参考手册;实际上也并没有特别深入的讲解语言方面的知识。主题的选择更倾向于更有教学意义而不是涵盖所有范畴。在阅读时最好以从前往后的顺序阅读以得到最好的效果。自由软件基金会发布的《The GNU Emacs Lisp Reference Manual》是这方面的权威。它有多种印刷本和电子版;详情参见[[file:E.org][附录E]]。
* 什么是Emacs?
认为Emacs仅仅是一个可编程的文本编辑器是完全错误的。同样的说它是C语言编辑器也是不对的。虽然看起来很较真,但实际上编写C语言和编写文本是两种完全不同的工作,Emacs通过变成两个不同的编辑器来包容这种差异。当编写代码时,你不会关心段落结构。当编写文本时,你不会关心自动缩进。
Emacs同时也是一个Lisp代码编辑器。它也是16进制数据文件的编辑器。它也可以作为大纲的编辑器。它也可以作为文件目录的编辑器,压缩文件的编辑器,email的编辑器等等。每一种编辑器都是一种Emacs的模式(mode),即一系列将Emacs的原生要素和行为组合起来以实现新特性的Lisp代码。因此每个模式也就是Emacs的一种扩展,也就是说如果你把这些模式都除掉的话--删掉所有扩展并且只剩下Emacs的核心--那么你就根本没有了任何的编辑器;你只剩下制作编辑器的原材料。你只剩下了编辑器生成器(editor-builder)。
你能用编辑器生成器生成什么呢?当然是编辑器了,但是什么是编辑器呢?编辑器就是一个用来展示和修改某种数据,以及用来帮助与这些数据更友好的进行交互的程序。当编辑文本文件时,规则很简单:每个可见字符按照顺序展示出来,换行符执行换行;一个光标用来表示用户的下一个操作将会发生在数据的什么位置。当编辑目录时就不是那么直观了--路径文件中的数据必须先转换成可读的格式--最终的交互流程要看起来比较人性化。
这个关于编辑器的定义几乎涵盖了所有交互程序的范畴,而这绝非偶然。交互程序总是用来处理某种数据的编辑器。因此可以说,Emacs在广义上,是一种交互程序的生成器。它是一个UI工具包!就像很多好的工具包一样,Emacs提供了一套UI组件,一套操作它们的方法,一个事件循环,一套成熟的I/O机制,以及一种用来把它们整合起来的程序语言。UI组件看起来可能不如X11,Windows或者Macintosh所提供的那样漂亮,但是就像Emacs程序员所发现的,一个超级漂亮的图形工具集往往是多余的。99%的程序都是文本形式的,不管是数字列表,菜单项,或者填字游戏里的单词(参考[[file:10.org][第十章]]所展示的例子)。对于这些程序,Emacs在功能性、精巧性、简单性以及性能上都要优于其他。
“为什么Emacs用户不同?”,这个问题的真正答案并不仅仅是他们花费时间在改造他们的工具上。他们在使用Emacs来达到自己所期望的目的:创造出无穷无尽的新工具(a universe of new tools)。
* 本书的组织形式
书中的每章都基于前一章。我建议你从前往后顺序读这本书;这样的话里面的所有安排就都变得有意义了。
+ 第1章
介绍一些你可以对Emacs做出的基本修改。这也会使你熟悉Emacs Lisp,如何对Lisp表达式求值,以及那会如何改变Emacs的行为。
+ 第2章
教给你如何编写和安装Lisp函数来使其正确执行。钩子和一种称为修饰的特性将会被引入。
+ 第3章
如何在不同函数调用间保存信息以及如何使多组函数共同工作--这是编写系统而不仅仅是编写命令的第一步。符号属性和标记将会在这中间被介绍。
+ 第4章
展示一些你极有可能会经常用到的技术:用来改变当前buffer和其中字符串的方法。正则表达式会被介绍。
+ 第5章
讨论加载、自动加载以及包的概念,这些特性会在你创建大量相关函数时用到。
+ 第6章
加入一些关于Lisp重要特性的背景知识。
+ 第7章
展示如何将相关函数和变量组装进称为“子模式”的包里。这个章节中的核心例子是使Emacs中的段落格式化功能工作的更像一个正常的文本处理软件。
+ 第8章
展示Emacs Lisp解释器的灵活性,如何控制在何时执行什么,以及如何编写不受运行时错误影响的代码。
+ 第9章
解释子、主模式间的差别,并且为后者提供一个简单的例子:一个专门用来处理谚语文件的主模式。
+ 第10章
定义一个完全改变Emacs默认行为的主模式--一个填字游戏谜题编辑器,通过它向你展示Emacs对于开发文本相关的应用是多么灵活。
+ 附录B
对Lisp的语法,数据类型以及控制结构提供了一个实用的介绍。
+ 附录C
描述了可以用来追踪你的Emacs Lisp代码中问题的工具。
+ 附录D
解释了把代码分享给别人时需要遵循的步骤。
+ 附录E
概述了如何在你的系统上得到一个可用的Emacs版本。
* 获取示例程序
通过浏览器,你可以获取到示例:
ftp://ftp.oreilly.com/published/oreilly/nutshell/emacs_extensions
** FTP
要使用FTP,你需要一台能直接访问网络的电脑。下面是一个例子,你需要输入的是其中粗体的部分:
% *ftp ftp.oreilly.com*
Connected to ftp.oreilly.com.
220 FTP server (Version 6.21 Tue Mar 10 22:09:55 EST 1992) ready.
Name (ftp.oreilly.com:yourname): *anonymous*
331 Guest login ok, send domain style e-mail address as password.
Password: *yournameayourhost.com* (use your user name and host here)
230 Guest login ok, access restrictions apply.
ftp> *cd /published/oreilly/nutshell/emacsextensions*
250 CWD command successful.
ftp> *binary* (Very important! You must specify binary transfer for
gzipped files.)
200 Type set to I.
ftp> *get examples.tar.gz*
200 PORT command successful.
150 Opening BINARY mode data connection for *examples.tar.gz.*
226 Transfer complete.
ftp> *quit*
221 Goodbye.
文件格式为gzipped tar归档;输入下面的指令展开它:
#+BEGIN_SRC shell
% gzip -dc examples.tar.gz | tar -xvf -
#+END_SRC
System V 系统需要下面的tar指令:
#+BEGIN_SRC shell
% gzip -dc examples.tar.gz | tar -xvof -
#+END_SRC
如果gzip在你的系统上不存在,那么单独使用uncompress以及tar指令。
#+BEGIN_SRC shell
% uncompress examples.tar.gz
% tar xvf examples.tar
#+END_SRC
* 致谢
感谢Nathaniel Borenstein,他帮助我驱散了对于C的执念并且教会了我欣赏这个世界上多姿多彩的编程语言。
感谢Richard Stallman编写了Emacs--两次--他提出的令人惊奇的言论是对的:黑客编写更好的代码是为了满足自己而并非为了钱。
感谢Mike McInerny,他固执的坚持使我开始使用GNU Emacs--即使开始的几次我都认为这并不值得我花时间。
感谢Ben Liblit提供的想法,代码以及对于我的Defer包(本书中的一章,直到Emacs有了自己的功能相同的包,timer)的bug修正。其他的帮助来自于Simon Marshal,他在他的defer-lock中使用并且改进了很多其中的想法。Hi,Si。
感谢Linda Branagan向我展示了即使像我这样一个平凡的人也能写书。(她当然并不平凡;一点也不。)
感谢Emily Cox和Henry Rathvon提供的对于填字游戏谜题的一些内行知识。
感谢对于本书的早期草稿做出校对和建议的朋友们:Julie Epelboim,Greg Fox,David Hartmann,Bart Schaefer,Ellen Siever,以及Steve Webster。
感谢Zanshin Inc.以及Internet Movie Database允许我在这些工程和这本书之间分配我的工作精力。
感谢编辑,Andy Oram,能够灵活地应对我上面提到的这种杂乱无章的工作。
感谢Alex,我的狗,在我写这本书的大部分过程中都在我的脚边开心地打转。
最重要的是,感谢Andrea Dougherty,她鼓励着我,支持着我,做出了无数的牺牲,提供了数不清的服务,在我需要的时候给我陪伴并且在我需要独处的时候离开(而不是反过来),并且在其他所有方面都对我和这本书有益:这一定是爱。
================================================
FILE: 1.org
================================================
#+TITLE: 自定义Emacs
#+SETUPFILE: ./resource/template.org
* 在本章:
*Backspace和Delete*
*Lisp*
*按键和字符串*
*C-h绑定到什么*
*C-h应该绑定到什么*
*执行Lisp表达式*
*Apropos*
本章将介绍基本的Emacs定制化,并且在这一过程中教给你一些Emacs Lisp。最简单、最常见的定制之一就是将一个按键的命令复制到另一个按键上。可能你不喜欢Emacs的两次按键(C-x C-s)来保存文件,因为其他的编辑器通常都只是简单的C-s。或者你可能只是想按C-x,却不小心按成C-x C-c,也就是退出Emacs,而你希望按下C-x C-c不要造成这么酷炫的效果。或者也可能,就像下面的例子所展示的,你可能希望对Emacs提供给你的键位做出一些自己的修改。
* Backspace 和 Delete
想象你想要输入“Lisp”但却输成了“List”。要改正你的拼写,你是按下BACKSPACE键呢还是DELETE键?
这当然由你的键盘而定,但是我要问的并不仅仅是一个按键上标记了什么的问题。有时按键上标记的是“Backspace”,有时它又被标记为“Delete”,有时又是“Erase”,有时是一个向左的箭头或是别的什么图案。对于Emacs来说,按键上标记着什么并不重要,它在乎的是按下这个键时所触发的数字代码。“向左移动并且删掉前面的字符”可能会产生一个ASCII的“backspace”编码(十进制8,通常表示为BS)或者一个ASCII的“delete”编码(十进制127,通常表示为DEL)。
在Emacs的默认配置中,认为只有DEL表示“向左移动并且删掉前面的字符”。如果你的BACKSPACE/DELETE/ERASE键产生了一个BS,那么它将不会按照你所希望的那样执行。
更糟的是当你按下BACKSPACE/DELETE/ERASE键,它却产生了一个BS时。Emacs认为既然BS并不用来左移并删除前面的字符,那么它就可以用来触发另一个方法。结果是,BS现在触发的方法和按下C-h时触发的一样。如果你们那里并不需要C-h来执行左移并删除前面的字符,那么C-h更好的选择是作为一个Help键,而这也是Emacs的选择。不幸的是,这意味着如果你的BACKSPACE/DELETE/ERASE触发BS的话,那么按下它将不会触发backspace,delete或者erase;它将触发Emacs的在线帮助。
当Emacs的初学者想要修正一个拼写错误时,他们不止一次的被Emacs做出了热烈的欢迎。突然一个新的Emacs窗口--帮助窗口--弹了出来,提示不幸的用户来选择一些帮助命令。帮助的内容如此的冗长使得用户更加的瞠目结舌。用户恐慌的按下一大堆的C-g(终止当前的操作),伴随着一大堆的终端错误铃声提示。怪不得许多聪明善良的用户选择继续使用安全无害的vi,而不是成为Emacs的疯狂传道者。我每当想起这件事就很伤心,特别是这一情形很容易被修复。Emacs启动时,它将读取并且执行你的根目录下的.emacs文件。它使用Emacs Lisp作为语言,而读完本书你会发现,只要编写一些Emacs Lisp放到.emacs里,Emacs几乎没有什么是你不能改变的。我们要关注的第一件事就是向.emacs中添加一些代码来把BS和DEL都指定为“向前退格并且删掉一个字符”,而将帮助命令移动到其他键上去。首先我们需要看一下.emacs文件所使用的语言,Lisp。
* Lisp
自从1950年以来已经产生了很多种不同形式的Lisp。最初它应用于人工智能,它很胜任这份工作,因为它允许符号运算,可以将代码作为数据处理,可以简化复杂数据结构的构建。但是Lisp可以做的要比一门AI语言多得多。它应用于非常广的问题处理上,这经常被计算机科学家忽视,而Emacs用户却知道的很清楚。Lisp不同于其他编程语言的特性有:
+ 括号前缀表示法
Lisp中的所有表达式和方法都由括号括起来[[[1-1][1]]],方法名通常在参数之前。所以在其他语言中你通常会这么写:
#+BEGIN_SRC elisp
x + y
#+END_SRC
而在Lisp中,你会这么写:
#+BEGIN_SRC elisp
(+ x y)
#+END_SRC
“前缀表示法”表示运算符在运算对象的前面。当运算符在运算对象中间时,这称为“中缀表示法”。
虽然与通常的习惯不同,但前缀表示法相对于中缀有一些好处。在中缀语言中,要将5个变量加起来需要4个加号:
#+BEGIN_SRC elisp
a + b + c + d + e
#+END_SRC
Lisp更简洁:
#+BEGIN_SRC elisp
(+ a b c d e)
#+END_SRC
而且,不会产生运算符的优先级问题。例如,
#+BEGIN_SRC emacs-lisp
3 + 4 * 5
#+END_SRC
的结果是35还是23?这需要知道*是否比+具有更高的优先级。而在Lisp中,就不会有这种疑惑:
#+BEGIN_SRC elisp
(+ 3 (* 4 5)) ; 结果是23
(* (+ 3 4) 5) ; 结果是35
#+END_SRC
(Lisp中的注释使用分号,作用到行尾。)最后,中缀语言需要在方法中使用逗号分隔参数:
#+BEGIN_SRC elisp
foo(3 + 4, 5 + 6)
#+END_SRC
Lisp不需要额外的语法:
#+BEGIN_SRC elisp
(foo (+ 3 4) (+ 5 6))
#+END_SRC
+ List数据类型
Lisp有一种内建的数据类型称为列表(list)。列表是一种用括号括起来的,不包含或者包含着其他Lisp对象的Lisp对象。下面是一些列表:
#+BEGIN_SRC elisp
(hello there) ;包含着两个“符号”的列表
(1 2 "xyz") ;两个数字和一个字符串
() ;空列表
#+END_SRC
列表可以作为值赋给其他变量,作为参数传递给方法以及作为返回值传递,使用cons和append这种方法来进行组合,使用car和cdr来进行拆分。后面我们将会更详细地叙述这些知识。
+ 垃圾回收
Lisp是一种垃圾回收的语言,这意味着Lisp会自动的回收你的程序里的数据结构所使用的内存。与之相反的,比如C语言,程序员必须显式的使用malloc来分配内存,然后显式的使用free来释放内存。(在非垃圾回收语言里,malloc/free这种语句非常容易出错。过早的释放内存是全世界程序错误中最大的原因之一,而忘记释放内存则会造成内存的泄露。)
除了所有这些垃圾回收机制所具有的有点,它也有一个缺点:Emacs会不时的停下正在做的所有事情,向用户显示“Garbage collecting...”。用户要等到垃圾回收结束才能继续使用Emacs[[[1-2][2]]]。这通常只会持续不到1s,但是却可能非常频繁。后面我们将会学到如何减少垃圾回收发生的实用技巧。
表达式(expression)通常表示Lisp代码中的任何一部分或者任何Lisp数据结构。所有Lisp表达式,不管是代码还是数据,都可以被Emacs中内建的Lisp解释器执行。对一个变量求值的结果就是访问之前储存在变量中的Lisp对象。就像我们下面将要看到的,用来执行Lisp函数的方式就是对一个列表求值。
自从Lisp发明以来已经产生了许多Lisp方言,它们之间各有不同。MacLisp, Scheme和Common Lisp是其中较为有名的。Emacs Lisp和它们都不一样。这本书只关注Emacs Lisp。
* 按键和字符串
本章的目的是使所有触发BS的键同触发DEL的键能一样的工作。当然这将导致C-h不再触发帮助命令。你需要选择其他的键来使用帮助;我自己的方式是使用META-question-mark。
** META键
META键的工作方式和CONTROL键以及SHIFT键一样,都是需要在按下其他键的同时按着它。这种键被称为修饰键(modifiers)。虽然不是所有键盘都有META键。有时ALT键起着同样的作用,但是也不是所有键盘都有ALT键。无论如何,你都不是必须使用META或者ALT。单次按键META-x总是可以使用两键序列ESC x来替代。(注意ESC不是修饰键--你需要先按下ESC,松开手,再按下x键。)
** 将按键绑定到命令上
在Emacs里,每个按键都触发一条命令或者是一个触发命令的多键序列的一部分。就像我们将要看到的,命令是一种特殊的Lisp函数。使一个按键触发类似帮助这种命令的行为被称为绑定。我们需要执行一些Lisp代码来将按键绑定到命令上。global-set-key是一个用于做这件事的函数。
下面介绍如何调用global-set-key。记住在Lisp里函数调用就是简单的一个括起来的列表。第一个元素是函数名称,剩下的元素全是参数。函数global-set-key使用两个参数:要绑定的按键序列,以及要绑定的命令。
#+BEGIN_SRC elisp
(global-set-key keysequence command)
#+END_SRC
需要注意Emacs Lisp是区分大小写的。
我们选择的按键序列是META-question-mark。这在Emacs Lisp中如何表示呢?
** 字符串表示按键
在Emacs Lisp中有一些不同的方式来表示一个按键序列。最简单的是直接使用字符串。在Lisp中,字符串是一些被引号括起来的字符序列。
#+BEGIN_SRC elisp
"xyz" ; 三个字母的字符串
#+END_SRC
要在字符串中使用双引号,使用反斜杠(\):
#+BEGIN_SRC emacs-lisp
"I said, \"Look out!\""
#+END_SRC
这表示如下字符串:
#+BEGIN_SRC text
I said, "Look out!"
#+END_SRC
要在字符串中表示反斜杠需要使用另一个反斜杠对其转义。
普通的按键使用它所代表的字符来表示它。例如,按键q在Lisp中被字符串“q”所表示。而反斜杠\则写作“\\”。
像META-question-mark这种特殊字符在字符串里使用特殊的标识符:“\M-?”来表示。虽然字符串里有四个字母,但Emacs会将此字符串读为META question-mark[[[1-3][3]]]。
在Emacs的术语中,M-x是META-x的简写,“\M-x”是字符串版本。CONTROL-x在Emacs文档中简写为C-x,在字符串中表示为“\C-x”。你也可以组合CONTROL和META键。CONTROL-META-x简写作C-M-x,字符串表示为“\C-\M-x”。顺便,”\C-\M-x”和”\M-\C-x”(META-CONTROL-x)等价。
(CONTROL-x在文档里有时也表示为^x,那么字符串就表示为”\^x”。)
现在我们知道了如何填写global-set-key的第一个参数:
#+BEGIN_SRC emacs-lisp
(global-set-key "\M-?" command)
#+END_SRC
(另一种书写”\M-?”的方式是”\e?”。字符串“\e”表示escape,而M-x和Esc x等价。)
下面我们必须找出command需要填写什么。这个参数应该是我们希望M-?触发的帮助函数的名称,也就是当前C-h所触发的函数。在Lisp中,函数使用符号(symbols)来表示。符号就像其他语言中的函数名或者变量名,虽然Lisp在命名时比大多数语言都允许更宽泛的字符集。例如,合法的符号名包括let*以及up&down-p。
* C-h绑定到什么
要找到帮助命令的符号,我们可以使用C-h b,这将会触发另一个名为describe-bindings的命令。这是帮助系统众多的命令之一。它会弹出一个列出所有有效键绑定的窗口。查找C-h,我们可以找到这一行:
#+BEGIN_SRC elisp
C-h help-command
#+END_SRC
这告诉了我们help-command是指向帮助命令的符号。我们的Lisp示例即将完成了,但是我们不能只是写下
#+BEGIN_SRC elisp
(global-set-key “\M-?” help-command) ; 几乎对了!
#+END_SRC
这是错误的,因为符号只要出现在Lisp表达式里就会马上被解释执行。如果符号出现在列表的第一个位置时,那么它将作为函数的名称来执行。否则,它作为变量的值就要被展开。但是当我们运行global-set-key时,我们不需要help-command所包含的值,不管那是什么。我们需要的是help-command这个符号的本身。简而言之,我们希望在传递给global-set-key之前不要对符号进行求值。毕竟就我们所知,help-command并没有作为变量的值存在。
阻止符号(以及其他任何Lisp表达式)被求值的方法是在它的前面加一个单引号(')进行引用(quoted)。就像这样:
#+BEGIN_SRC emacs-lisp
(global-set-key "\M-?" 'help-command)
#+END_SRC
我们的Lisp例子现在完成了。如果你把它放到你的.emacs文件中,那么以后当你打开Emacs时M-?将会触发help-command。(马上我们将会学到如何立即触发Lisp表达式。)M-? b将会像C-h b一样触发describe-bindings(这时M-?和C-h都绑定到了help-command)。
顺便,为了说明引用和非引用的区别,下面两条表达式可以达成同样的效果:
#+BEGIN_SRC emacs-lisp
(setq x 'help-command) ; setq分配一个变量
(global-set-key "\M-?" x) ; 使用 x 的变量值
#+END_SRC
第一行使变量x保存符号help-command。第二行使用x的值--符号help-command--绑定给M-?。这个例子与上一个的唯一区别是你现在多使用了一个变量x。
符号并不是唯一可以被单引号前缀的;任何Lisp表达式都能被引用,包括列表,数字,字符串,以及其他我们后面将要学到的表达式。'expr是下面的简写:
#+BEGIN_SRC elisp
(quote expr)
#+END_SRC
这在执行的时候会延缓求值(yield)。你可能已经注意到了符号help-command需要引用而字符串参数“\M-?”却不需要。这是因为在Lisp里,字符串是自解释的,当字符串被执行时,它返回的是它本身。所以对其进行引用是无害而多余的。数字,字符以及向量(vector)是其他自解释的Lisp表达式。
* C-h应该绑定到什么?
既然我们已经将help-command绑定到M-?,下面我们需要给C-h绑定一些什么。使用前面所描述的同样的流程--也就是说,触发命令describe-bindings(使用C-h b或者M-? b)--我们发现DEL触发的命令是delete-backward-char。
所以我们可以这样写:
#+BEGIN_SRC emacs-lisp
(global-set-key "\C-h" 'delete-backward-char)
#+END_SRC
现在DEL和C-h一样了。如果你把下面的命令放到.emacs里:
#+BEGIN_SRC emacs-lisp
(global-set-key "\M-?" 'help-command)
(global-set-key "\C-h" 'delete-backward-char)
#+END_SRC
那么以后在Emacs里,BACKSPACE/DELETE/ERASE将会执行正确的事情,不管发出的是BS还是DEL。但是我们如何使他们马上产生效果呢?这需要显式执行(explicit evaluation)这两个表达式。
* 执行Lisp表达式
有几种方式来显式执行Lisp表达式。
1. 你可以将Lisp表达式放到一个文件里,然后载入这个文件。假设你把表达式放到文件rebind.el里。(Emacs Lisp文件的后缀名是.el)。你可以键入M-x load-file RET rebind.el RET以使Emacs来执行文件的内容。如果你把这些内容放到了.emacs里,你可以使用同样的方法来载入它。但是在你使用了Emacs一段时间后,你的.emacs将会变得越来越大,它的载入将会变得很慢。因此,你不会希望为了一点点改动就重新载入整个文件。因此我们可以使用下一种选择。
2. 你可以使用命令eval-last-sexp,这绑定到了[[[1-4][4]]]C-x C-e上。(sexp[[[1-5][5]]]是S表达式(S-expression)的简写,也就是符号表达式的简写,也就是Lisp表达式的另一种说法。)这个命令将执行光标左边的Lisp表达式。所以你要做的是将光标放到第一行的末尾:
#+BEGIN_SRC emacs-lisp
(global-set-key "\M-?" 'help-command) |
(global-set-key "\C-h" 'delete-backward-char)
#+END_SRC
然后按下C-x C-e;然后移动到第二行尾:
#+BEGIN_SRC emacs-lisp
(global-set-key "\M-?" 'help-command)
(global-set-key "\C-h" 'delete-backward-char) |
#+END_SRC
然后再次按下C-x C-e。执行global-set-key的结果--一个特别的符号nil(我们后面将会再次看到)--展示在了Emacs屏幕下方的消息区里。
3. 你可以使用命令eval-expression,这绑定到了M-:[[[1-6][6]]]。这个命令在minibuffer(屏幕的底部)中提示你输入一个Lisp表达式,然后执行它并输出结果。Emacs的制作者认为eval-expression是少数一些对于初学者来说尝试使用会造成危险的命令之一。以我来看,这简直是胡说;不论如何,这个命令在初始时是被禁用的,所以当你尝试使用时,Emacs告诉你“You have typed M-:, invoking disabled command eval-expression.”。然后它会显示eval-expression的描述并且如下提示:
#+BEGIN_SRC emacs-lisp
You can now type
Space to try the command just this once,
but leave it disabled,
Y to try it and enable it (no questions if you use it again),
N to do nothing (command remains disabled).
#+END_SRC
如果你选择Y,Emacs将会把下面的表达式加入你的.emacs。
#+BEGIN_SRC emacs-lisp
(put 'eval-expression 'disabled nil)
#+END_SRC
(put函数和属性列表(property list)有关,我们将会在第三章的[[file:3.org::*符号属性][符号属性]]中看到它)我的建议是你可以在获得这个提示之前就把它手动加入到.emacs里,这样你就不会被“disabled command”警告所困扰了。当然,当你把这条语句放到.emacs里之后,使用前面提到的eval-last-sexp使它马上生效是一个不错的想法。
4. 你可以使用*scratch* buffer。这个buffer在Emacs启动的时候就会自动创建。它使用了Lisp 交互模式。在这个模式里,按下C-j来执行eval-print-last-sexp,它很像eval-last-sexp,除了它会将结果插入到光标所在的位置。Lisp交互模式的另一个特性是你可以使用M-TAB进行自动补全(触发lisp-complete-symbol)。Lisp交互模式在用来调试太长的Lisp表达式或者数据结构太复杂的时候特别有用。
不管你使用哪一种方法,执行global-set-key表达式的结果是产生了新的按键绑定。
* Apropos
在结束第一个例子之前,让我们讨论一下Emacs的最重要的在线帮助特性,apropos。假设你同时拥有BS和DEL键,你希望BS删除光标前面的字符而DEL删除后面的。你现在知道了delete-backward-char用来完成前面的目的,但是你不知道什么命令完成后面的。你确信Emacs一定有这么一个命令。但是如何找到它呢?
答案是使用apropos命令,它允许你使用表达式来搜索所有已知的变量名和函数名。试试这么做[[[1-7][7]]]:
#+BEGIN_SRC elisp
M-x apropos RET delete RET
#+END_SRC
返回值是一个列出了所有符合“delete”的变量和函数的buffer。如果我们在这个buffer里搜索“character”,然后翻到这一部分
#+BEGIN_SRC elisp
backward-delete-char
Command: Delete the previous N characters (following if N is negative).
backward-delete-char-untabify
Command: Delete characters backward, changing tabs into spaces.
delete-backward-char
Command: Delete the previous N characters (following if N is negative).
delete-char
Command: Delete the following N characters (previous if N is negative).
#+END_SRC
而函数delete-char正是我们需要的。
#+BEGIN_SRC emacs-lisp
(global-set-key "\C-?" 'delete-char)
#+END_SRC
(由于历史原因,DEL由CONTROL-question-mark来触发。)
你可以使用前置参数来执行apropos。在Emacs中,在执行命令前按下C-u将会向命令传递额外的参数。通常,C-u后面跟着一个数字;例如C-u 5 C-b表示“将光标向前移动5个字符”。有时额外的参数就是你按下的C-u本身。
当apropos使用了前置参数时,它不只显示所有符合搜索表达式的函数和变量,它还展示出列表中每个命令绑定的按键(这不是默认的,因为搜索按键绑定很慢)。使用C-u M-x apropos RET delete RET 然后搜索“character”,我们将会得到下面的信息:
#+BEGIN_SRC elisp
backward-delete-char (not bound to any keys)
Command: Delete the previous N characters (following if N is negative).
backward-delete-char-untabify (not bound to any keys)
Command: Delete characters backward, changing tabs into spaces.
delete-backward-char C-h, DEL
Command: Delete the previous N characters (following if N is negative).
delete-char C-d
Command: Delete the following N characters (previous if N is negative).
#+END_SRC
这证实了现在C-h和DEL都会执行delete-backward-char,并且告诉了我们delete-char已经有了一个绑定:C-d。在我们执行
#+BEGIN_SRC emacs-lisp
(global-set-key "\C-?" 'delete-char)
#+END_SRC
之后,如果我们再次执行apropos,我们将会得到
#+BEGIN_SRC elisp
backward-delete-char (not bound to any keys)
Command: Delete the previous N characters (following if N is negative).
backward-delete-char-untabify (not bound to any keys)
Command: Delete characters backward, changing tabs into spaces.
delete-backward-char C-h
Command: Delete the previous N characters (following if N is negative).
delete-char C-d, DEL
Command: Delete the following N characters (previous if N is negative).
#+END_SRC
如果我们知道我们要搜索的对象是Emacs命令,而不是变量或者函数,我们可以使用command-apropos(M-? a)来缩小搜索范围。命令和其他Lisp函数的区别是命令特别用于交互式的触发,也就是说可以通过按键或者M-x触发。非命令的函数只能被其他Lisp代码调用或者被类似eval-epression和eval-last-sexp这样的命令来执行。我们将会在下一章看到更多的函数和命令的知识。
<<1-1>>[1]. 批评者通常认为Lisp的括号是它标志性的缺点。他们认为,Lisp是“Lots of Infernal Stupid Parentheses”的简写(实际上是“List Processing”的简写)。以我来看,这个更简单的符号使得Lisp比其他语言更易读,而我希望你也这么认为。
<<1-2>>[2]. Emacs使用了一种标记-清扫的垃圾回收设计,是最简单的垃圾回收实现方式之一。有一些其他的实现方式会更少打扰用户;例如,一种称为“incremental”的方式在执行时不会使Emacs当机。不幸的是,Emacs没有使用这些方式。
<<1-3>>[3]. 你可以使用length函数查看字符串的长度来确认这件事。如果你执行(length "\M-?"),结果为1。如何“执行”在本章的后面有介绍。
<<1-4>>[4]. 技术上说,我们应该说按键被绑定到了命令上,而不是命令绑定到了按键上。(说按键“绑定到”了命令上正确的表示了这个按键序列只能做一件事--触发这个命令。说命令“绑定”到了一个按键上则表示只有这个按键序列能够触发这个命令,而这并不是真的。)但是一般来说这种误用的“绑定到”并不会引起什么误会。
<<1-5>>[5]. 遗憾地读作“sex pee.”。
<<1-6>>[6]. 这个按键绑定是19.29新引入的。在之前的版本,eval-expression默认绑定到M-ESC。
<<1-7>>[7]. 所有的Emacs命令,不管它们绑定到了哪里(如果有的话),都可以通过M-x command-name RET来执行。自然,M-x自己也是一个绑定到按键上的命令,execute-extend-command,它会提示输入一个要执行的函数名。
================================================
FILE: 10.org
================================================
#+TITLE: 一个综合示例
#+SETUPFILE: ./resource/template.org
* 在本章:
*纽约时报规则*
*数据表示*
*用户界面*
*建立模式*
*追踪未授权的修改*
*解析Buffer*
*词语查找器*
本章是我们编程示例的终点。实现一个填字游戏编辑器(crossword puzzle editor)是个好主意--显然Emacs的设计者并没有预先设计这一功能,但这可以被实现。设计和实现Crossword模式将会展示出Emacs作为应用构建工具的真正潜力。
在为填字游戏编辑器设计完数据模型之后,我们将会为它创建一个用户界面,创建用来展示我们数据模型的函数并且将输入限制在我们允许的范围内。我们将会编写插入到Emacs菜单中的命令以及用于跟其他进程通讯的命令。通过这些,我们将会利用我们前面学到的Lisp技术来执行复杂的逻辑以及字符串处理。
* 纽约时报规则
我是填字游戏的狂热粉丝。我曾经每天都做纽约时报的填字游戏。我经常会思考构建一个填字游戏需要哪些技术,并且希望我自己动手完成它。最初我试图用方格纸来做,但是我很快发现填字游戏的创建包含如此多的尝试和错误(至少对我如此)以至于我才做到中间,我的橡皮已经把纸擦透了!最终我选择编写一个计算机程序来帮助我创建填字游戏。
填字游戏表格(diagram),或者说网格(grid),包含着“空格(blank)”以及“障碍(block)”。空格是一个可能填充了字母的空方格。障碍则是一个用来分隔单词的没有填写字母的黑色方块。优秀的填字游戏设计者将会尽可能少的使用障碍。
Crossword模式将会延用被我称作“纽约时报规则”的填字游戏规则(当然这与其他的填字游戏应该是相似或者相同的):
1. 网格是一个nxn的方块,n为奇数。纽约时报的每日游戏为15*15,而周末则为21*21。
2. 网格符合“180度对称”,即如果你把网格旋转180度,那么空格和障碍仍然保持相同的位置。[[[10-40][40]]]以数学语言来说,这表示如果网格square(x, y)是一个空白的话,那么square(n-x+1, n-y+1)也一定是(n代表网格的宽度,x和y从0开始);如果(x, y)是一个障碍,那么(n-x+1, n-y+1)也一定是。如图10-1展示的180度对称的例子。
[[file:resource/10-1.png]]
图10-1. 一个180度对称的例子
3. 游戏中的所有单词必须至少包含2个字母。(实际上,我被告知纽约时报从未使用过少于三个字母的单词,但是为了简化,我们使用两个。)
* 数据表示
让我们以构建数据表示作为开始。一个明显的方法是使用一个二元数组来存储填字游戏的数据,或者说矩阵。Emacs Lisp并没有这种数据类型,但是我们可以使用向量(vector)来创建一个。
** 向量(Vectors)
Lisp的向量跟列表类似,即它是0个或多个子表达式(包括嵌套向量或者列表)的序列。但是,向量允许对其元素的随机访问,而列表却必须从头到尾遍历来找到一个元素。(但并不是说向量就比列表更好。向量不像列表一样可以加长或者缩短。所以就像常说的那样,要因地制宜。)
向量使用中括号来代替小括号:
#+BEGIN_SRC emacs-lisp
[a b c ...]
#+END_SRC
向量是自运算的(self-evaluating);也就是说,向量的运算结果是向量自身。它的子表达式并不会被求值。所以如果你写
#+BEGIN_SRC emacs-lisp
[a b c]
#+END_SRC
你将会得到一个包含着三个符号的向量,a,b,和c。如果你希望向量包含变量a,b和c的值,那么你必须使用vector函数来构建向量:
#+BEGIN_SRC emacs-lisp
(vector a b c) -> [17 37 42] ; 或者其他什么值
#+END_SRC
** 矩阵包
使用向量来设计矩阵包是清晰直观的。我们指定一个向量来表示矩阵的行,而每一行都是一个代表列的向量。我们会这样创建:
#+BEGIN_SRC emacs-lisp
(defun make-matrix (rows columns &optional initial)
"Create a ROWS by COLUMNS matrix."
(let ((result (make-vector rows nil))
(y 0))
(while (< y rows)
(aset result y (make-vector columns initial))
(setq y (+ y 1)))
result))
#+END_SRC
参数initial指定了一个Lisp表达式作为矩阵内每个元素的初始值。第一次调用make-vector创建了一个填充了nil的向量,长度为rows。然后我们将每个nil替换为一个新的长度为columns的向量。函数aset用来设置向量的元素而aref用来获取。[[[10-41][41]]]向量的索引从0开始。调用(asset vector index value)会将vector中的第index个元素设置为value。调用(aref vector index)会返回位置为index的值。
在while循环里调用的make-vector会将每个嵌套向量的元素设置为initial,所以在make-matrix的最后,result是一个包含着rows个嵌套向量的向量,而每个嵌套向量都是由columns个initial组成的。
为什么不像下面这样更简单的编写这个函数呢:
#+BEGIN_SRC emacs-lisp
(defun make-matrix (rows columns &optional initial)
"Create a ROWS by COLUMNS matrix."
(make-vector rows (make-vector columns initial))) ; 错啦!
#+END_SRC
原因是里面的那个make-vector只会产生一个新的向量。外面的调用会使用这个向量作为外面向量的每个元素的初始值。换句话说,外面向量的每个元素将会共享同一个内部的向量,而我们希望的是每个元素的值是各不相同的嵌套向量。
现在我们定义好了矩阵的结构,那么定义它的基本操作就很简单了:
#+BEGIN_SRC emacs-lisp
(defun matrix-set (matrix row column elt)
"Given a MATRIX, ROW, and COLUMN, put element ELT there."
(let ((nested-vector (aref matrix row)))
(aset nested-vector column elt)))
(defun matrix-ref (matrix row column)
"Get the element of MATRIX at ROW and COLUMN."
(let ((nested-vector (aref matrix row)))
(aref nested-vector column)))
#+END_SRC
得到矩阵的宽和高的函数也许有用:
#+BEGIN_SRC emacs-lisp
(defun matrix-columns (matrix)
"Number of columns in MATRIX."
(length (aref matrix 0))) ; 子向量的长度
(defun matrix-rows (matrix)
"Number of rows in MATRIX."
(length matrix)) ; 外部向量的长度
#+END_SRC
当函数的定义非常短,就像上面这四个,通常使用defsubst而不是defun把它们转换为内联函数(inline functions)是个好主意。使用defsubst定义的内联函数与defun定义的函数的功能一样,除了在编译时对于内联函数的调用会被替换为函数本身。这有一个主要的好处:在运行时,当前函数并不需要建立一个对其他函数的调用。这会稍微快一点,但是如果是在成千上万次的循环中的话,叠加起来还是很可观的。不幸的是,这么做也有代价。首先每次调用都会拷贝一份,因此这会增加内存的占用。另一个是如果你修改了内联函数的定义,其他的定义仍然会保持编译文件里的那一份。(因此可以说,defsubst与C++的内联函数相同,或者与C语言中的宏函数相同。)
我们可以将上面的代码放到matrix.el中,在文件的最后添加一行(provide 'matrix),然后在之后的程序中通过(require 'matrix)使用它。
** 矩阵在填字游戏中的变化
现在让我们考虑一个填字游戏网格,也就是一种特殊的矩阵。其中的每个格子只有如下四种状态:
1. 空的,表示我们会向其中填写一个字母或者一个障碍。
2. 半空,表示我们会向其中填写一个字母而不是一个障碍(因为180度对称的原因)。
3. 填充了一个障碍。
4. 填充了一个字母。
让我们使用nil表示一个格子是空的,符号letter表示必须填写字母的半空格子,符号block表示一个包含着障碍的格子,以及字母本身(在Emacs中以数字表示,即它的ASCII码)表示一个包含着字母的格子。
根据上面的定义,让我们使用矩阵来为填字游戏网格定义一种新的数据类型。
#+BEGIN_SRC emacs-lisp
(require 'matrix)
(defun make-crossword (size)
"Make a crossword grid with SIZE rows and columns."
(if (zerop (% size 2)) ; 是偶数吗?(%是取余函数)
(error "make-crossword: size must be odd"))
(if (< size 3) ; size是不是太小了?
(error "make-crossword: size must be 3 or greater"))
(make-matrix size size nil))
(defsubst crossword-size (crossword)
"Number of rows and columns in CROSSWORD."
(matrix-rows crossword)) ; 或者用matrix-columns,都一样
(defsubst crossword-ref (crossword row column)
"Get the element of CROSSWORD at ROW and COLUMN."
(matrix-ref crossword row column))
(defsubst crossword--set (crossword row column elt)
"Internal function for setting a crossword grid square."
(matrix-set crossword row column elt))
#+END_SRC
函数crossword--set名字中间使用了双横线。这是一种习惯上定义“私有”函数的方式,它表示这个函数并不是包声明的编程接口。在这个例子里,crossword--set是私有的,因为它并没有实现我们希望填字游戏网格所具有的纽约时报规则。Crossword包的用户将不会直接使用crossword--set;它们会直接使用下面定义的crossword-store-letter,crossword-store-block,以及crossword-clear-cell。只有Crossword包自身以及一些用于判断180度对称和单词长度大于2的规则才会使用crossword--set。
** 使用Cons Cells
让我们创建一个概念“兄弟(cousin)”来表示一个给定格子的对称格子。
#+BEGIN_SRC emacs-lisp
(defun crossword-cousin-position (crossword row column)
"Give the cousin position for CROSSWORD ROW and COLUMN."
(let ((size (crossword-size crossword)))
(cons (- size row 1) (- size column 1))))
#+END_SRC
这个函数会以dotted pair的方式返回兄弟格子的行、列值(参照[[file:6.org][第6章]]中[[file:6.org::*列表细节][列表细节]]的章节):(cousin-row . cousin-column)。下面是两个直接获取和设置兄弟格子的函数:
#+BEGIN_SRC emacs-lisp
(defun crossword-cousin-ref (crossword row column)
"Get the cousin of CROSSWORD's ROW,COLUMN position."
(let ((cousin-position (crossword-cousin-position crossword
row
column)))
(crossword-ref crossword
(car cousin-position)
(cdr cousin-position))))
(defun crossword--cousin-set (crossword row column elt)
"Internal function for setting the cousin of a cell."
(let ((cousin-position (crossword-cousin-position crossword
row
column)))
(crossword--set crossword
(car cousin-position)
(cdr cousin-position)
elt)))
#+END_SRC
注意到crossword--cousin-set是另一个名字中间有双横线的“私有”函数。
现在让我们为纽约时报规则创建用来储存障碍和字母的函数。首先,字母。当向一个格子中填写一个字母的时候,我们必须保证格子的兄弟已经包含了一个字母(我们可以使用numberp来检测)。如果它没有,我们必须在那里存储一个符号letter:
#+BEGIN_SRC emacs-lisp
(defun crossword-store-letter (crossword row column letter)
"Given CROSSWORD, ROW, and COLUMN, put LETTER there."
(crossword--set crossword row column letter)
(if (numberp (crossword-cousin-ref crossword row column))
nil
(crossword--cousin-set crossword row column 'letter)))
#+END_SRC
插入障碍稍微简单一点:
#+BEGIN_SRC emacs-lisp
(defun crossword-store-block (crossword row column)
"Given CROSSWORD, ROW, and COLUMN, put a block there."
(crossword--set crossword row column 'block)
(crossword--cousin-set crossword row column 'block))
#+END_SRC
现在让我们编写一个用来清空格子的函数。当清空一个格子时,有下面几种可能的情况:
+ 格子和它的兄弟都包含着字母。如果是的话,格子变成“半空”状态而兄弟不受影响。
+ 格子和它的兄弟都包含着障碍。如果是的话,格子和兄弟都清空。
+ 格子已经是半空状态(因为它的兄弟包含着一个字母。)如果这样的话,什么都不发生。
+ 格子包含着一个字母但它的兄弟是半空的。如果这样的话,两个格子都清空。
+ 格子和兄弟都是空的。如果这样,什么都不发生。
我们可以使用一个简单的规则来处理这情况:如果格子的兄弟包含一个字母,那么格子变为半空并且兄弟不受影响;否则格子自身和它的兄弟都清空。下面就是实现的代码。
#+BEGIN_SRC emacs-lisp
(defun crossword-clear-cell (crossword row column)
"Erase the CROSSWORD cell at ROW,COLUMN."
(if (numberp (crossword-cousin-ref crossword row column))
(crossword--set crossword row column 'letter)
(crossword--set crossword row column nil)
(crossword--cousin-set crossword row column nil)))
#+END_SRC
现在看一下nxn(n是奇数)网格中间的方格,它的兄弟是它自己。这表示我们需要对crossword-clear-cell做一点小的修正。中间的方格一定不能设置成符号letter。(幸运的是,crossword-store-block和crossword-store-letter仍然能够正确地工作。)
#+BEGIN_SRC emacs-lisp
(defun crossword-clear-cell (crossword row column)
"Erase the CROSSWORD cell at ROW,COLUMN."
(let ((cousin-position (crossword-cousin-position crossword
row
column)))
(if (and (not (equal cousin-position
(cons row column)))
(numberp (crossword-ref crossword
(car cousin-position)
(cdr cousin-position))))
(crossword--set crossword row column 'letter)
(crossword--set crossword row column nil)
(crossword--set crossword
(car cousin-position)
(cdr cousin-position)
nil))))
#+END_SRC
在这个版本里,只有当cousin-position不等于(row . column)的时候格子才会被设置为letter--也就是说,格子本身并不是自己的兄弟。如果格子是自己的兄弟,或者它的兄弟不包含字母,那么(与前一个版本一样)将它与它的兄弟都设置为nil。最后一个crossword--set对于中间的格子来说是多余的,但也并没有什么副作用。注意我们在函数的开头计算了兄弟的位置,这样我们使用crossword-ref替代了crossword-cousin-ref,使用crossword--set替代了crossword--cousin-set,这样就避免了对于兄弟位置的多次求值。
** 单字母单词
单字母单词会在一个符合“障碍,非障碍,障碍”的行中出现;或者当一个非障碍的格子出现在障碍与边界之间时出现。下面这个函数用来检测一个给定的方格是不是一个单字母单词。
#+BEGIN_SRC emacs-lisp
(defun crossword-one-letter-p (crossword row column)
"Is CROSSWORD cell at ROW,COLUMN a one-letter word?"
(and (not (eq (crossword-ref crossword row column)
'block))
(or (and (crossword-block-p crossword (- row 1) column)
(crossword-block-p crossword (+ row 1) column))
(and (crossword-block-p crossword row (- column 1))
(crossword-block-p crossword row (+ column 1))))))
#+END_SRC
这个逻辑有一点复杂,但是我们可以用[[file:3.org][第三章]]中学到的技术来理解这种表达式:即每次深入一层子表达式:
#+BEGIN_SRC emacs-lisp
(and ...)
#+END_SRC
当crossword-one-letter-p的子表达式的结果都为true时函数将返回true,否则返回false。
#+BEGIN_SRC emacs-lisp
(and (not ...)
(or ...))
#+END_SRC
“如果某些事情不为真并且其他的一些事情为真则返回真。”
#+BEGIN_SRC emacs-lisp
(and (not (eq ...))
(or (and ...)
(and ...)))
#+END_SRC
“如果一些事情不等于另一些并且一些事情都为真或者另一些事情都为真则返回真。”
#+BEGIN_SRC emacs-lisp
(and (not (eq (crossword-ref crossword row column)
'block))
(or (and (crossword-block-p crossword (- row 1) column)
(crossword-block-p crossword (+ row 1) column))
(and (crossword-block-p crossword row (- column 1))
(crossword-block-p crossword row (+ column 1)))))
#+END_SRC
“如果当前的格子不是障碍并且其上面的格子和下面的格子是障碍或者左面的格子和右面的格子是障碍则返回真。”这里包含着一个小技巧:crossword-block-p在访问边界外的方格时则认为它们包含障碍。
下面是crossword-block-p的定义:
#+BEGIN_SRC emacs-lisp
(defun crossword-block-p (crossword row column)
"Does CROSSWORD's ROW,COLUMN cell contain a block?"
(or (< row 0)
(>= row (crossword-size crossword))
(< column 0)
(>= column (crossword-size crossword))
(eq (crossword-ref crossword row column) 'block)))
#+END_SRC
* 用户界面
现在我们有了一整套用来根据我们制定的规则来处理填字游戏数据结构的函数;但是还没有提供方法让用户与填字游戏网格进行交互。我们必须编写用户界面,这包括多个用来操作填字游戏的命令以及用来实时显示填字游戏网格的方法。
** 显示
让我们选择一种用来在Emacs buffer中显式填字游戏网格的方法。
网格的每一行在buffer中都被表示为一行。但是对于网格中的列,应该占用屏幕上的两列--这会使网格在大多数显示器上显得更方正。(在大多数字体里,一个字母的高度会比宽度高很多,这会使得一个nxn的字符块看起来非常窄。)
空格子会用点号(.)表示。障碍用井号(#)表示。半空格子用问号(?)表示。当然,包含字母的格子用字母自身表示。
下面这个方法会把一个网格插入到当前buffer里。它不会清空buffer,也不会放置光标;这应该留给这个函数的调用者来做,我们稍后会定义他们。
#+BEGIN_SRC emacs-lisp
(defun crossword-insert-grid (crossword)
"Insert CROSSWORD into the current buffer."
(mapcar 'crossword-insert-row crossword))
#+END_SRC
回忆我们在[[file:6.org][第六章]]中[[file:6.org::*其他有用的列表函数][其他有用的列表函数]]一节中讲到的mapcar会把一个方法应用到列表中的每个元素上。而这对向量也有效;因为crossword是一个包含着行的向量,所以crossword-insert-grid对网格的每一行调用了cross-insert-row。
下面是上面用到的crossword-insert-row的定义:
#+BEGIN_SRC emacs-lisp
(defun crossword-insert-row (row)
"Insert ROW into the current buffer."
(mapcar 'crossword-insert-cell row)
(insert "\n"))
#+END_SRC
这与上面的工作方式一样,即对每行中的每个格子调用了crossword-insert-cell。在行的最后,我们另起一行。
最后,下面是crossword-insert-row所需要的crossword-insert-cell:
#+BEGIN_SRC emacs-lisp
(defun crossword-insert-cell (cell)
"Insert CELL into the current buffer."
(insert (cond ((null cell) ".")
((eq cell 'letter) "?")
((eq cell 'block) "#")
((numberp cell) cell))
" "))
#+END_SRC
这会插入两个字母;一个点号,问号,井号或者一个字母;后面跟着一个空格(这会使一个格子在屏幕上占有两列)。第一个字符插入什么用一个cond结构来确定,也就是if的一个变体。cond的每一个参数被称作子句(clause),每个子句是一个列表。每个子句的第一个元素被称为条件(condition),会按次序执行。当一个子句的条件运算结果为真时,那么子句剩下的元素(如果存在的话)就会被执行,而最后一个表达式的值会作为cond的返回值。成功执行的子句后面的子句会被忽略。
#+BEGIN_SRC emacs-lisp
(cond ((condition1 body ...)
(condition2 body ...)
...))
#+END_SRC
如果你希望cond中包含一个“else”子句--当其他子句都不为真的时候执行--你可以在最后添加一个条件为真的子句:
#+BEGIN_SRC emacs-lisp
(cond ((condition1 body ...)
(condition2 body ...)
...
(t body ...)))
#+END_SRC
函数insert会将任意数量的字符串或者字母插入到当前的buffer里;这也就是为什么我们可以向它传递格子的值,不管是数字、“ ”、或者字符串来插入。
** 放置光标
让我们继续构建我们的模式一定会用到的组件。
现在我们可以展示填字游戏网格了,我们还应该提供一种方法来把光标放到任意一个网格里。光标的位置向用户展示了下一次操作将会影响哪个格子。下面这个函数假定网格已经画到了当前的buffer里,并且它起始于(point-min)。[[[10-42][42]]]
#+BEGIN_SRC emacs-lisp
(defun crossword-place-cursor (row column)
"Move point to ROW,COLUMN."
(goto-char (point-min))
(forward-line row)
(forward-char (* column 2)))
#+END_SRC
下一步,当用户触发一些操作时,我们需要根据光标的位置得到它在当前网格中对应的座标。
#+BEGIN_SRC emacs-lisp
(defun crossword-cursor-coords ()
"Compute (ROW . COLUMN) from cursor position."
(cons (- (current-line) 1)
(/ (current-column) 2)))
#+END_SRC
函数/是Emacs Lisp中的除法函数,在两个参数都是整数的时候执行整除。结果会向0取整。感谢这个方法,
#+BEGIN_SRC emacs-lisp
(/ (current-column) 2)
#+END_SRC
不管光标在前面的列还是后面的空格都会返回正确的列数。
不幸的是,虽然Emacs内置了current-column,但是却没有一个current-line。[[[10-43][43]]]下面是其中的一种写法:
#+BEGIN_SRC emacs-lisp
(defun current-line ()
"Return line number containing point."
(let ((result 1)) ; Emacs从1开始计算行数
(save-excursion
(beginning-of-line) ; 这样bobp将会正常工作
(while (not (bobp))
(forward-line -1)
(setq result (+ result 1))))
result))
#+END_SRC
函数bobp用来检测光标是否在buffer的开头。
** 更新显示
当用户编辑网格时,下层的数据结构的改变需要反映到buffer里。如果每次都擦除整个buffer然后调用crossword-insert-grid插入整个网格是很没有效率的。相反的,我们将只绘制发生改变的格子。
我们已经有了相关的工具:crossword-place-cursor和crossword-insert-cell。下面是一个使用了这些组件的函数。它假定光标在被影响的格子上,然后重画这个格子和它的兄弟。
#+BEGIN_SRC emacs-lisp
(defun crossword-update-display (crossword)
"Called after a change, keeps the display up to date."
(let* ((coords (crossword-cursor-coords))
(cousin-coords (crossword-cousin-position crossword
(car coords)
(cdr coords))))
(save-excursion
(crossword-place-cursor (car coords)
(cdr coords))
(delete-char 2)
(crossword-insert-cell (crossword-ref crossword
(car coords)
(cdr coords)))
(crossword-place-cursor (car cousin-coords)
(cdr cousin-coords))
(delete-char 2)
(crossword-insert-cell (crossword-ref crossword
(car cousin-coords)
(cdr cousin-coords))))))
#+END_SRC
你可能会认为这个函数里对于crossword-place-cursor的第一次调用是多余的,因为它把光标放到了刚刚通过crossword-cursor-coords取到的同一个位置。但是要知道在网格中一个格子有两列宽,而光标很有可能在第二列里。为了使crossword-insert-cell正确工作,光标必须在第一列里。crossword-place-cursor保证了这一点。外面包裹的save-excursion保证了在更新完成后光标返回到它原来的地方。
** 用户命令
现在我们需要定义用户与Crossword模式交互的命令。
*** 网格变更指令
让我们假设使用Crossword模式的buffer中有一个名为crossword-grid的buffer局部的变量保存着填字游戏的网格。(在下一节我们将会设计在创建crossword-mode命令时创建crossword-grid的具体细节。)因此用来清空一个格子的用户命令可以如下编写。
#+BEGIN_SRC emacs-lisp
(defun crossword-erase-command ()
"Erase current crossword cell."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-clear-cell crossword-grid
(car coords)
(cdr coords)))
(crossword-update-display crossword-grid))
#+END_SRC
类似的,下面的命令用来插入一个障碍:
#+BEGIN_SRC emacs-lisp
(defun crossword-block-command ()
"Insert a block in current cell and cousin."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-store-block crossword-grid
(car coords)
(cdr coords)))
(crossword-update-display crossword-grid))
#+END_SRC
用来插入字母的命令会更棘手一点。一共有26个字母,而我们并不想编写类似crossword-insert-a和crossword-insert-b这样的26个不同的命令。我们希望用一个函数绑定到26个字母按键,当触发的时候插入对应的字母。一个通用的函数是self-insert-command。我们将定义一个插入用户按下的字母的函数crossword-self-insert。
#+BEGIN_SRC emacs-lisp
(defun crossword-self-insert ()
"Self-insert letter in current cell."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-store-letter crossword-grid
(car coords)
(cdr coords)
(aref (this-command-keys) O)))
(crossword-update-display crossword-grid))
#+END_SRC
这个函数用了this-command-keys来检测用户按下了哪个按键。this-command-keys将会返回一个字符串或者一个包含着符号事件(symbolic events)的向量(在本章后面的[[鼠标命令][鼠标命令]]的部分将会描述更多细节);但是crossword-store-letter需要的是一个字符,而不是字符串,或者符号,或者向量。我们使用aref来得到第一个元素并且传递给crossword-store-letter,是基于我们确信它是一个字符串,并且我们并不关心除了第一个元素之外的东西。这应该没问题,因为当我们在后面的章节[[按键绑定][按键绑定]]中做设置的时候,我们只会将crossword-self-insert绑定到单个按键上(也就是说字母按键),并且我们会让用户不可能,或者至少更困难地输入不合法的字符。
*** 导航
用户需要一些除了Emacs原本的光标移动命令之外的方式来在格子之间移动,因为它们并不能很好的适配到网格的导航里。例如,每个网格都是两列宽,所以我们需要按两次C-f来移动到右面的格子。再比如,移动到网格的最左边不应该自动拐回到下一行的开始处。它应该直接停止。
导航命令的定义是很直观的。只需要知道用户希望移动的方向,以及移动多远。我们需要定义向左、右、上、下移动一个格子的命令;用来移动到行首、尾的命令;移动到列首、尾的命令;以及移动到网格的开始(左上角)、结束(右下角)的命令。
首先,横向移动的命令:
#+BEGIN_SRC emacs-lisp
(defun crossword-cursor-right (arg)
"Move ARG cells to the right."
(interactive "p") ; 前置参数为数字
(let* ((coords (crossword-cursor-coords))
(new-column (+ arg (cdr coords))))
(if (or (< new-column 0)
(>= new-column (crossword-size crossword-grid)))
(error "Out of bounds"))
(crossword-place-cursor (car coords)
new-column)))
(defun crossword-cursor-left (arg)
"Move ARG cells to the left."
(interactive "p")
(crossword-cursor-right (- arg)))
#+END_SRC
类似的纵向移动的命令:
#+BEGIN_SRC emacs-lisp
(defun crossword-cursor-down (arg)
"Move ARG cells down."
(interactive "p")
(let* ((coords (crossword-cursor-coords))
(new-row (+ arg (car coords))))
(if (or (< new-row 0)
(>= new-row (crossword-size crossword-grid)))
(error "Out of bounds"))
(crossword-place-cursor new-row
(cdr coords))))
(defun crossword-cursor-up (arg)
"Move ARG cells up."
(interactive "p")
(crossword-cursor-down (- arg)))
#+END_SRC
现在定义移动到行列头尾的命令。
#+BEGIN_SRC emacs-lisp
(defun crossword-beginning-of-row ()
"Move to beginning of current row."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-place-cursor (car coords) 0)))
(defun crossword-end-of-row ()
"Move to end of current row."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-place-cursor (car coords)
(- (crossword-size crossword-grid)
1))))
(defun crossword-top-of-column ()
"Move to top of current column."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-place-cursor 0 (cdr coords))))
(defun crossword-bottom-of-column ()
"Move to bottom of current row."
(interactive)
(let ((coords (crossword-cursor-coords)))
(crossword-place-cursor (- (crossword-size crossword-grid)
1)
(cdr coords))))
#+END_SRC
最后,移动到网格的首尾的命令。
#+BEGIN_SRC emacs-lisp
(defun crossword-beginning-of-grid ()
"Move to beginning of grid."
(interactive)
(crossword-place-cursor 0 0))
(defun crossword-end-of-grid ()
"Move to end of grid."
(interactive)
(let ((size (crossword-size crossword-grid)))
(crossword-place-cursor size size)))
#+END_SRC
又仔细想了想,下面的东西也许会有用:一个用来跳到当前格子的兄弟的命令。
#+BEGIN_SRC emacs-lisp
(defun crossword-jump-to-cousin ()
"Move to cousin of current cell."
(interactive)
(let* ((coords (crossword-cursor-coords))
(cousin (crossword-cousin-position crossword-grid
(car coords)
(cdr coords))))
(crossword-place-cursor (car cousin)
(cdr cousin))))
#+END_SRC
* 建立模式
有两种情况用户希望进入Crossword模式。一种是访问保存着之前填字游戏网格缓存的文件。另一种是创建一个新的网格的时候。
创建一个新的网格需要创建一个新的buffer并且使用crossword-insert-grid填充它。进入主模式不应该变更buffer的内容,所以crossword-mode将只会在一个buffer已经包含着填字游戏网格的情况下进入。我们将设计一个单独的命令,crossword,来创建一个新的网格。
#+BEGIN_SRC emacs-lisp
(defun crossword (size)
"Create a new buffer with an empty crossword grid."
(interactive "nGrid size: ")
(let* ((grid (make-crossword size))
(buffer (generate-new-buffer "*Crossword*")))
(switch-to-buffer buffer)
(crossword-insert-grid grid)
(crossword-place-cursor 0 O) ; 从左上角开始
...))
#+END_SRC
我们现在先不完成这个函数,但是在继续之前,让我们看一下这个函数中间的一些有趣的东西:
1. (interactive "nGrid Size: ")。字母n是Emacs提示用户输入值的提示符之一。这会允许你提供一个提示字符串,就像我们上面做的那样。这个interactive声明表示,“用字符串“Grid size:”提示用户,然后读入一个数字作为返回。”
但是如果这个命令需要两个参数,一个数字然后一个字符串呢?那么这个interactive的声明看起来会是什么样呢?
Emacs认为n后面直到新的一行之间的部分都是提示字符串。所以只要在字符串中嵌入一个换行符,然后再引入另一个提示符就可以了,就像这样:
#+BEGIN_SRC emacs-lisp
(interactive "nFirst prompt: \nsSecond prompt: ")
#+END_SRC
2. 我们使用let*而不是let来使得grid在buffer之前创建。这并不是必须的,因为buffer的创建并不依赖于grid(例如,size可能是一个不合法的值)。真正的原因是在Emacs中创建buffer的代价非常高,而且buffer并不像其他的变量那样会自己释放(垃圾回收并不会回收它)。一旦一个buffer创建了,直到调用kill-buffer前它一直存在。
3. 新buffer的名字是*Crossword*。通常,没有关联文件的buffer的名字一般以星号开头和结尾--例如*scratch*和*Help*。当用户编辑了buffer之后,他可以将其保存到一个文件里(例如通过C-x C-w),这时Emacs会将buffer重命名为对应的文件。
让我们暂时把注意力集中到crossword-mode命令上。就像我们之前已经决定的,它只应用于已经包含填字游戏网格的buffer。它会解析这个buffer。也就是说会根据buffer里的文字构建一个新的网格对象出来。解析过的网格需要赋值给crossword-grid。下面是根据[[file:9.org][第9章]]中讲到的主模式而编写的:
#+BEGIN_SRC emacs-lisp
(defun crossword-mode ()
"Major mode for editing crossword puzzles.
Special commands:
\\{crossword-mode-map}"
(interactive)
(kill-all-local-variables)
(setq major-mode 'crossword-mode)
(setq mode-name "Crossword")
(use-local-map crossword-mode-map)
(make-local-variable 'crossword-grid)
(setq crossword-grid (crossword-parse-buffer))
(crossword-place-cursor 0 0) ; 从左上角开始
(run-hooks 'crossword-mode-hook))
#+END_SRC
后面我们再定义crossword-mode-map和crossword-parse-buffer。
现在让我们来看一下crossword命令。在将一个网格放置到一个空的buffer中后,这个buffer必须进入Crossword模式。怎么做呢?最明显的答案是调用crossword-mode:
#+BEGIN_SRC emacs-lisp
(defun crossword (size)
"Create a new buffer with an empty crossword grid."
(interactive "nGrid size: ")
(let* ((grid (make-crossword size))
(buffer (generate-new-buffer "*Crossword*")))
(switch-to-buffer buffer)
(crossword-insert-grid grid)
(crossword-place-cursor 0 0) ; 从左上角开始
(crossword-mode)))
#+END_SRC
这看起来不错,但是有一些效率问题。crossword-mode会调用crossword-parse-buffer来创建一个crossword数据结构,即使crossword之前已经建立好了。如果能够保持一份crossword的拷贝的话就可以跳过解析这一步。
这么做的最好的方式是创建另一个被crossword和crossword-mode同时使用的函数,它负责进入Crossword模式时两边相同的处理。
#+BEGIN_SRC emacs-lisp
(defun crossword--mode-setup (grid)
"Auxiliary function to set up crossword mode."
(kill-all-local-variables)
(setq major-mode 'crossword-mode)
(setq mode-name "Crossword")
(use-local-map crossword-mode-map)
(make-local-variable 'crossword-grid)
(setq crossword-grid grid)
(crossword-place-cursor 0 0)
(run-hooks 'crossword-mode-hook))
#+END_SRC
我们让crossword--mode-setup使用网格作为参数。所以crossword应该用自己构建的网格去调用它:
#+BEGIN_SRC emacs-lisp
(defun crossword (size)
"Create a new buffer with an empty crossword grid."
(interactive "nGrid size: ")
(let* ((grid (make-crossword size))
(buffer (generate-new-buffer "*Crossword*")))
(switch-to-buffer buffer)
(crossword-insert-grid grid)
(crossword--mode-setup grid)))
#+END_SRC
而crossword-mode则应该使用解析buffer的结果来调用它:
#+BEGIN_SRC emacs-lisp
(defun crossword-mode ()
"Major mode for editing crossword puzzles.
Special commands:
\\{crossword-mode-map}"
(interactive)
(crossword--mode-setup (crossword-parse-buffer)))
#+END_SRC
** 按键绑定
在前面我们定义了几个用户命令,例如crossword-erase-command和crossword-block-command。现在让我们定义crossword-mode-map并且为这些命令选择对应的按键绑定。
#+BEGIN_SRC emacs-lisp
(defvar crossword-mode-map nil
"Keymap for Crossword mode.")
(if crossword-mode-map
nil
(setq crossword-mode-map (make-keymap))
...)
#+END_SRC
这些命令大部分都与通常的Emacs命令很类似。例如,crossword-beginning-of-row和crossword-end-of-row与beginning-of-line和end-of-line很相似,而它们分别绑定到了C-a和C-e。这是不是表示我们应该把它们做出类似的绑定呢?
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map "\C-a"
crossword-beginning-of-row)
(define-key crossword-mode-map "\C-e"
crossword-end-of-row)
#+END_SRC
也许吧。但是假如用户并不使用C-a来调用beginning-of-line呢?这样的话,C-a就不是个正确的选择。因为它们的相似性,用户会希望crossword-beginning-of-row与beginning-of-line具有相同的快捷键。最好的选择是我们能够找到用户对于beginning-of-line的绑定然后将crossword-beginning-of-row绑定到上面。而这就是substitute-key-definition的功能。
#+BEGIN_SRC emacs-lisp
(substitute-key-definition 'beginning-of-line
'crossword-beginning-of-row
crossword-mode-map
(current-global-map))
#+END_SRC
这表示,“获取当前begining-of-line在全局键位表中的绑定,然后在crossword-mode-map中为crossword-beginning-of-row创建一个相同的绑定。”
我们可以使用多个对于substitute-key-definition的调用来建立crossword-mode-map;或者更准确的说,使用一个循环。
#+BEGIN_SRC emacs-lisp
(let ((equivs
((forward-char . crossword-cursor-right)
(backward-char . crossword-cursor-left)
(previous-line . crossword-cursor-up)
(next-line . crossword-cursor-down)
(beginning-of-line . crossword-beginning-of-row)
(end-of-line . crossword-end-of-row)
(beginning-of-buffer . crossword-beginning-of-grid)
(end-of-buffer . crossword-end-of-grid))))
(while equivs
(substitute-key-definition (car (car equivs))
(cdr (car equivs))
crossword-mode-map
(current-global-map))
(setq equivs (cdr equivs))))
#+END_SRC
我们创建了一个包含着“等价对”的list变量equivs。每次遍历循环的时候,(car equivs)会取到一个等价对,例如(next-line . crossword-cursor-down)。这样,(car (car equivs))就是要在全局键位表里要查找的命令(例如next-line)而(cdr (car equivs))则是对应的要放到crossword-mode-map中的命令(例如crossword-cursor-down)。
现在我们必须将字母键绑定到crossword-self-insert上。
#+BEGIN_SRC emacs-lisp
(let ((letters
'(?A ?B ?C ?D ?E ?F ?G ?H ?I ?J ?K ?L ?M
?N ?O ?P ?Q ?R ?S ?T ?U ?V ?W ?X ?Y ?Z ?a ?b ?c ?d ?e ?f ?g ?h ?i ?j ?k ?l ?m
?n ?o ?p ?q ?r ?s ?t ?u ?v ?w ?x ?y ?z)))
(while letters
(define-key crossword-mode-map
(char-to-string (car letters))
'crossword-self-insert)
(setq letters (cdr letters))))
#+END_SRC
这样我们就只有crossword-erase-command,crossword-block-command,crossword-top-of-column,crossword-bottom-of-column和crossword-jump-to-cousin没有绑定了(因为它们在通常的编辑模式中并没有对应的操作)。让我们先绑定前两个:
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map " " crossword-erase-command)
(define-key crossword-mode-map "#" crossword-block-command)
#+END_SRC
因为看起来对于清空格子和插入障碍操作来说这很直观。对于剩下的三个,让我们使用以C-c开头的两次按键来绑定。前面我们说过,C-c是模式相关的绑定的前缀。
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map "\C-ct"
'crossword-top-of-column)
(define-key crossword-mode-map "\C-cb"
'crossword-bottom-of-column)
(define-key crossword-mode-map "\C-c\C-c"
'crossword-jump-to-cousin) ; 来自于C-x C-x
#+END_SRC
这些就是目前我们需要的所有按键绑定;但是不幸的是,就像其他所有的键位表,对于未设置的按键都会自动继承全局表。这表示,有一些按键可能会对我们小心构建的填字游戏网格造成破坏。数字键和其他一些可见字还是绑定在self-insert-command上;C-w,C-k和C-d仍然能删除掉buffer的一部分;C-y仍然会在任意点插入任意内容;等等。
这些情况可以使用suppress-keymap部分解决,它可以使所有的self-inserting按键变为未定义。我们应该在创建键位表之后定义按键之前调用suppress-keymap。
#+BEGIN_SRC emacs-lisp
(if crossword-mode-map
nil
(setq crossword-mode-map (make-keymap))
(suppress-keymap crossword-mode-map)
...)
#+END_SRC
这只会保证self-inserting的按键行为正确,但是类似C-w和C-y这样的其他危险按键还潜伏着。一个更完全(更彻底)的方法是在crossword-mode-map中截取所有按键绑定:
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map [t] 'undefined)
#+END_SRC
在这个对于define-key的调用里,按键参数并不是像我们之前那样是一个字符串;它是一个包含着t的向量。我们之前说过向量和字符串是相似的;它们都是数组的一种。实际上,在define-key中一个包含着字母的向量和一个包含着字母的字符串作用相同;我们将在下一部分更仔细的观察包含着符号的向量。向量[t]表示捕获所有在当前键位表中未绑定的按键。通常,如果当前的局部键位表中未定义一个按键,那么就会去查找全局表。[t]表示“在这儿停下”。所以这是禁用所有未显式启用的按键的一种方法。
** 鼠标命令
当在类似X这种图形界面系统下运行Emacs的时候,鼠标也可以像按键那样触发操作。实际上,鼠标动作与普通按键的绑定都在同一个键位表里。
键位表数据结构可以是向量,assoc list,或者是它们俩的组合。当你按下一个键,你会产生一个用来索引向量的数值,或者用来搜索assoc的键值。当你点击鼠标时,你会产生一个只能用来搜索assoc的符号。例如符号down-mouse-1表示按下了鼠标键1(通常是左键),而符号mouse-1表示按键1松开了。(习惯上按键按下的事件用来获取鼠标指针的位置,而松开用来判断鼠标按下之后是否移动过。)其他的鼠标事件包括C-down-mouse-2(按住ctrl键的同时按下鼠标中键),S-drag-mouse-3(按下shift键的同时拖动按键3),以及double-mouse-1(双击按键1)。
鼠标输入与键盘输入的另一个不同是当你按下鼠标键时会带来一些额外的数据:例如,你在窗口中按下按键的位置。按键输入总是发生在“point”上,而鼠标输入则发生在鼠标光标处。因此,鼠标输入被表示为一个称为输入事件(input event)的数据结构。绑定到一个鼠标动作的命令可以通过调用last-input-event,或者在interactive声明中使用符号e来访问到当前的事件。
为了展示这些,让我们为Crossword模式定义三个简单的鼠标指令。鼠标按键1将会把光标放到一个格子里,鼠标按键2将会放入一个障碍,而按键3则会清除掉一个格子。
在每个例子里,初始的down-事件会放置光标并且将位置记录到一个变量crossword-mouse-location里。当按键松开时,新的位置与之前的位置比较。如果不同的话,什么都不做。
让我们以crossword-mouse-set-point开始,这个函数会回应鼠标键按下的事件。
#+BEGIN_SRC emacs-lisp
(defvar crossword-mouse-location nil
"Location of last mouse-down event, as crossword coords.")
(defun crossword-mouse-set-point (event)
"Set point with the mouse."
(interactive "@e")
(mouse-set-point event)
(let ((coords (crossword-cursor-coords)))
(setq crossword-mouse-location coords)
(crossword-place-cursor (car coords)
(cdr coords))))
#+END_SRC
interactive声明中的@表示“在做任何事情之前,找到任何的触发这个命令的鼠标点击(如果有的话)并且选中点击发生的窗口”。code letter e告诉interactive将触发这个命令的鼠标事件打包为一个列表并且赋给event。
我们并不需要从这个事件结构中得到任何信息,但是我们需要将它传递给mouse-set-point,它需要使用event当中储存的窗体位置数据来为point计算一个新的位置。当point放置完成后,我们可以调用crossword-cursor-coords来计算并且记住所在的网格座标。最后我们调用crossword-place-cursor,因为每个格子都有两列宽而mouse-set-point可能把光标放到了错误的列上。
下面我们为这三个鼠标按下事件建立绑定:
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map [down-mouse-1]
'crossword-mouse-set-point)
(define-key crossword-mode-map [down-mouse-2]
'crossword-mouse-set-point)
(define-key crossword-mode-map [down-mouse-3]
'crossword-mouse-set-point)
#+END_SRC
现在我们分别来看每个鼠标释放的操作。我们希望释放按键1与按下按键1做的事情一样,所以简单的把mouse-1绑定到down-mouse-1所绑定的指令就可以了:
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map [mouse-1]
'crossword-mouse-set-point)
#+END_SRC
下面是用来放置障碍和擦除格子的命令:
#+BEGIN_SRC emacs-lisp
(defun crossword-mouse-block (event)
"Place a block with the mouse."
(interactive "@e")
(mouse-set-point event)
(let ((coords (crossword-cursor-coords)))
(if (equal coords crossword-mouse-location)
(crossword-block-command))))
(defun crossword-mouse-erase (event)
"Erase a cell with the mouse."
(interactive "@e")
(mouse-set-point event)
(let ((coords (crossword-cursor-coords)))
(if (equal coords crossword-mouse-location)
(crossword-erase-command))))
#+END_SRC
下面是对于这些命令的绑定:
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map [mouse-2]
'crossword-mouse-block)
(define-key crossword-mode-map [mouse-3]
'crossword-mouse-erase)
#+END_SRC
** 菜单命令
我们还没有用来检测单字母单词的用户命令;但是我们在本章的前面章节[[单字母单词][单字母单词]]中定义了一个crossword-one-letter-p。让我们用它来定义一个命令,crossword-find-singleton,用来找到网格中的单字母单词(如果存在的话)并且把光标移动到那里。
#+BEGIN_SRC emacs-lisp
(defun crossword-find-singleton ()
"Jump to a one-letter word, if one exists."
(interactive)
(let ((row O)
(size (crossword-size crossword-grid))
(result nil))
(while (and (< row size)
(null result))
(let ((column 0))
(while (and (< column size)
(null result))
(if (crossword-one-letter-p crossword-grid
row column)
(setq result (cons row column))
(setq column (+ column 1)))))
(setq row (+ row 1)))
(if result
(crossword-place-cursor (car result)
(cdr result))
(message "No one-letter words."))))
#+END_SRC
这个函数会遍历网格中的所有格子,检测它是不是一个单字母单词,找到第一个的时候停止或者显式信息“No one-letter words.”
我们可以将其绑定到一个按键上。C-c 1展示了它的用途。
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map "\C-c1"
'crossword-find-singleton)
#+END_SRC
但是对于用户来讲检测单字母单词并不像光标操作和其他命令那样是一个常用操作。用户可能并不希望为此记住一个按键绑定。既然它并不会频繁的使用,将它放到菜单上就是一个很好的选择。
定义菜单项是很简单的,这涉及到了键位表的另一个方面。首先我们需要定义一个新的键位表,它需要包含菜单“card”的菜单项。后面我们会为这个菜单添加一个顶级的菜单栏“Crossword”。
#+BEGIN_SRC emacs-lisp
(defvar crossword-menu-map nil
"Menu for Crossword mode.")
(if crossword-menu-map
nil
(setq crossword-menu-map (make-sparse-keymap "Crossword"))
(define-key crossword-menu-map [find-singleton]
'("Find singleton" . crossword-find-singleton)))
#+END_SRC
菜单键位表必须有一个“总提示语”。这也就是make-sparse-keymap中的可选参数“Crossword”的意义。
当前我们的菜单只有一个菜单项。它绑定到了一个自定义的事件符号find-singleton。这个“事件”绑定到了一个包含着字符串“Find singleton”以及符号crossword-find-singleton的cons cell。字符串用于显示菜单项的描述。符号则是选中菜单项时要触发的函数名称。自定义的事件符号find-singleton是没有意义的,它只需要跟同一个菜单中的其他符号不重名就行了。
要把这个菜单放到顶级的菜单栏上,我们必须为这个菜单选择另一个代表它的全局符号;这里我们使用crossword。现在,只需要将菜单键位表绑定到一个自定义的事件序列[menu-bar crossword]就可以了。
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map [menu-bar crossword]
(cons "Crossword" crossword-menu-map))
#+END_SRC
这次,绑定被放到了crossword-mode-map里,这会使我们能够访问到crossword-menu-map中的菜单项。事件符号menu-bar代表了全局的菜单栏。事件序列[menu-bar crossword]选中了Crossword菜单的键位表,而事件序列[menu-bar crossword find-singleton]表示用户通过菜单选中了“Find singleton”菜单项。
* 追踪未授权的修改
即使我们竭尽全力的防止用户对于buffer的不合法的修改,但是用户总是可以找到什么方法去修改它。这时屏幕上的填字游戏网格就不匹配crossword-grid中的数据结构了。我们如何才能恢复它呢?
一种方法是将一个函数添加到每次buffer发生改变都会触发的after-change-functions(参照[[file:4.org][第四章]]中[[file:4.org::*聪明的方式][聪明的方式]]一节)里。如果改变是“未授权的”,我们必须将buffer与crossword-grid数据结构重新同步。
什么是“未授权的”呢?它是“授权”的反义词,所以让我们添加一个方法来“授权”buffer中的改变。
#+BEGIN_SRC emacs-lisp
(defvar crossword-changes-authorized nil
"Are changes currently authorized?")
(make-variable-buffer-local 'crossword-changes-authorized)
(defmacro crossword-authorize (&rest subexprs)
"Execute subexpressions, authorizing changes."
'(let ((crossword-changes-authorized t))
,@subexprs))
#+END_SRC
这是一个可以在buffer发生改变的时候包裹函数体的宏。它将crossword-changes-authorized暂时设为t,执行函数体,然后把crossword-changes-authorized重置为之前的值。默认的,改变都是未授权的。所以为了防止用户对buffer造成破坏,我们必须重写crossword-insert-grid和crossword-update-display来授权它们做出的更改:
#+BEGIN_SRC emacs-lisp
(defun crossword-insert-grid (crossword)
"Insert CROSSWORD into the current buffer."
(crossword-authorize
(mapcar 'crossword-insert-row crossword)))
(defun crossword-update-display (crossword)
"Called after a change, keeps the display up to date."
(crossword-authorize
(let* ((coords (crossword-cursor-coords))
(cousin-coords (crossword-cousin-position crossword
(car coords)
(cdr coords))))
(save-excursion
(crossword-place-cursor (car coords)
(cdr coords))
(delete-char 2)
(crossword-insert-cell (crossword-ref crossword
(car coords)
(cdr coords)))
(crossword-place-cursor (car cousin-coords)
(cdr cousin-coords))
(delete-char 2)
(crossword-insert-cell (crossword-ref crossword
(car cousin-coords)
(cdr cousin-coords)))))))
#+END_SRC
然后我们必须向after-change-functions添加一个函数用来检测当crossword-changes-authorized不为真的时候发生的改变:
#+BEGIN_SRC emacs-lisp
(defun crossword-after-change-function (start end len)
"Recover if this change is not authorized."
(if crossword-changes-authorized
nil ; 如果改变是经过授权的则什么都不做
recover somehow
))
(make-local-hook 'after-change-functions)
(add-hook 'after-change-functions
'crossword-after-change-function)
#+END_SRC
要知道一个用户指令可能会造成很多改变,我们不能每次执行完一个命令就“尝试恢复”几次。这表示在当前的命令执行完成之后(可能发生了许多改变),我们应该检测有哪些未经授权的改变发生了,然后重新同步。因此我们还应该向post-command-hook中添加一个函数(在每次执行完一个用户指令之后执行一次)。
我们需要创建一个新的变量,crossword-unauthorized-change,用来告诉我们当前的指令是否造成了未授权的改变。我们需要修改crossword-after-change-function来设置它,然后创建一个新的函数,crossword-post-command-function,来测试它:
#+BEGIN_SRC emacs-lisp
(defvar crossword-unauthorized-change nil
"Did an unauthorized change occur?")
(make-variable-buffer-local 'crossword-unauthorized-change)
(defun crossword-after-change-function (start end len)
"Recover if this change is not authorized."
(if crossword-changes-authorized
nil
(setq crossword-unauthorized-change t)))
(defun crossword-post-command-function ()
"After each command, recover from unauthorized changes."
(if crossword-unauthorized-change
resynchronize)
(setq crossword-unauthorized-change nil))
#+END_SRC
然后把它们添加到crossword--mode-setup里:
#+BEGIN_SRC emacs-lisp
(make-local-hook 'after-change-functions)
(add-hook 'after-change-functions
'crossword-after-change-function)
(make-local-hook 'post-command-hook)
(add-hook 'post-command-hook
'crossword-post-command-function)
#+END_SRC
对于重新同步,我们有两种选择:相信buffer的内容然后更新crossword-grid中的数据结构;或者相信crossword-grid,清除buffer的内容然后使用crossword-insert-grid重新插入网格。
表面来看,没有理由认为buffer里的可见内容会比我们内部的数据结构更可信,因为显然buffer会比数据结构更容易发生损坏。但是,至少有一大原因使我们至少应该试着去相信buffer:撤销(undo)命令。如果用户执行了撤销,buffer将会回滚到最后一个命令执行之前的状态。这是有意义的。但是这并不会回滚crossword-grid的状态。因此,我们应该使用“未授权改变鉴别器”重新解析buffer中的网格(我们知道我们能做到,因为我们已经约定了crossword-parse-buffer的定义)。如果失败了,那么很有可能是因为buffer的格式不正确了,那么我们应该擦除掉buffer然后插入一个正确的网格。
下面是crossword-post-command-function的完成体:
#+BEGIN_SRC emacs-lisp
(defun crossword-post-command-function ()
"After each command, recover from unauthorized changes."
(if crossword-unauthorized-change
(let ((coords (crossword-cursor-coords)))
(condition-case nil
(setq crossword-grid (crossword-parse-buffer))
(error (erase-buffer)
(crossword-insert-grid crossword-grid)))
(crossword-place-cursor (car coords)
(cdr coords))))
(setq crossword-unauthorized-change nil))
#+END_SRC
这个函数使用了condition-case,一个与unwind-protect([[file:8.org][第八章]]中[[file:8.org::*优雅的失败][优雅的失败]]章节中第一次引入)类似的特殊结构。unwind-protect看起来是这样的:
#+BEGIN_SRC emacs-lisp
(unwind-protect
body
unwind ...)
#+END_SRC
它会执行body,是否完成取决于执行过程中是否有错误产生。不管body是否成功完成,unwind最后都会执行。
condition-case和unwind-protect的区别在于condition-case包含着只在错误的时候执行的表达式。它使用起来是这样的:
#+BEGIN_SRC emacs-lisp
(condition-case var
body
(symbol1 handler ...)
(symbol2 handler ...)
...)
#+END_SRC
如果body因为“信号条件(signaled condition)”而终止,后面的处理子句之一将会执行来“捕获”那个错误。子句执行的条件是其symbol与信号条件相同。在这里,我们只关心称为error的信号条件(通过error函数发出),所以我们对于condition-case的使用看起来是这样的:
#+BEGIN_SRC emacs-lisp
(condition-case var
body
(error handler ...))
#+END_SRC
如果var不为空,那么它就是当某个子句执行的时候,Emacs用来存放当前错误的变量名称--也就是发出这个信号条件的error的参数。但是在我们的例子里,由于我们并不需要这个信息,所以var是nil。
我们需要将crossword-grid赋值为crossword-parse-buffer执行的结果。如果解析失败,crossword-parse-buffer发出一个错误信号,这会使condition-case在替换crossword-grid的值之前终止掉。如果这发生了,错误处理子句将会执行,擦除掉buffer并且插入正确的crossword-grid的拷贝。
不管何种情况,我们最后都会把光标放置到函数开始时记录的网格座标处;但是假如buffer的结构已经坏到连获取座标也失败了呢?因此我们应该有两次对于condition-case的调用:
#+BEGIN_SRC emacs-lisp
(defun crossword-post-command-function ()
"After each command, recover from unauthorized changes."
(if crossword-unauthorized-change
(condition-case nil
(let ((coords (crossword-cursor-coords)))
(condition-case nil
(setq crossword-grid (crossword-parse-buffer))
(error (erase-buffer)
(crossword-insert-grid crossword-grid)))
(crossword-place-cursor (car coords)
(cdr coords)))
(error (erase-buffer)
(crossword-insert-grid crossword-grid)
(crossword-place-cursor 0 0))))
(setq crossword-unauthorized-change nil))
#+END_SRC
外面的那个condition-case用来处理crossword-cursor-coords的错误。它会擦除掉buffer,重新插入网格,然后把光标放置到左上角。里面的condition-case用来处理crossword-parse-buffer,擦除并且重新插入网格,然后重置之前记录的光标位置。
既然现在我们可以跟踪并且恢复buffer中未授权的修改,我建议从crossword-mode-map中移除掉对于所有按键绑定的捕获,
#+BEGIN_SRC emacs-lisp
(define-key crossword-mode-map [t] 'undefined)
#+END_SRC
毕竟这有点太过头了,这会使得我们无法利用类似C-k和C-y这样的无害并且有益的命令。
既然填字游戏数据会被存储到文本文件里,那么用户就有可能使用其他编辑器去破坏它,或者使用Emacs但是不用Crossword模式。这些改变的大多数将会使得Crossword模式初始化的时候解析失败。
* 解析Buffer
下面是一个crossword-parse-buffer的定义:
#+BEGIN_SRC emacs-lisp
(defun crossword-parse-buffer ()
"Parse the crossword grid in the current buffer."
(save-excursion
(goto-char (point-min))
(let* ((line (crossword-parse-line))
(size (length line))
(result (make-crossword size))
(row 1))
(crossword--handle-parsed-line line 0 result)
(while (< row size)
(forward-line 1)
(setq line (crossword-parse-line))
(if (not (= (length line) size))
(error "Rows vary in length"))
(crossword--handle-parsed-line line row result)
(setq row (+ row 1)))
result)))
#+END_SRC
这个函数会调用crossword-parse-line,它会解析一行文本并且转化成列表。列表的长度会告诉我们填字游戏网格的长宽(因为网格是方形的)。然后我们对这个size调用crossword-parse-line,每次一行。每当解析完一行,我们通过调用crossword--handle-parsed-line来填充一行result中保存的数据结构。它的定义如下:
#+BEGIN_SRC emacs-lisp
(defun crossword--handle-parsed-line (line row grid)
"Take LINE and put it in ROW of GRID."
(let ((column 0))
(while line
(cond ((eq (car line) 'block)
(crossword-store-block grid row column))
((eq (car line) nil)
(crossword-clear-cell grid row column))
((numberp (car line))
(crossword-store-letter grid row column (car line))))
(setq line (cdr line))
(setq column (+ column 1)))))
#+END_SRC
下面是crossword-parse-line,它是crossword-parse-buffer的主干:
#+BEGIN_SRC emacs-lisp
(defun crossword-parse-line ()
"Parse a line of a Crossword buffer."
(beginning-of-line)
(let ((result nil))
(while (not (eolp))
(cond ((eq (char-after (point)) ?#)
(setq result (cons 'block result)))
((eq (char-after (point)) ?.)
(setq result (cons nil result)))
((eq (char-after (point)) ??)
(setq result (cons nil result)))
((looking-at "[A-Za-z]")
(setq result (cons (char-after (point))
result)))
(t (error "Unrecognized character")))
(forward-char 1)
(if (eq (char-after (point)) ?\ )
(forward-char 1)
(error "Non-blank between columns")))
(reverse result)))
#+END_SRC
这里每次读取两个字符。第一个字符应该是一个井号(#),点号(.),问号(?,与.一样处理),或者一个字母。cond表达式告诉我们在每种情况下如何处理。如果这些都不是,则发出一个错误信号--“Unrecognized character”。否则,下一个字符则应该是用来分割网格的列的空格。再一次,如果它不是,那么触发错误。
结果会通过cons存储到result中,也就是说每一行的第一个元素会出现在列表的最后,第二个会出现在倒数第二,依此类推。所以函数最后要做的是调用reverse来得到正确次序的列表。
另一件事:如果一个Emacs模式只能用来处理特定格式的文本,那么需要给模式符号设置一个special属性:
#+BEGIN_SRC emacs-lisp
(put 'crossword-mode 'mode-class 'special)
#+END_SRC
这会告诉Emacs不要将Crossword模式用作其他buffer的默认模式,因为它只能处理已经包含可解析的填字游戏网格的buffer。
* 词语查找器
直到目前为止,Crossword模式并不比一张图片好多少。它会记录你把什么字母放到了什么位置,但是对于谜题的设计者来说它并不会提供什么帮助。设计填字游戏谜题的真正难点并不在于记录每个格子里填写了什么;而在于找到能够与你选择的单词相匹配的词,例如你需要一个五个字母的单词而后三个字母需要是“fas”。
可以使用标准的UNIX工具来帮助你寻找合适的单词。UNIX程序grep,通过给定一个合适的正则表达式,可以帮助从词语文件里找到匹配的词语。大多数UNIX系统都在/usr/dict/words下或者/usr/lib/dict/words下有一个词语文件,或者在GNU系统里的/usr/local/share/dict/words。
如果词语文件中每个单词一行,那么可以通过下面的UNIX命令找到一个五个字母并且以“fas”结尾的单词:
#+BEGIN_SRC shell
grep -i '..fas$' word-file
#+END_SRC
(-i告诉grep大小写敏感。)这个指令会返回我们结果,“sofas”。
如果我们只需要触发一次按键然后让Emacs帮助我们构建正确的正则表达式并且运行grep不是很棒吗?
下面就是它的工作方式。当光标在一个格子上时,C-c h将会横向搜索适合的单词,C-c v会纵向搜索。在这两种情况下,函数会从左到右,或者从上到下查找最近的障碍。中间的格子被用来构建正则表达式。空格或者“letter”格子变为点号(.);字母变为它们自己。正则表达式以^开头,以$结尾。将这个正则表达式传递给grep,结果返回到一个临时buffer里。
** 第一次尝试
为了简化,让我们先只设计这个命令的横向版本。让我们称它为crossword-hwords。我们要做的第一件事是得到光标位置并且检测当前格子的类型。
#+BEGIN_SRC emacs-lisp
(defun crossword-hwords ()
"Pop up a buffer listing horizontal words for current cell."
(interactive)
(let ((coords (crossword-cursor-coords)))
(if (eq (crossword-ref crossword-grid
(car coords)
(cdr coords))
'block)
(error "Cannot use this command on a block"))
#+END_SRC
如果当前的格子是个障碍的话则终止。没有单词可以跨越一个障碍(不管是横向还是纵向)。否则的话:
#+BEGIN_SRC emacs-lisp
(let ((start (- (cdr coords) 1))
(end (+ (cdr coords) 1)))
#+END_SRC
我们使用start和end来记录当前格子左面和右面的第一个障碍。
#+BEGIN_SRC emacs-lisp
(while (not (crossword-block-p crossword-grid
(car coords)
start))
(setq start (- start 1)))
#+END_SRC
这会把start向左移动直到遇到一个障碍。crossword-block-p认为网格的边界是由“障碍”围起来的,所以这个循环会保证当遇到网格的边界时停止。
#+BEGIN_SRC emacs-lisp
(while (not (crossword-block-p crossword-grid
(car coords)
end))
(setq end (+ end 1)))
#+END_SRC
end与之前的start一样,只是把向左替换成了向右。
#+BEGIN_SRC emacs-lisp
(let ((regexp "^")
(column (+ start 1)))
(while (< column end)
#+END_SRC
这几行用来准备拼装正则表达式,从start后面的格子开始,到end前面的格子结束。
#+BEGIN_SRC emacs-lisp
(let ((cell (crossword-ref crossword-grid
(car coords)
column)))
(if (numberp cell)
(setq regexp (concat regexp
(char-to-string cell)))
(setq regexp (concat regexp "."))))
#+END_SRC
这会检测while循环中的格子是否是一个字母。如果是的话,我们将它添加到正则表达式中;否则添加一个点号(.)。
(我们使用char-to-string来将一个字母?a转变为“a”,因为只有字符串才能被传递给concat。)
然后我们递增column来进行下一次循环:
#+BEGIN_SRC emacs-lisp
(setq column (+ column 1)))
#+END_SRC
在循环退出时,我们在正则表达式的最后添加一个$:
#+BEGIN_SRC emacs-lisp
(setq regexp (concat regexp "$"))
#+END_SRC
然后,我们创建一个buffer来获取grep的结果:
#+BEGIN_SRC emacs-lisp
(let ((buffer (get-buffer-create "*Crossword words*")))
#+END_SRC
函数get-buffer-create会使用指定的名字返回一个buffer对象。如果已经存在叫这个名字的buffer,则返回这个buffer,否则创建一个。(如果你不希望重用旧的buffer,你可以使用generate-new-buffer来直接创建一个。)
#+BEGIN_SRC emacs-lisp
(set-buffer buffer)
#+END_SRC
我们暂时的选中*Crossword words* buffer,使它成为“当前的”。set-buffer的作用范围只到当前命令的结束,而并不会影响用户所认为的当前buffer。(如果需要的话,我们可以调用switch-to-buffer。)
#+BEGIN_SRC emacs-lisp
(erase-buffer)
#+END_SRC
这会清空buffer,避免我们重用之前调用crossword-hwords遗留下来的buffer。
现在调用call-process来执行grep程序:
#+BEGIN_SRC emacs-lisp
(call-process "grep"
nil t nil
"-i" regexp
"/usr/local/share/dict/words")
#+END_SRC
我们不应该直接通过名称“grep”来使用这个程序,更好的方式是通过一个变量--例如,crossword-grep-program--在上面的调用中替代“grep”。如果另一个grep程序更适合,用户可以更改这个变量。我们可以对词语文件做同样的处理,使用一个变量crossword-words-file来代替直接的命名/usr/local/share/dict/words。
call-process中的参数nil,t和nil表示:
1. “这个程序不需要‘标准输入’。”它的输入来自于后面命令行参数中的文件名。如果使用了一个非nil的参数,这个字符串需要指向一个作为输入的文件名。如果为t,当前的buffer会被用作程序的输入。
2. “将输出发送到当前的buffer”(例如,*Crossword words* buffer)。nil表示“丢弃输出”。0表示“丢弃输出并且马上返回(不等待程序执行完成)”。buffer对象表示将输出发送到哪个buffer。
参数也可以是一个包含两个元素的列表,而每个元素都是我们刚才描述的参数之一。列表中的第一个参数表示在哪里存放程序的“标准输出”。第二个元素表示在哪里存放程序的“标准错误”。
3. “不要在数据到来的时候马上刷新buffer”(这会减慢程序的执行)。Emacs会在程序结束之后再在*Crossword words* buffer中显式所有的输出。
call-processs剩下的参数作为命令行参数传递给grep:-i表示关闭大小写敏感;regexp,包含着我们之前拼接的正则表达式;而/usr/local/share/dict/words则是grep用来搜索的文件。
crossword-hwords要做的最后一件事是展示*Crossword words* buffer所包含的grep的输出。这通过display-buffer来实现:
#+BEGIN_SRC emacs-lisp
(display-buffer buffer))))))
#+END_SRC
这就是我们第一个版本的crossword-hwords。
如果你只希望查找在两个已经存在的障碍之间填充的单词的话,这个版本的crossword-hwords是不错的;但是有时你可能会根据需要寻找一些更短的单词并且插入一些障碍。例如,如果你有一个看起来这样的行:
#+BEGIN_SRC emacs-lisp
. . . . . . . a d a c . . . .
#+END_SRC
而你按下了C-c h,你会得到一个提示“asclepiadaceous”。但是你可能希望把这行变成这样:
#+BEGIN_SRC emacs-lisp
. . . . # h e a d a c h e # #
#+END_SRC
问题是,crossword-hwords只会计算正则表达式“^.......adac....$”,而“headache”并不符合这个正则。
我们可以尝试移除掉正则中的^和$,以及前面和后面的点,这样就剩下了adac。如果把这个正则传递给grep,它将会找到“headache”。但是它也会找到“tetracadactylity”,而它的长度比需要的长1(而adac的位置无论如何也不对)。
** 第二次尝试
一种不错的方式是构建一个这样的正则:“^.?.?.?.?.?.?.?adac.?.?.?.?$”。每个.?代表了0个或1个字符;所以整个正则表达式会匹配0-7个字符,然后是“adac”,后面跟着0-4个字符。这个表达式会包含“headache”而剔除“tetracadactylity”。
让我们再尝试一次:
#+BEGIN_SRC emacs-lisp
(defun crossword-hwords ()
"Pop up a buffer listing horizontal words for current cell."
(interactive)
(let ((coords (crossword-cursor-coords)))
(if (eq (crossword-ref crossword-grid
(car coords)
(cdr coords))
'block)
(error "Cannot use this command on a block"))
(let ((start (- (cdr coords) 1))
(end (+ (cdr coords) 1)))
(while (not (crossword-block-p crossword-grid
(car coords)
start))
(setq start (- start 1)))
(while (not (crossword-block-p crossword-grid
(car coords)
end))
(setq end (+ end 1)))
#+END_SRC
直到现在,这与之前一样:start和end指向两边的障碍。
现在让我们给这个函数引入一个新的概念:也就是正则表达式的核心(core)。我们将使用这个概念来匹配每个字母都必须一致的部分。
前面和后面的障碍并不是必须匹配的;它们是可选的。但是从第一个字母到最后一个字母必须匹配,即使中间有障碍。所以当我们构建匹配下面这行的正则时:
#+BEGIN_SRC emacs-lisp
. . . bar . foo . . . .
#+END_SRC
“核心”是bar.foo,而整个正则表达式前面有三个可选字符而后面有五个:^.?.?.?bar.foo.?.?.?.?.?$就是我们想要的。
这表示我们必须找到填字游戏网格的核心。在正则表达式中任何核心外的空白都需要转化成.?。任何核心内的空白都转化成.(点号)。
我们将以start和end来开始我们的改进:
#+BEGIN_SRC emacs-lisp
(let ((corestart (+ start 1))
(coreend (- end 1)))
(while (null (crossword-ref crossword-grid
(car coords)
corestart))
(setq corestart (+ corestart 1)))
(while (null (crossword-ref crossword-grid
(car coords)
coreend))
(setq coreend (- coreend 1)))
#+END_SRC
这会把corestart向右移动而coreend向左移动来跳过空白格子。注意start和end间可能并没有“核心”的存在。在这个例子里,corestart向end移动而coreend向start移动。这没有问题,因为下面这段代码中我们使用corestart和coreend的方式对这个特性是不敏感的:
#+BEGIN_SRC emacs-lisp
(let ((regexp "^")
(column (+ start 1)))
(while (< column end)
(if (or (< column corestart)
(> column coreend))
(setq regexp
(concat regexp ".?"))
#+END_SRC
这里,如果我们还没有找到核心,或者我们已经过去了,我们会向正则中添加.?。注意如果没有核心的话,我们总是添加.?。[[[10-44][44]]]
如果我们在核心中,我们的处理与之前一样--除了我们现在调用egrep而不是grep,因为grep不理解?语法而egrep理解:
#+BEGIN_SRC emacs-lisp
(let ((cell (crossword-ref crossword-grid
(car coords)
column)))
(if (numberp cell)
(setq regexp (concat regexp
(char-to-string cell)))
(setq regexp (concat regexp ".")))))
(setq column (+ column 1)))
(setq regexp (concat regexp "$"))
(let ((buffer (get-buffer-create "*Crossword words*")))
(set-buffer buffer)
(erase-buffer)
(call-process "egrep"
nil t nil
"-i" regexp
"/usr/local/share/dict/words")
(display-buffer buffer)))))))
#+END_SRC
再次的,你可能会希望使用crossword-egrep-program和crossword-words-file来代替egrep和/usr/local/share/dict/words。实际上,本章剩下的部分将会采取这种方式。
命令crossword-vwords--crossword-hwords的纵向版本--大体上与crossword-hwords相同。定义并且抽取出两个函数公用代码的工作就留给读者自己做了。
** 异步egrep
刚刚编写的crossword-hwords调用了egrep,等待它的完成,然后显示出运行结果。但是假设你使用了不是egrep的其他程序;或者假设你将crossword-words-file设置为一个访问缓慢的网络上的地址。crossword-hwords因此可能会需要一段时间来执行,而Emacs在这段时间里都是不可用的。
如果crossword-hwords只是启动egrep的执行,然后让它“在后台运行”,而让用户能够继续与Emacs交互就好多了。为此,我们可以使用Emacs的异步进程(asynchronous process)对象。
异步进程对象是一个用来表示在你的电脑上运行的另一个程序的Lisp数据结构。新的进程通过start-process创建,它与call-process很像(在前面章节中我们见过)。但是不像call-process,start-process并不会等待程序执行完成。相反,它会返回一个进程对象。
对于进程对象我们能做很多事。你可以发送输入给正在运行的进程;你可以发送信号;你可以杀掉进程。你可以问进程查询状态(例如查询它正在运行还是已经退出了)。你可以将这个进程绑定到一个Emacs buffer。
让我们使用start-process来重写crossword-hwords。为了节省空间,我们只关注crossword-hwords的结尾。下面是之前的版本:
#+BEGIN_SRC emacs-lisp
(let ((buffer (get-buffer-create "*Crossword words*")))
(set-buffer buffer)
(erase-buffer)
(call-process crossword-egrep-program
nil t nil
"-i" regexp
crossword-words-file)
(display-buffer buffer)))))))
#+END_SRC
下面是使用start-process的版本:
#+BEGIN_SRC emacs-lisp
(let ((buffer (get-buffer-create "*Crossword words*")))
(set-buffer buffer)
(erase-buffer)
(start-process "egrep"
buffer
crossword-egrep-program
"-i" regexp
crossword-words-file)
(display-buffer buffer)))))))
#+END_SRC
不同之处只是我们改用了start-process,然后调整了参数的顺序。start-process的第一个参数(例子中的“egrep”)是Emacs内部一个用来引用进程的名称。(它并不必须是要运行的程序名称。)下一个是要接收输出的buffer,如果存在的话;然后是要运行的程序,以及它的参数。
在程序运行之后,start-process马上返回,也就是说display-buffer会马上执行。但是我们可能并不希望*Crossword words* buffer马上显示。我们希望它在egrep运行结束之后再显示。所以我们需要一个方法来得到进程什么时候退出。当那发生的时候,我们才希望调用display-buffer。
为此,我们需要对这个进程对象添加一个sentinel。sentinel是一个当进程状态改变时会调用的Lisp函数。我们对程序退出时的状态改变感兴趣;但是状态改变在进程收到信号之后也会发生。
下面是一个调用start-process,然后添加了当进程退出的时候显示buffer的sentinel的版本。为了安装sentinel,我们必须保存start-process返回的进程对象然后把它传递给set-process-sentinel:
#+BEGIN_SRC emacs-lisp
(let ((buffer (get-buffer-create "*Crossword words*")))
(set-buffer buffer)
(erase-buffer)
(let ((process
(start-process "egrep"
buffer
crossword-egrep-program
"-i" regexp crossword-words-file)))
(set-process-sentinel process
'crossword--egrep-sentinel))))))))
#+END_SRC
我们可以这样定义crossword--egrep-sentinel:
#+BEGIN_SRC emacs-lisp
(defun crossword--egrep-sentinel (process string)
"When PROCESS exits, display its buffer."
(if (eq (process-status process)
'exit)
(display-buffer (process-buffer process))))
#+END_SRC
调用进程sentinel时有两个参数:进程对象,以及一个用来描述状态改变的字符串。我们会忽略掉这个字符串。我们通过检测进程的状态来看它是否已经退出了。如果已经退出了,我们就显示进程buffer,这可以通过process-buffer找到。这个buffer我们在调用start-process的时候传递过了。
假设我们不希望显示buffer等待egrep的退出,但是我们也不希望马上显示。相反,我们希望在第一个结果到来之后就显示它。为此,我们需要对进程对象安装一个filter。
filter是一个当进程有输出的时候就会调用的函数。当进程没有filter的时候,输出会进入到关联的buffer里。但是当filter存在的时候,filter函数负责输出的去向。所以让我们稍微修改我们的例子,使用一个filter函数来(a)将输出放到buffer里然后(b)显示那个buffer。
#+BEGIN_SRC emacs-lisp
(let ((buffer (get-buffer-create "*Crossword words*")))
(set-buffer buffer)
(erase-buffer)
(let ((process
(start-process "egrep"
buffer
crossword-egrep-program
(set-process-filter process
"-i" regexp
crossword-words-file)))
(set-process-filter process
'crossword--egrep-filter)
(set-process-sentinel process
'crossword--egrep-sentinel))))))))
#+END_SRC
我们保留着sentinel,这样保证了egrep退出之后会显示buffer,即使并没有输出。
下面是我们对于crossword--egrep-filter的定义:
#+BEGIN_SRC emacs-lisp
(defun crossword--egrep-filter (process string)
"Handle output from PROCESS."
(let ((buffer (process-buffer process)))
(save-excursion
(set-buffer buffer)
(goto-char (point-max))
(insert string))
(display-buffer buffer)))
#+END_SRC
调用filter有两个参数:进程对象,以及一个刚刚到来的输出字符串。我们找到进程的buffer然后把输出写入到它的最后。然后我们通过调用display-buffer来确保buffer的显示。
因为filter(以及sentinel)可能会被调用很多次(这也就是异步编程的本质),我们必须确保这不会造成什么不好的副作用。这表示他们必须额外做一些同步函数不用关心的事。例如,每次命令结束之后,Emacs会恢复之前选中buffer的记录;在命令的执行过程中,函数可能会在不影响用户当前可见buffer的情况下调用set-buffer来做出更改。恢复选中buffer只会发生在命令结束之后--而post-command-hook也差不多这时候执行。因为异步函数可能会在命令结束之后执行,因此任何对于set-buffer的调用最后可能都不会重置,这会造成我们不希望看到的结果。这也就是为什么crossword--egrep-filter使用了save-excursion的原因。
关于start-process的另一件事。当Emacs创建进程的时候,它会通过UNIX的管道或者伪终端(pseudo-ttys,ptys)保持一个对于它的连接(通过它进行输入输出流)。管道对于像egrep这种不需要交互的进程来说更合适,而伪终端对于交互程序更合适--例如像UNIX shell这样的命令解析器。start-process创建的连接的种类被变量process-connection-type控制--nil表示使用管道,t表示伪终端。虽然有点古怪,但是最好每次调用start-process的时候都用let暂时把process-connection-type设置为需要的值,例如:
#+BEGIN_SRC emacs-lisp
...
(let ((process-connection-type nil))
(start-process "egrep"
buffer
crossword-egrep-program
"-i" regexp crossword-words-file))
...
#+END_SRC
** 选择单词
现在让我们添加能够从*Crossword words* buffer中选择单词并且自动插入到填字游戏网格中的功能。
我们需要做的第一件事是在*Crossword words* buffer中存储一些额外的信息--即存储在buffer的局部变量中。如果我们希望在buffer中的一个单词上按下RET之后这个单词会填写到Crossword buffer中正确的位置上,那么*Crossword words*必须知道正确的Crossword buffer是哪个以及最终摆放在哪里。
下面是两个buffer间必须要传递的数据。
1. start + 1的值--即单词在网格中开始的位置。
2. 当前的单词搜索是横向还是纵向。之前的例子中都限定为横向,但是要记住实际上是有两个方向的。
3. 正则表达式的“核心”的相关信息。要解释为什么这是必须的,让我们考虑一下我们前面的例子:网格的行看起来是这样的:
#+BEGIN_SRC emacs-lisp
. . . . . . . a d a c . . . .
#+END_SRC
crossword-hwords对这行生成的正则表达式是“^.?.?.?.?.?.?.?adac.?.?.?.?$”。“核心”是adac,“前缀”是“.?.?.?.?.?.?.?”而“后缀”是“.?.?.?.?”。当用户选择的时候,例如,从*Crossword words* buffer中选择了adactyl,它应该放置到行的什么位置呢?它应该这样放置吗:
#+BEGIN_SRC emacs-lisp
a d a c t y l a d a c . . . .
#+END_SRC
当然不是;它应该这样放:
#+BEGIN_SRC emacs-lisp
. . . . . . . a d a c t y l .
#+END_SRC
为了在行中正确的放置单词,就很有必要知道前缀长7个字符,而正则的“核心”在单词adactyl的位置0处。通常,如果前缀有p个字符长,而核心能够在选择的单词的位置m处找到,那么我们在摆放单词的时候就应该跳过p-m个字符。
为了把这些存储在*Crossword words* buffer的局部变量里,以及使RET表示“选中光标所在处的单词”,让我们为这个buffer定义一个小的主模式。让我们称它为crossword-words-mode,如下所示:
#+BEGIN_SRC emacs-lisp
(defvar crossword-words-mode-map nil
"Keymap for crossword-words mode.")
(defvar crossword-words-crossword-buffer nil
"The associated crossword buffer.")
(defvar crossword-words-core nil
"The core of the regexp.")
(defvar crossword-words-prefix-len nil
"Length of the regexp prefix.")
(defvar crossword-words-row nil
"Row number where the word can start.")
(defvar crossword-words-column nil
"Column number where the word can start.")
(defvar crossword-words-vertical-p nil
"Whether the current search is vertical.")
(if crossword-words-mode-map
nil
(setq crossword-words-mode-map (make-sparse-keymap))
(define-key crossword-words-mode-map "\r" 'crossword-words-select))
#+END_SRC
回车键在字符串中写作“\r”。
#+BEGIN_SRC emacs-lisp
(defun crossword-words-mode ()
"Major mode for Crossword word-list buffer."
(interactive)
(kill-all-local-variables)
(setq major-mode 'crossword-words-mode)
(setq mode-name "Crossword-words")
(use-local-map crossword-words-mode-map)
(make-local-variable 'crossword-words-crossword-buffer)
(make-local-variable 'crossword-words-core)
(make-local-variable 'crossword-words-prefix-len)
(make-local-variable 'crossword-words-row)
(make-local-variable 'crossword-words-column)
(make-local-variable 'crossword-words-vertical-p)
(run-hooks 'crossword-words-mode-hook))
#+END_SRC
我们还没定义crossword-words-select。我们一会再来做。首先,让我们重写crossword-hwords来做两件事:
+ 它必须保存正则的核心信息以及前缀的长度。为了简化,如果没有核心则提示错误并且终止操作。
+ 当创建单词列表buffer的时候,必须使它进入Crossword-words模式然后设置那几个局部变量。
如下所示:
#+BEGIN_SRC emacs-lisp
(defun crossword-hwords ()
"Pop up a buffer listing horizontal words for current cell."
(interactive)
(let ((coords (crossword-cursor-coords)))
(if (eq (crossword-ref crossword-grid
(car coords)
(cdr coords))
'block)
(error "Cannot use this command on a block"))
(let ((start (- (cdr coords) 1))
(end (+ (cdr coords) 1)))
(while (not (crossword-block-p crossword-grid
(car coords)
start))
(setq start (- start 1)))
(while (not (crossword-block-p crossword-grid
(car coords)
end))
(setq end (+ end 1)))
(let ((corestart (+ start 1))
(coreend (- end 1)))
(while (null (crossword-ref crossword-grid
(car coords)
corestart))
(setq corestart (+ corestart 1)))
#+END_SRC
直到这里,仍然与之前相同。
#+BEGIN_SRC emacs-lisp
(if (= corestart end)
(error "No core for regexp"))
#+END_SRC
这次,如果没有核心,则以错误终止。
#+BEGIN_SRC emacs-lisp
(while (null (crossword-ref crossword-grid
(car coords)
coreend))
(setq coreend (- coreend 1)))
(let ((core "")
(column corestart)
(regexp "^"))
#+END_SRC
我们这次由内向外构建正则,通过对核心求值开始:
#+BEGIN_SRC emacs-lisp
(while (<= column coreend)
(let ((cell (crossword-ref crossword-grid
(car coords)
column)))
(if (numberp cell)
(setq core (concat core
(char-to-string cell)))
(setq core (concat core ".")))
(setq column (+ column 1)))
#+END_SRC
现在core保存着正则的核心了。
然后生成正则的前缀:
#+BEGIN_SRC emacs-lisp
(setq column (+ start 1))
(while (< column corestart)
(setq regexp (concat regexp ".?"))
(setq column (+ column 1)))
#+END_SRC
...将core添加到前缀上:
#+BEGIN_SRC emacs-lisp
(setq regexp (concat regexp core))
#+END_SRC
...加上后缀:
#+BEGIN_SRC emacs-lisp
(setq column (+ coreend 1))
(while (< column end)
(setq regexp (concat regexp ".?"))
(setq column (+ column 1)))
(setq regexp (concat regexp "$"))
#+END_SRC
现在让我们移动到单词列表buffer,但是这次让我们把当前buffer记录在crossword-buffer中以在后面访问到它:
#+BEGIN_SRC emacs-lisp
(let ((buffer (get-buffer-create "*Crossword words*"))
(crossword-buffer (current-buffer)))
(set-buffer buffer)
#+END_SRC
现在让我们把*Crossword words*置入Crossword-words模式:
#+BEGIN_SRC emacs-lisp
(crossword-words-mode)
#+END_SRC
然后设置这些buffer局部变量:
#+BEGIN_SRC emacs-lisp
(setq crossword-words-crossword-buffer
crossword-buffer)
(setq crossword-words-core core)
(setq crossword-words-prefix-len (- corestart
(+ start 1)))
(setq crossword-words-row (car coords))
(setq crossword-words-column (+ start 1))
(setq crossword-words-vertical-p nil)
#+END_SRC
剩下的就与之前的一样了。
#+BEGIN_SRC emacs-lisp
(erase-buffer)
(let ((process
(let ((process-connection-type nil))
(start-process "egrep"
buffer
crossword-egrep-program
"-i" regexp
crossword-words-file))))
(set-process-filter process
'crossword--egrep-filter)
(set-process-sentinel process
'crossword--egrep-sentinel))))
#+END_SRC
现在所剩下的就是定义crossword-words-select。它的目的是找出光标所在的单词,找出核心在这个单词中的位置,然后找出这个单词应该摆放在填字游戏网格中的位置,然后将它放到那里。
#+BEGIN_SRC emacs-lisp
(defun crossword-words-select ()
(interactive)
(beginning-of-line)
(let* ((wordstart (point))
(word (progn (end-of-line)
(buffer-substring wordstart
(point))))
#+END_SRC
现在word保存着选中的行中的单词。
下一步我们使用string-match来找到核心在word中的位置。
#+BEGIN_SRC emacs-lisp
(corematch (string-match crossword-words-core
word))
#+END_SRC
现在corematch保存着核心在word中匹配的位置。
#+BEGIN_SRC emacs-lisp
(vertical-p crossword-words-vertical-p)
#+END_SRC
这会把buffer局部变量crossword-words-vertical-p拷贝给临时变量vertical-p,因为我们需要在Crossword buffer中取回它(那里并没有定义crossword-words-vertical-p)。
#+BEGIN_SRC emacs-lisp
(window (selected-window)))
#+END_SRC
这会记录包含着单词列表buffer的窗口。在这个函数的后面,我们会关闭这个窗口(但是并不销毁buffer),因为用户在选择完单词之后大概就不需要它了。
#+BEGIN_SRC emacs-lisp
(if (not corematch)
(error "This word does not fit"))
#+END_SRC
理论上这不可能--除非用户自己修改了单词列表buffer,所以最好还是检测一下。
#+BEGIN_SRC emacs-lisp
(let ((row (if vertical-p
(+ crossword-words-row
(- crossword-words-prefix-len corematch))
crossword-words-row))
(column (if vertical-p
crossword-words-column
(+ crossword-words-column
(- crossword-words-prefix-len corematch))))
#+END_SRC
现在row和column指出了在网格中我们应该放置单词的起始处。
#+BEGIN_SRC emacs-lisp
(i 0))
#+END_SRC
我们使用i来遍历word的字符,每次向网格中添加一个。
#+BEGIN_SRC emacs-lisp
(switch-to-buffer crossword-words-crossword-buffer)
#+END_SRC
这里使用了switch-to-buffer而不是set-buffer切换到Crossword buffer。这表示在命令结束之后Crossword buffer仍然处于选中状态。
#+BEGIN_SRC emacs-lisp
(while (< i (length word))
(crossword-store-letter crossword-grid
row
column
(aref word i))
(crossword-update-display crossword-grid
row
column)
(setq i (+ i 1))
(if vertical-p
(setq row (+ row 1))
(setq column (+ column 1)))))
#+END_SRC
这会把每个单词储存到网格里,并且按照需要横向或者纵向排列。在使用crossword-store-letter更新数据结构之后,通过调用crossword-update-display同步了显示。
当我们调用crossword-update-display时,我们并不希望更新包含着光标的格子;我们希望更新row和colum指向的刚刚存储了一个字母的格子。所以让我们提前约定,crossword-update-display使用网格座标作为可选参数,并且在它们提供的情况下替换光标所在的位置。我们将在下面修改crossword-update-display。
最后,让我们删除Crossword-words窗口以使用户专注于Crossword buffer。
#+BEGIN_SRC emacs-lisp
(delete-window window)))
#+END_SRC
下面是一个使用了可选网格座标参数的crossword-update-display版本,如果可选参数未提供则使用光标所在的位置。
#+BEGIN_SRC emacs-lisp
(defun crossword-update-display (crossword &optional row column)
"Called after a change, keeps the display up to date."
(crossword-authorize
(if (or (null row)
(null column))
(let ((coords (crossword-cursor-coords)))
(setq row (car coords)
column (cdr coords))))
(let ((cousin-coords (crossword-cousin-position crossword
row
column)))
(save-excursion
(crossword-place-cursor row
column)
(delete-char 2)
(crossword-insert-cell (crossword-ref crossword
row
column))
(crossword-place-cursor (car cousin-coords)
(cdr cousin-coords))
(delete-char 2)
(crossword-insert-cell (crossword-ref crossword
(car cousin-coords)
(cdr cousin-coords)))))))
#+END_SRC
现在我们只需要再调整一件事:我们必须解决选中的单词的模糊对齐问题。
** 模糊对齐
假设你的网格中有这么一行:
#+BEGIN_SRC emacs-lisp
# . . . f . #
#+END_SRC
然后你在这一行中按下了C-c h。crossword-hwords生成的正则表达式是^.?.?.?f.?$;它的核心是f。
单词列表buffer会充满包含“f”的单词。你选中了“fluff”。会发生什么呢?
当你选中“fluff”,crossword-words-select在“fluff”的位置0处找到了一处对于核心的匹配。这表示它将会试图以“fluff”的第一个字母来匹配格子中“f”,看起来就像这样:
#+BEGIN_SRC emacs-lisp
# . . . f l #
#+END_SRC
在这个例子里,我们不能使用核心所匹配的第一处。但是我们也不能使用最后一处,因为这会用“fluff”的最后一个字母来匹配格子中的“f”,而这会把太多的字母放到左面:
#+BEGIN_SRC emacs-lisp
# l u f f . #
#+END_SRC
我们必须使用“fluff”中的第二个“f”来匹配网格中的“f”。我们如何才能正确地做出这个选择呢?
答案是以前缀的长度来找到核心在单词中能够出现的最右的位置。这保证了单词在核心左侧的部分足够短,且最小化了右侧的字母数量。
例如,单词“fluff”包含着三处对于正则核心f的匹配。第一个位置为0,第二个为3,第三个为4。正则前缀的长度为3。所以“fluff”中对于f的最右匹配就应该小于等于3,也就是第二个。
选择尽可能靠右的匹配使得我们在放置单词时能尽可能多的填充前缀。而这又会保证我们不会超出右边界。
因此我们应该将crossword-words-select的这一部分:
#+BEGIN_SRC emacs-lisp
(let* (...
(corematch (string-match crossword-words-core
word))
...
#+END_SRC
替换为:
#+BEGIN_SRC emacs-lisp
(let* (...
(corematch
(let ((bestmatch nil)
(index O))
(while (and index (<= index
crossword-words-prefix-len))
(let ((match (string-match crossword-words-core
word
index)))
(if (and match
(<= match crossword-words-prefix-len))
(setq bestmatch match
index (+ match 1))
(setq index nil))))
bestmatch))
...
#+END_SRC
下面解释它如何工作:
#+BEGIN_SRC emacs-lisp
(let ((bestmatch nil)
(index O))
#+END_SRC
我们使用bestmatch来保存目前找到的最右位置而index来指示下一次搜索从哪里开始。循环在index为nil时终止(这与初始值0是不同的)。
#+BEGIN_SRC emacs-lisp
(while (and index (<= index
crossword-words-prefix-len))
#+END_SRC
while循环一直进行直到我们向右前进的太远了(也就是说与开始搜索的距离超过了crossword-words-prefix-len)。
#+BEGIN_SRC emacs-lisp
(let ((match (string-match crossword-words-core
word
index)))
#+END_SRC
这里我们使用了string-match的可选的第三个参数,也就是从word中的何处开始搜索。
#+BEGIN_SRC emacs-lisp
(if (and match
(<= match crossword-words-prefix-len))
#+END_SRC
我们必须确保match在传递给<=之前不为nil,因为它只接受数字。
如果已经找到了一个匹配结果,那么记录它然后向右开始下一次搜索;否则,将index设为nil来退出循环。
#+BEGIN_SRC emacs-lisp
(setq bestmatch match
index (+ match 1))
(setq index nil))))
#+END_SRC
最后,将bestmatch的值返回给corematch。
#+BEGIN_SRC emacs-lisp
bestmatch)
#+END_SRC
* 结语
我们可以继续向Crossword模式中添加功能,而且我也很难管住自己的手。例如,一旦网格满了,就计数网格中的方块并且生成横向单词和纵向单词的列表。提供以单词为单位移动光标的命令也是个不错的主意。
但是这就是目前为止我所编写的Crossword模式了。我需要面对这本书的时间点,而且,没人喜欢一个不知道该何时放弃一个玩具工程的程序员。
当然对你来说没有任何限制去完善Crossword模式。对于Emacs的探索同样如此,不管你选择任何方向。
<<10-40>>[40]. 180度对称也被称为“双向对称(two-way symmetry)”。还有“四向对称(four-way symmetry)”,即每次旋转90度网格仍然相同。
<<10-41>>[41]. 这些函数名称中的“a”表示“array”。为什么不用表示“vector”的vset和vref呢?答案是在Emacs Lisp中,向量(vector)是数组(array)的一种。字符串是两一种数组。所以aset和aref可以像处理向量这样处理字符串--当然这并不表示字符串是向量。
<<10-42>>[42]. 虽然我们在本章中并没有提到,但是你应该记得(point-min)并不一定返回buffer中的开始位置;如果narrowing生效的话它返回的可能是buffer中间的某个位置。
<<10-43>>[43]. 有一个what-line,但是这个函数用于交互式命令,而非在程序中使用。它会显示关于当前行号的信息,并且不会返回一个有用的返回值。我们需要一个行为相反的函数:不显示信息,并且返回当前的行号。
<<10-44>>[44]. 在没有“核心”的情况下触发crossword-hwords并不算是一个错误,但是应该提示用户这个情况,因为生成的正则会匹配字典中小于等于给定长度的所有单词--而这可能并不是用户希望看到的!
================================================
FILE: 2.org
================================================
#+TITLE: 简单的新命令
#+SETUPFILE: ./resource/template.org
* 在本章:
*游历窗口*
*逐行滚动*
*其他光标和文本移动命令*
*处理符号链接*
*修饰Buffer切换*
*补充:原始的前置参数*
本章中我们将会着手写一些很小的Lisp函数和命令,介绍很多的概念来帮助我们面对后面章节中将出现的更大的任务。
* 游历窗口
当我最初使用Emacs时,我对于C-x o很满意,也就是other-window。它将光标从一个窗口移动到另一个。如果我想把光标移动回前一个,我必须使用-1作为参数执行other-window,这需要输入C-u -1 C-x o,这太繁琐了。而同样繁琐的另一种方案是不停C-x o直到我逛遍所有窗口最终回到前一个窗口。
我真正需要的是用一个按键绑定表示“下一个窗口”以及另一个表示“前一个窗口”。我知道我可以编写一些Emacs Lisp代码将我需要的方法绑定到新的按键上。首先我必须选择这些按键。“Next”和“Previous”自然可以想到C-n和C-p,但是这些已经被绑定到了next-line和previous-line而我并不想修改它们。另一个选择是使用一些前置按键,后面跟着C-n和C-p。Emacs已经使用C-x作为很多两键命令的前置键(就像C-x o自己),所以我选择C-x C-n对应“下一个窗口”而C-x C-p对应“前一个窗口”。
我使用帮助命令describe-key[[[2-8][8]]]来查看C-x C-n和C-x C-p是否已经绑定到其他按键了。我发现C-x C-p已经绑定到了set-goal-column,而C-x C-p绑定到了mark-page。将他们绑定到“下一个窗口”和“上一个窗口”将会覆盖他们默认的绑定。而因为这并不是我经常使用的命令,所以我并不介意覆盖他们。如果我需要的话可以使用M-x来触发他们。
在决定了使用C-x C-n表示“下一个窗口”之后,我需要将它绑定到一些触发“下一个窗口”的命令上。而下一个窗口实际上和C-x o所执行的跳到另一个窗口的行为一样,也就是other-window。所以C-x C-n的按键绑定很简单。将下面的命令
#+BEGIN_SRC emacs-lisp
(global-set-key "\C-x\C-n" 'other-window)
#+END_SRC
写入到.emacs中就完成了。而定义C-x C-p绑定的命令就要动一点脑子了。Emacs中并不存在一个命令表示“将光标移动到上一个窗口”。是时候定义一个了!
** 定义other-window-backward
既然知道了给other-window传递一个参数-1可以使光标移动到上一个窗口,那么我们可以定义一个新的命令other-window-backward,如下所示:
#+BEGIN_SRC emacs-lisp
(defun other-window-backward ()
"Select the previous window."
(interactive)
(other-window -1))
#+END_SRC
让我们看一下这个函数定义的各个部分。
1. Lisp函数的定义以defun开始。
2. 下面跟着要定义的函数名称;这里使用other-window-backward。
3. 下面跟着函数的参数列表[[[2-9][9]]]。这个函数没有参数,所以我们使用了一个空列表。
4. 字符串”Select the previous window.”是这个新函数的文档字符串,或者叫做docstring。任何Lisp函数定义都可以有一个文档字符串。Emacs将会在使用命令describe-function(M-? f)或者apropos展示在线帮助时显示这个字符串。
5. 下一行(interactive)很特殊。这表示这个函数是一个交互式命令。在Emacs里,命令表示一个可以交互执行的Lisp函数,这表示它可以通过按键绑定或者通过M-x command-name来进行触发。并不是所有Lisp函数都是命令,但所有命令都是Lisp函数。
任何Lisp函数,包括交互命令,可以被其他Lisp代码使用(function arg ...)语法来进行调用。
函数通过在函数定义的头部(在可选的docstring之后)使用特殊的(interactive)表达式来表示自己是交互命令。更多信息在之后的“交互声明”中做更多叙述。
6. 跟在函数名,参数列表,文档字符串,以及interactive声明之后的是函数体,也就是一个Lisp表达式序列。这个函数的函数体是一个单独的表达式(other-window -1),也就是使用参数-1调用函数other-window。
执行defun表达式用来定义函数。现在我们可以在Lisp程序中通过(other-window-backward)来调用它;或者通过输入M-x other-window-backward RET来调用它;也可以通过M-? f other-window-backward RET[[[2-10][10]]]来查看帮助。现在我们唯一需要做的就是绑定:
#+BEGIN_SRC emacs-lisp
(global-set-key "\C-x\C-p" 'other-window-backward)
#+END_SRC
** 为other-window-backward添加参数
这个按键绑定已经能满足我们的需求了,但是我们还需要进行一点点改进。当使用C-x o(或者我们现在可以使用C-x C-n)来调用other-window时,你可以使用一个数字n作为参数来改变它的行为。如果使用了n,other-window可以跳过很多窗口。例如,C-u 2 C-x C-n表示“移动到当前窗口后面的第二个窗口”。就像我们已经看到的,n可以是一个负数来向回跳n个窗口。因此给other-window-backward添加一个参数来跳过窗口是很自然的想法。而现在,other-window-backward只能每次向后跳一次。
因此,我们需要给这个函数一个参数:要跳过的窗口数。我们可以这么做:
#+BEGIN_SRC emacs-lisp
(defun other-window-backward (n)
"Select Nth previous window."
(interactive "p")
(other-window (- n)))
#+END_SRC
我们给自己的函数一个参数n。我们还把交互声明修改为(interactive "p"),还把传递给other-window的参数从-1改为(- n)。让我们从交互声明开始看一下这些改动。
就像我们所看到的,交互命令是一种Lisp函数。这意味着命令也可以有参数。从Lisp中向函数传递参数是简单的;只要函数调用时写下来就可以了,就像(other-window -1)。但是如果函数是通过交互命令触发的呢?参数怎么传递?这也就是交互声明的目的。
interactive的参数描述了这个函数如何获取参数。当命令不需要参数时,那么interactive也没有参数,就像我们一开始other-window-backward中所示的那样。当命令需要参数时,interactive也有了一个参数:一个字母构成的字符串,每个字母描述一个参数。例子中的字母p表示“如果有前置参数,将它解释为一个数字,如果没有前置参数,就将参数默认设为1。”[[[2-11][11]]]在命令触发时参数n将接收这个值。所以如果用户输入C-u 7 C-x C-p,n就是7 。如果输入C-x C-p,则n是1 。当然你也可以在Lisp代码中调用other-window-backward,例如(other-window-backward 4)。
新版本的other-window-backward使用参数(- n)来调用other-window。这里将n传递给函数-来得到相反数(注意-和n之间的空格)。-通常表示减--例如(- 5 2)得到3--但是当只有一个参数时,他表示取负。
默认情况下,n是1,(- n)就是-1,对于other-window的调用就变成了(other-window -1)--同函数的第一个版本一样。如果用户指定了一个数字前缀参数--例如C-u 3 C-x C-p--那么我们调用的就是(other-window -3),也就是向前移动3个窗口,这正是我们需要的。
理解(- n)和-1的区别很重要。前者是一个函数调用。函数名和参数之间必须有一个空格。后者是一个整数常量。负号和1之间并没有空格。当然你也可以将它写成(- 1)(虽然没有必要在能直接写成-1的情况下而触发一次函数调用)。不能写成-n,因为n不是一个常量。
** 可选参数
我们还可以对other-window-backward做出另一个改进,即当调用函数的时候参数n是可选的,也就是当交互触发的时候前置参数也是可选的。它应该能够在不提供参数(other-window-backward)时触发默认行为(即(other-window-backward 1))。就像这样实现:
#+BEGIN_SRC emacs-lisp
(defun other-window-backward (&optional n)
"Select Nth previous window."
(interactive "p")
(if n
(other-window (- n)) ; 如果n非空
(other-window -1))) ; 如果n为空
#+END_SRC
参数中的关键词&optional表示所有后续的参数都是可选的。可选参数可能会也可能不会传递给函数。如果没给,可选参数的值为nil。
关于符号nil有三点需要注意:
1. 它表示错误。在Lisp的判断结构中--if, cond, while, and, or以及not--nil表示“false”,其他值表示“true”。因此,在表达式
#+BEGIN_SRC elisp
(if n
(other-window (- n))
(other-window -1))
#+END_SRC
(Lisp版本的if-then-else结构)中,第一个n被求值。如果n的值是true(非空),那么
#+BEGIN_SRC elisp
(other-window (- n))
#+END_SRC
被执行,否则
#+BEGIN_SRC elisp
(other-window -1)
#+END_SRC
被执行。
还有另一个符号,t,代表truth, 但是这没有nil重要,就像后面表明的。
2. 它和空表很难区分。在Lisp解释器中,符号nil和空表()是相同的对象。如果你调用listp来判断符号nil是否是一个表,你将会得到结果t,也就是truth。同样的,如果你使用symbolp来判断空表是否是一个符号,那么也会得到t。但是,如果你传递任何其他列表给symbolp,或者传递其他符号给listp,那么你会得到nil--即表示非。
3. 它的值就是它自身。当你计算符号nil时,结果是nil。因此,不像其他的符号,当你需要它的名称而不是它的值得时候,nil不需要引用,因为它的名称就是它的值。所以你可以这样写:
#+BEGIN_SRC emacs-lisp
(setq x nil) ; 将nil赋给变量x
#+END_SRC
将nil赋给变量x而不必这样写:
#+BEGIN_SRC emacs-lisp
(setq x 'nil)
#+END_SRC
虽然这两种写法都可以。同样的,不要试图将任何新的值赋给nil,[[[2-12][12]]]虽然它看起来是一个合法的变量名称。
nil的另一个功能就是区分列表是否正确。这将在[[file:./6.org][第六章]]中讨论。
另一个符号t用来表示正确。就像nil,t也表示着自身的值,因此不需要引用。与nil不同的是,t并没有跟其他什么对象相同。也与nil不同的是,nil是唯一表示错误的方式,而其他所有Lisp值都和t一样表示正确。但是,当你仅仅想表示正确时(就像symbolp的返回值)你不需要选择一个类似17或者“plugin”这样的值来表示它。
** 简化代码
就像前面提到的,表达式
#+BEGIN_SRC elisp
(if n ; 如果...
(other-window (- n)) ; ...那么
(other-window -1)) ; ...否则
#+END_SRC
是Lisp版本的if-then-else结构。if的第一个参数是一个条件。它将被检测结果是真(除nil之外的一切值)还是假(nil)。如果为真,则第二个参数--“then”分句将会被执行。如果是假,第三个参数--“else”分句(可选的)--将会被执行。if的返回值总是最后执行的表达式的结果。[[file:./B.org][附录B]]会向你展示if和其他像cond和while这样的Lisp流程控制函数。
在本例中,我们可以通过提取公有表达式的方式来进行简化。注意到other-window在if的两个分支中都被调用了。唯一的区别来自传递给other-window的参数n。因此我们可以将表达式重写:
#+BEGIN_SRC elisp
(other-window (if n (- n) -1))
#+END_SRC
通常,
#+BEGIN_SRC elisp
(if test
(a b)
(a c))
#+END_SRC
可以简写成(a (if test b c))。
我们还观察到在if的两个分支上,我们都在取反--不管是n的负数还是1的负数。所以
#+BEGIN_SRC elisp
(if n (-n) -1)
#+END_SRC
可以变为
#+BEGIN_SRC emacs-lisp
(- (if n n 1))
#+END_SRC
** 逻辑表达式
另一个Lisp程序员的常用技巧甚至可以使这个表达式更简单:
#+BEGIN_SRC elisp
(if n n 1) = (or n 1)
#+END_SRC
函数or跟大多数语言中的逻辑或都一样:如果所有条件为否,则返回否,否则返回是。但是Lisp的or还有另一个用途:它挨个计算它的参数的值直到找到第一个为真的值并返回。如果没找到,则返回nil。所以or的返回值并不仅仅是false或者true,它返回false或者表中的第一个为true的值。这意味着通常来说,
#+BEGIN_SRC elisp
(if a a b)
#+END_SRC
可以替换为
#+BEGIN_SRC elisp
(or a b)
#+END_SRC
实际上,通常我们都应该这么写,因为如果a是true,那么(if a a b)会执行两次a而(or a b)只执行一次。(另一方面,如果你就是想a执行两次,那么当然你应该使用if)。实际上,
#+BEGIN_SRC elisp
(if a a ; 如果a为true,返回a
(if b b ; else if b为true,返回b
...
(if y y z))) ; else if y为true,返回y,否则z
#+END_SRC
(虽然这看上去很夸张但在真正的程序里这是很常见的一种模式)可以转换成下面这种形式。
#+BEGIN_SRC elisp
(or a b .. y z)
#+END_SRC
同样的,
#+BEGIN_SRC elisp
(if a
(if b
...
(if y z)))
#+END_SRC
(注意这个例子中没有任何else)可以被写成
#+BEGIN_SRC elisp
(and a b ... y z)
#+END_SRC
因为and通过计算每个参数直到遇到一个值为nil的参数。如果找到了,就返回nil,否则它返回最后一个参数的值。
另一个简写需要注意:一些程序员喜欢将
#+BEGIN_SRC elisp
(if (and a b ... y) z)
#+END_SRC
转换成
#+BEGIN_SRC elisp
(and a b ... y z)
#+END_SRC
我不这么做,因为虽然他们功能上相同,但是前一个有一个细微的暗示--即“如果a-y都是true的话就执行z”--后一种却不是这样,这可以让人更加容易理解代码。
** 最好的other-window-backward
回到other-window-backward。使用我们自己整理过的other-window调用,现在函数的定义看起来是这样的:
#+BEGIN_SRC emacs-lisp
(defun other-window-backward (&optional n)
"Select Nth previous window."
(interactive "p")
(other-window (- (or n 1))))
#+END_SRC
但是最好的定义--最有Emacs Lisp风格的--应该是这样:
#+BEGIN_SRC emacs-lisp
(defun other-window-backward (&optional n)
"Select Nth previous window."
(interactive "P")
(other-window (- (prefix-numeric-value n))))
#+END_SRC
在这个版本中,交互声明中的字母并不是小写的p了,而是大写的P;而other-window的参数变成了(- (prefix-numeric-value n)),而不是(- (or n 1))。
大写的P表示“当以交互的方式调用时,将前置参数保持为原始形式(raw form)并将其赋值给n”。前置参数的原始形式是Emacs使用的一种内部数据结构,用于在触发命令之前记录用户提供的前置信息。(查看[[补充:原始的前置参数][补充:原始的前置参数]]得到更多关于原始前置参数数据结构的细节。)函数prefix-numeric-value可以将像(interactive "p")那样将数据结构转换为一个数字。而且,如果other-window-backward以非交互的方式调用(因此n就不再是一个原始形式的前置参数),prefix-numeric-value还是会做正确的事情--也就是说,如果n是数字则直接返回n,如果n为nil则返回1。
可以说,这个定义并不比我们前面定义的other-window-backward的功能更强大。但是这个版本更“Emacs-Lisp-like”,因为它的代码重用性更好。它使用内建的函数prefix-numeric-value而不是重复定义函数的行为。
现在,让我们看看另一个例子。
* 逐行滚动
在我使用Emacs之前,我习惯了一些编辑器上存在而Emacs上并没有的特性。自然我很怀念这些功能并且决定找回他们。这其中的一个例子是使用一个键来向上、向下滚屏。
Emacs有两个滚屏方法,scroll-up和scroll-down,分别绑定到C-v和M-v。每个方法都有一个可选参数来告诉它要滚动多少行。默认的,他们每次翻一屏。(不要把向上、向下滚屏和通过C-n/C-p向上、向下移动光标混淆。)
虽然我可以使用C-u 1 C-v和C-u 1 M-v来每次向上、向下滚动一行,我还是希望只使用一次按键就实现这一功能。使用前面章节所讲述的技术,这很容易实现。
虽然在这之前,我还是要先考虑一件事。我永远也分不清这两个函数实际上分别是干什么的。scroll-up是不是将文本向上移动,展示出下面的一部分文件?或者它表示展示上面的一部分文件,而把所有文字下移?我希望这些方法的名称能够少一些混淆,就像scroll-ahead和scrll-behind。
我们可以使用defalias来指向任意Lisp函数。
#+BEGIN_SRC elisp
(defalias 'scroll-ahead 'scroll-up)
(defalias 'scroll-behind 'scroll-down)
#+END_SRC
这样就好多了。现在我们就再也不用为这些混淆的名字而头痛了(虽然原来的名字仍然还在)。
现在我们来定义两个函数来使用正确的参数调用scroll-ahead和scroll-behind。这个过程和之前定义other-window-backward一样:
#+BEGIN_SRC elisp
(defun scroll-one-line-ahead ()
"Scroll ahead one line."
(interactive)
(scroll-ahead 1))
(defun scroll-one-line-behind ()
"Scroll behind one line."
(interactive)
(scroll-behind 1))
#+END_SRC
同样,我们可以给他们一个可选参数来使函数更通用:
#+BEGIN_SRC elisp
(defun scroll-n-lines-ahead (&optional n)
"Scroll ahead N lines (1 by default)."
(interactive "P")
(scroll-ahead (prefix-numeric-value n)))
(defun scroll-n-lines-behind (&optional n)
"Scroll behind N lines (1 by default)."
(interactive "P"))
#+END_SRC
最后,我们需要选择按键来绑定新的命令。我喜欢C-q绑定scroll-n-lines-behind而C-z绑定scroll-n-lines-ahead:
#+BEGIN_SRC elisp
(global-set-key "\C-q" 'scroll-n-lines-behind)
(global-set-key "\C-z" 'scroll-n-lines-ahead)
#+END_SRC
默认的,C-q绑定到了quoted-insert。我将这条不常用的函数移动到了C-x C-q:
#+BEGIN_SRC elisp
(global-set-key "\C-x\C-q" 'quoted-insert)
#+END_SRC
C-x C-q的默认绑定是vc-toggle-read-only,我并不关心它的丢失。
C-z的在X系统下默认绑定是iconify-or-deiconify-frame,在终端的绑定是suspend-emacs。在这两种情况下,函数也绑定到了C-x C-z,所以也没有必要重新绑定他们。
* 其他光标和文本移动命令
下面是另外一些绑定到合理键位的简单命令。
#+BEGIN_SRC emacs-lisp
(defun point-to-top ()
"Put point on top line of window."
(interactive)
(move-to-window-line 0))
(global-set-key "\M-," 'point-to-top)
#+END_SRC
"Point"指代光标的位置。这个命令将光标移动到窗口的左上角。推荐的按键绑定替换了tags-loop-continue,我把它替换到了C-x,:
#+BEGIN_SRC elisp
(global-set-key "\C-x," 'tags-loop-continue)
#+END_SRC
下一个函数将光标移动到了窗口的左下角。
#+BEGIN_SRC elisp
(defun point-to-bottom ()
"Put point at beginning of last visible line."
(interactive)
(move-to-window-line -1))
(global-set-key "\M-." 'point-to-bottom)
#+END_SRC
这次的按键绑定替换了find-tag。我将它放到了C-x.,这回替换了我并不关心的set-fill-prefix。
#+BEGIN_SRC elisp
(defun line-to-top ()
"Move current line to top of window."
(interactive)
(recenter 0))
(global-set-key "\M-!" 'line-to-top)
#+END_SRC
这条命令将光标所在的行移动到屏幕的最顶端。这条命令替换了shell-command。
改变Emacs的按键绑定有一个缺点。当你习惯了自己高度定制化的Emacs后再在另一个没有这些定制的Emacs上工作时(例如在不同的电脑上或者使用了朋友的账号登录),你会很不习惯。这经常困扰着我。我训练着自己在未定制的Emacs上工作而不会受太多影响。我很少使用未定制的Emacs,所以总的来说得大于失。当你疯狂的更改按键绑定之前,你需要权衡一下这些得失。
* 处理符号链接
目前为止,我们写的函数都非常简单。本质上,他们都只是重新排列了一下参数来调用其他已经存在的函数。现在让我们看看需要我们更多编程工作的示例。
在UNIX里,符号链接(symbol link,或者symlink)是一个指向另一个文件的文件。当你查看符号链接的内容时,你实际上得到的是它所指向的文件的内容。
假设你在Emacs里访问了一个指向其他文件的符号链接。你修改了一下文件内容然后按下C-x C-s来保存buffer。Emacs应该做什么呢?
1. 使用编辑的文件替换符号链接,破坏链接,所指向的原始文件保持不变。
2. 覆盖符号链接所指向的文件。
3. 提示用户来选择上面的方案。
4. 其他。
不同的编辑器处理符号链接的方式都不一样,所以习惯一个编辑器的用户可能会对其他编辑器的行为感到不适应。而且,我相信情况不同正确的处理方式也不同,而用户每次遇到这种情况都被迫需要考虑一下。
我的做法是:当我访问一个符号链接文件时,我让Emacs自动的将buffer变为只读。当我想要修改时会导致一个“Buffer is read-only”的错误。这个错误提示我可能正在访问一个符号链接。然后我会选择使用我自己设计的两个特殊命令之一来处理。
** 钩子
当我希望Emacs在我访问某个文件时将其对应的buffer变为只读,我必须告诉Emacs“当我访问这个文件时执行一段特定的Lisp代码”。访问文件的动作应该触发一段我写的代码。这时钩子(hooks)就出场了。
钩子是指在特定情况下执行的指向某个函数列表的Lisp变量。例如,变量write-file-hooks是当一个buffer保存时Emacs执行的函数列表,而post-command-hook是当执行一个交互命令时执行的函数列表。在本例中我们最感兴趣的钩子是find-file-hooks,这在当Emacs访问一个新文件时会被执行。(有许多钩子,有一些我们将会在后面的内容中看到。要查看所有钩子,可以使用M-x apropos RET hook RET。)
函数add-hook将一个函数添加到钩子变量上。下面的函数将被添加到find-file-hooks:
#+BEGIN_SRC elisp
(defun read-only-if-symlink ()
(if (file-symlink-p buffer-file-name)
(progn
(setq buffer-read-only t)
(message "File is a symlink"))))
#+END_SRC
这个函数用来检测当前buffer的文件是否是符号链接。如果是,则buffer将变为只读并且显示“File is a symlink”。让我们仔细看一下这个函数。
+ 首先,注意参数列表是空的。钩子变量中的函数都没有参数。
+ 函数file-symlink-p用来检测它的参数,也就是buffer的文件名称是否是一个符号链接。它是一个断言(predicate),这表示它会返回true或者false。在Lisp中,断言通常被以p或者-p结尾。
+ file-symlink-p的参数是buffer-file-name。这个预置的变量在每个buffer中都有不同的值,因此也称为buffer局部变量。它总是保存着当前buffer的名字。在这里,当前buffer是指find-file-hooks执行时找到的文件。
+ 如果buffer-file-name指向的是符号链接,我们希望做两件事:将buffer变为只读,并且提示一条信息。但是,Lisp在if-then-else中的“then”部分只允许一条表达式。如果我们写成:
#+BEGIN_SRC elisp
(if (file-symlink-p buffer-file-name)
(setq buffer-read-only t)
(message "File is a symlink"))
#+END_SRC
这表示,“如果buffer-file-name是符号链接,那么就把buffer变成只读的,否则打印信息‘File is a symlink.’”要想两条语句都执行,我们可以把他们放到progn里,就像下面这样:
#+BEGIN_SRC elisp
(progn
(setq buffer-read-only t)
(message "File is a symlink"))
#+END_SRC
progn表达式会顺序执行内部的表达式并且返回最后执行的语句的值。
+ 变量buffer-read-only也是buffer局部变量,用于控制当前buffer是否是只读的。
既然我们已经定义了read-only-if-symlink,我们就可以调用
#+BEGIN_SRC elisp
(add-hook 'find-file-hooks 'read-only-if-symlink)
#+END_SRC
来将其添加到访问新文件就会触发的函数列表中。
** 匿名函数
当你使用defun定义函数的时候,你给了函数一个可以在任何地方调用的名字。但是对于那些并不需要在任何地方都被调用的函数呢?假如它只需要在一个地方生效呢?可以说,read-only-if-symlink仅需要在find-file-hooks的列表里执行;实际上,在find-file-hooks之外的地方调用它甚至并不是什么好事。
我们可以在不指定名称的情况下定义函数。这种函数被称为匿名函数。我们使用Lisp的关键词lambda[[[2-13][13]]]来定义,除了不指定函数名外,它的作用跟defun一模一样。
#+BEGIN_SRC elisp
(lambda ()
(if (file-symlink-p buffer-file-name)
(progn
(setq buffer-readonly t)
(message “File is a symlink))))
#+END_SRC
lambda后面的空括号是匿名函数的参数列表。这个函数没有参数。匿名函数可以用在任何你使用函数名的地方:
#+BEGIN_SRC elisp
(add-hook 'find-file-hooks
'(lambda ()
(if (file-symlink-p buffer-file-name)
(progn
(setq buffer-read-only t)
(message "File is a symlink")))))
#+END_SRC
这样就只有add-hook可以访问它了。[[[2-14][14]]]
不过也有一个不应该在钩子中使用匿名函数的原因。如果你想要从钩子中移除一个函数的话,你需要使用函数名来调用remove-hook,就像这样:
#+BEGIN_SRC elisp
(remove-hook 'find-file-hooks 'read-only-if-symlink)
#+END_SRC
而如果使用匿名函数就没法这样做了。
** 处理符号链接
当Emacs提醒我在编辑符号链接时,我可能希望打开链接的目标文件来作为当前buffer的内容;我也可能希望"clobber"符号链接(将符号链接文件替换为所指向的真实文件)然后再访问它。下面是这两个的实现方式:
#+BEGIN_SRC elisp
(defun visit-target-instead ()
"Replace this buffer with a buffer visiting the link target."
(interactive)
(if buffer-file-name
(let ((target (file-symlink-p buffer-file-name)))
(if target
(find-alternate-file target)
(error "Not visiting a symlink")))
(error "Not visiting a file")))
(defun clobber-symlink ()
"Replace symlink with a copy of the file."
(interactive)
(if buffer-file-name
(let ((target (file-symlink-p buffer-file-name)))
(if target
(if (yes-or-no-p (format "Replace %s with %s?"
buffer-file-name
target))
(progn
(delete-file buffer-file-name)
(write-file buffer-file-name)))
(error "Not visiting a symlink")))
(error "Not visiting a file")))
#+END_SRC
两个函数都以下面的表达式开始:
#+BEGIN_SRC elisp
(if buffer-file-name
...
(error “Not visiting a file”))
#+END_SRC
(我将其他内容省略掉以强调这个if结构。)因为buffer-file-name可能为空(当前buffer可能没有访问任何文件--例如,*scratch* buffer),所以这是必要的,而传递nil给file-symlink-p将会触发错误,“Wrong type argument: stringp,nil”。[[[2-15][15]]]这个错误表示一个函数的参数应该是字符串--一个符合stringp断言的对象--但是却得到了nil。visit-target-instead和clobber-symlink都会触发这个错误信息,所以我们自己来检测buffer-file-name是不是nil。如果是nil,那么“else”子句里我们会使用error函数生成一个可读性更好的错误信息--“Not visiting a file”。当error函数被调用时,当前的命令会被终止,Emacs将会返回到它的最顶层来等待用户的下一个输入。
为什么read-only-if-symlink中不需要检测buffer-file-name是否为空呢?因为这个方法只会由find-file-hooks调用,而这个钩子只有当访问某个文件时才会触发。
在buffer-file-name条件的“then”部分,两个函数都有下面的结构
#+BEGIN_SRC elisp
(let ((target (file-symlink-p buffer-file-name))) ...)
#+END_SRC
大多数语言都有方法来创建临时变量(也称为局部变量),它们只存在于某个特定的代码域中,称为变量的作用域。在Lisp中,临时变量使用let来创建,结构是这样的:
#+BEGIN_SRC elisp
(let ((var1 value1)
(var2 value2)
...
(varn valuen))
body1 body2 ... bodyn)
#+END_SRC
这会将value1赋值给var1,value2赋值给var2,依此类推;var1和var2只能在bodyi表达式中使用。此外,使用临时变量能够帮助避免不同域的代码中出现函数名相同的冲突。
所以表达式
#+BEGIN_SRC elisp
(let ((target (file-symlink-p buffer-file-name))) ...)
#+END_SRC
创建了一个名为target的临时变量,它的值是(file-symlink-p buffer-file-name)的返回值。
就像前面提到的,file-symlink-p是一个断言,也就是说它的返回值是真或者假。但是因为真在Lisp中可以被任何除nil之外的值表示,如果file-symlink-p的参数是一个符号链接时它的返回值并不一定就是t。实际上,它会返回符号链接所指向的文件名。所以如果buffer-file-name是符号链接的名字,target将会是符号链接的目标的名称。
在临时变量target的作用域中,let的body都是这样的:
#+BEGIN_SRC elisp
(if target
...
(error “Not visiting a symlink”))
#+END_SRC
在执行完let的body之后,变量target就不存在了。
在let中,如果target为空(file-symlink-p可能会返回nil,因为buffer-file-name可能并不是一个符号链接),那么我们就会在“else”里产生一个错误信息,“Not visiting a symlink”。否则每个函数中会执行自己的逻辑。最后我们来看两个函数不一样的地方。
函数visit-target-instead中执行
#+BEGIN_SRC elisp
(find-alternate-file target)
#+END_SRC
这会访问target文件来替换当前的buffer,并且会提示用户,以免原buffer还有未保存的修改。它甚至会触发find-file-hooks,因为新文件也可能是一个符号链接!
在visit-target-instead调用find-alternate-file的地方,clobber-symlink则如下所示:
#+BEGIN_SRC elisp
(if (yes-or-no-p ...) ...)
#+END_SRC
函数yes-or-no-p会询问用户一个问题,并会根据用户的选择返回true或false。本例中,问题是:
#+BEGIN_SRC elisp
(format "Replace %s with %s?"
buffer-file-name
target)
#+END_SRC
这个字符串的结构和C语言的printf很相似。第一个参数是一个格式化模式字符串。每个%s都使用后面的字符串参数来替换。第一个%s使用buffer-file-name的值替换,第二个使用target的值替换。所以如果buffer-file-name的值是“foo”而target的值是“bar”,那么提示就会是“Replace foo with bar?”(format函数还支持其他的格式化符号。例如,如果参数是ASCII值则%c会打印出一个字母。使用M-? f format RET来查看整个功能列表。)
在检查了yes-or-no-p的返回值并且用户选择了“yes”之后,clobber-symlink将会执行:
#+BEGIN_SRC elisp
(progn
(delete-file buffer-file-name)
(write-file buffer-file-name))
#+END_SRC
我们已经知道,progn会把多条Lisp表达式组合起来。delete-file会删除文件(只是个符号链接),write-file会将当前buffer的内容保存到buffer-file-name所指向的位置,只是这次保存的是普通文件。
我喜欢将C-x t绑定到visit-target-instead(默认未被使用)而C-x 1绑定到clobber-symlink(默认绑定到count-linespage)。
* 修饰Buffer切换
让我们以一个例子总结本章,这个例子将会引入一个称为修饰(advice)的非常有用的Lisp工具。
我发现我经常同时编辑许多名称相似的文件;例如,foobar.c和foobar.h。当我想从一个buffer切换到另一个时,我使用C-x b,也就是switch-to-buffer,它会询问我buffer的名称。因为我希望尽量少的按键,我使用TAB来补全buffer名称。我会输入
#+BEGIN_SRC elisp
C-x b fo TAB
#+END_SRC
并且希望TAB会将“fo”补全为”foobar.c”,然后我只要按下RET就可以了。90%的情况下这工作的很好。另外的情况下,就像这个例子中,按下fo TAB将只会补全为“foobar.”,而让我自己区分是选择”foobar.c”还是”foobar.h”。出于习惯,我常常按下RET,结果buffer的名称变成了”foobar.”。
这时,Emacs将会创建一个新的名为foobar.的新buffer,当然这完全不是我想要的。现在我需要杀掉这个新buffer(使用C-x k,kill-buffer)然后再来一次。虽然我有时也需要新建一个不存在的buffer,但是这和刚刚这种错误的情况相比很少见。我希望在这种情况中,Emacs能够在我 出错之前提示我。
要达到这点,我们可以使用advice。advice是指一段在函数调用之前或之后执行的代码。前置修饰可以在参数传递给函数之前对其进行修改。后置修饰可以修改函数的返回值。修饰跟钩子变量有点像,只是Emacs只为一些特定的情况定义了不多的一些钩子,而你却能选择对哪些方法进行修饰。
下面是修饰的第一次尝试:
#+BEGIN_SRC elisp
(defadvice switch-to-buffer (before existing-buffer
activate compile)
"When interactive, switch to existing buffers only."
(interactive "b"))
#+END_SRC
让我们仔细看看它。函数defadvice用于创建一个新的修饰。它的第一个参数是要被修饰的函数名(不必引用,unquoted)--在本例中也就是switch-to-buffer。后面跟着的是特定格式的列表。它的第一个元素--在本例中也就是before--告诉我们这是前置还是后置修饰。(还有一种修饰,称为“around”,它能让你在修饰函数的内部调用被修饰的方法。)后面跟着的是这个修饰的名称;本例中是existing-buffer。以后如果你想删除或者修改这个修饰你可以使用这个名称。再后面是一些关键词:activate表示这个修饰在其定义之后马上生效(可以只是定义修饰而不生效);compile表示这个修饰的代码应该被“byte-compiled”提高执行速度(查看[[file:./5.org][第五章]])。
在特定格式的列表之后,跟着一个可选的文档字符串。
本例中的body只有一行交互声明,这会替换switch-to-buffer的交互声明。switch-to-buffer接受任何字符串作为buffer-name参数,而交互声明中的字符b表示“只接受已存在的buffer的名称”。我们在不影响任何以非交互形式调用switch-to-buffer的情况下做出了这个更改。所以这个修饰高效的完成了整件工作:它使switch-to-buffer只接受已存在的buffer名。
不幸的是,这样约束性太大了。还是应该能够切换到不存在的buffer,但是只在某些特殊的条件下才移除这个限制--例如,当使用前置参数的时候。这样,C-x b将会拒绝切换到不存在的buffer,而C-u C-x b将允许。
我们可以这么做:
#+BEGIN_SRC elisp
(defadvice switch-to-buffer (before existing-buffer
activate compile)
"When interactive, switch to existing buffers only,
unless given a prefix argument."
(interactive
(list (read-buffer "Switch to buffer:"
(other-buffer)
(null current-prefix-arg)))))
#+END_SRC
又一次,我们使用了前置修饰修改了switch-to-buffer的交互声明。但是这次,我们使用了一种未见过的形式调用interactive:我们传递了一个列表作为参数给它,而不是一个字母组成的字符串。
当interactive的参数不是字符串而是一些表达式时,这些表达式会进行运算得到一个参数列表传递给函数。所以在这个例子中我们调用了list,它使用下面这段表达式的返回值构建:
#+BEGIN_SRC elisp
(read-buffer "Switch to buffer: "
(other-buffer)
(null current-prefix-arg))
#+END_SRC
函数read-buffer是一个底层的用于向用户询问buffer名称的函数。说它底层是因为所有其他询问buffer名称的函数最终调用的都是它。它的调用需要一个提示字符串和两个可选参数:一个默认切换到的buffer,以及一个布尔值用于标识输入是否只能是已存在的buffer。
默认的buffer,我们传递了(other-buffer)的返回值给它,它的作用是产生一个可用的默认buffer。(通常它会选择最近使用的但是当前不可见的buffer。)对于是否限制输入的布尔状态值,我们使用了
#+BEGIN_SRC elisp
(null current-prefix-arg)
#+END_SRC
这会查看current-prefix-arg是否为nil。如果是,则返回t;否则返回nil。因此,如果没有前置参数(也就是current-prefix-arg为nil),那么我们调用的是
#+BEGIN_SRC elisp
(read-buffer "Switch to buffer: "
(other-buffer)
t)
#+END_SRC
表示“读入buffer名称,只接受已存在的buffer”。如果有前置参数,那么我们调用的是
#+BEGIN_SRC elisp
(read-buffer "Switch to buffer: "
(other-buffer)
nil)
#+END_SRC
表示“读入buffer名称而不做任何限制”(允许不存在的buffer作为参数)。然后read-buffer的返回值被传给了list,list(包含着一个元素,也就是buffer名称)传递给switch-to-buffer作为参数列表。
switch-to-buffer这样修饰之后,Emacs将不会回应我切换到不存在的buffer的要求了,除非我按下C-u来要求这种能力。
完整起见,你还应该同样修饰函数switch-to-buffer-other-window和switch-to-buffer-other-frame。
* 补充:原始的前置参数
变量current-prefix-arg总是保存着最后的“原始”前置参数,跟你从(interactive "P")中取到的一样。
函数prefix-numeric-value可以应用到一个跟你从(interactive "P")中取得的“原始”前置参数一样类型的值来得到数值。
原始的前置参数什么样子呢?表格2-1展示出了原始值以及对应的数值。
表格2-1:前置参数
| 如果用户输入 | 原始值 | 数值 |
| C-u后面跟一个(可能是负数)数字 | 数字本身 | 数字本身 |
| C-u - (后面什么都没有) | 符号- | -1 |
| C-u 一行中n次 | 一个包含数字4的n次方的表 | 4的n次方 |
| 没有前置参数 | nil | 1 |
<<2-8>>[8]. 如果你像[[file:/1.org][第一章]]中描述的那样修改了help-command的绑定,那么describe-key的按键绑定是M-? k;否则是C-h k。
<<2-9>>[9]. “parameter”与“argument”有什么不同呢?这两个概念通常可以替换使用,但是技术上来讲,“parameter”是指函数定义中的形参,而“argument”是指函数调用时传入的实参。argument的值会传递给parameter。
<<2-10>>[10]. 再一次,如果你已经把help-command的绑定到M-?那么就是M-? f。从这开始,我将假设你修改过了,或者你至少应该理解我的做法。
<<2-11>>[11]. 要查看interactive的code letter,按下M-? f interactive RET。
<<2-12>>[12]. 实际上Emacs也不允许你把任何值赋给nil。
<<2-13>>[13]. “lambda演算”是一套用于研究函数及其参数的数学形式。某种意义上来说它是Lisp(以及其他很多语言)的理论基础。单词“lambda”只是一个希腊语中的单词,并没有什么特殊的含义。
<<2-14>>[14]. 这并不是绝对正确的。其他的代码可以搜索find-file-hooks列表的内容并且执行里面的所有函数。这里的意思是这个函数相对于defun的显式声明来说隐藏起来了。
<<2-15>>[15]. 请自己试一下:M-: (file-symlink-p nil) RET。
================================================
FILE: 3.org
================================================
#+TITLE: 协作命令
#+SETUPFILE: ./resource/template.org
* 在本章:
*症状*
*解药*
*归纳出更一般的解决方法*
本章将讲述不同命令协同工作,以完成在一个命令里保存信息而在另一个命令里获取它。共享信息的最简单的方式是创建一个变量并把值存进去。本章中我们当然也会这么做。例如,我们会把当前buffer中的位置信息存储起来然后在其他命令中使用它。但是我们也会学到一些更复杂的保存状态的方式,特别是标记(markers)和符号属性(symbol properties)。我们将会把这些跟buffer和窗口的信息组合起来写出一套允许你“回滚”翻页动作的函数。
* 症状
你正在编写一些复杂的Lisp代码。你非常专注,在头脑中努力的理清数据之间的微妙的联系然后将它们编写到屏幕上。你正在处理一个非常精妙的部分而这时你发现了左面的一个拼写错误。你希望按下C-b C-b C-b来回到那里对其修正,但是--灾难发生了--你按下了C-v C-v C-v,向下翻了三屏,最终停在了离你的代码几光年以外的地方。你试图找到在这个失误发生之前光标在哪里,以及为什么在那里,以及你正在那里做什么,这无疑打乱了你头脑中的思考。当你翻页,或者搜索,或者查找撤销列表来回到那里时,你已经忘了最初想要修正的那个拼写错误,最终这变成了一个bug,可能会花费你几个小时来找到它。
Emacs在这个例子中没有起到帮助作用。它使你如此容易的在你的文档中迷失而找到回去的路却很困难。虽然Emacs有一个可扩展的撤销工具,但这只能撤销修改。你不能撤销如此简单的浏览行为。
* 解药
假设我们可以这样修改C-v(scroll-up命令[[[3-16][16]]]),当我们按下它之后,Emacs认为,“可能用户是误按的C-v,所以我将记录一些‘撤销’的信息以备未来需要”。然后我们可以编写另一个函数,unscroll,它可以回滚最后一次的滚动。这样只要记住unscroll的按键绑定就能避免再次发生这种迷失。
实际上这并没完全解决问题。如果你一次按下了多个C-v,调用一次unscroll应该将它们都撤销,而不是只撤销最后一次。这表示只有序列中的第一个C-v才需要记住它的起始位置。我们怎样来检测这种情况的发生呢?在我们的C-v代码里记录开始位置之前,我们需要知道(a)下一个命令是否还是scroll-up,或者(b)前一个命令是否不是scroll-up。显然(a)是不可能的:我们不能预见未来。幸运的是(b)很简单:Emacs有一个称为last-command的变量专门记录这一信息。这个变量是我们将使用的在不同命令间传递信息的第一个方法。
现在唯一剩下的问题是:我们如何把这个额外的代码关联到scroll-up?修饰特性能很好的解决这一问题。前面讲过一段修饰代码可以在被修饰方法之前或之后执行。在本例中我们需要前置修饰,因为只有在执行scroll-up之前我们才能知道开始的位置在哪里。
** 声明变量
我们将以建立一个保存“撤销”信息的全局变量unscorll-to开始,它保存了unscroll应该将光标移动到的位置。我们将使用defvar来声明变量。
#+BEGIN_SRC emacs-lisp
(defvar unscroll-to nil
"Text position for next call to 'unscroll'.")
#+END_SRC
全局变量不需要声明。但是使用defvar声明变量有一些优势:
+ 使用defvar能够关联一个文档字符串给变量,就像defun那样。
+ 可以给变量赋一个默认值。本例中,unscroll-to的默认值是nil。
通过defvar给变量赋默认值与使用setq不一样。使用setq将会强制给变量赋默认值,而defvar只会在变量没有任何值时才会赋给默认值。
为什么这很重要呢?假设你的.emacs文件有这么一行:
#+BEGIN_SRC emacs-lisp
(setq mail-signature t)
#+END_SRC
这表示当你使用Emacs发送email时,你希望在最后添加你自己的签名文件。当你启动Emacs时,mail-signature被设置为了t,但是定义邮件发送代码的Lisp文件,sendmail,还没有被载入。它只有当你第一次调用mail命令时才会加载。当你调用mail时,Emacs将执行sendmail Lisp文件中的代码:
#+BEGIN_SRC emacs-lisp
(defvar mail-signature nil ...)
#+END_SRC
这表示nil是mail-signature的默认初始值。但是你已经给mail-signature赋过值了,而你不希望载入sendmail时把你的设置给覆盖了。另一方面,如果你在.emacs中并没有给mail-signature赋过任何值,你还是希望这个值能够生效。
+ 使用defvar声明的变量可以通过多个标签相关(tag-related)的命令找到。在工程中通过标签找到变量和函数定义是一种快捷的方式。Emacs的标签工具,例如find-tag函数,可以找到任何通过def...函数(defun, defalias, defmacro, defvar, defsubst, defconst, defadvice)定义的东西。
+ 当你编译字节码时(见[[file:./5.org][第五章]]),编译器如果发现变量未使用defvar声明将会产生一个警告。如果你的所有变量都进行了声明,那么你可以使用这些警告来找出哪些变量名写错了。
** 保存和取出point
让我们定义一个变量来存储scroll-up队列的最初位置,即最初光标在文本中的位置。光标在文本中的位置被称为point,其值就是从buffer开始位置计算(从1开始)有多少个字符。point的值可以由函数point得到。
#+BEGIN_SRC emacs-lisp
(defadvice scroll-up (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (eq last-command 'scroll-up))
(setq unscroll-to (point))))
#+END_SRC
这个修饰是这么工作的:
1. 函数eq告诉我们它的两个参数是否相等。本例中,参数是last-command变量的值,以及符号scroll-up。last-command的值是最后一次用户触发的命令的符号(在本章后面的部分[[使用this-command][使用this-command]]中也能看到)。
2. eq的返回值被传递给not,这会对它的参数的布尔值取反。如果nil传给not,那么返回t。如果其他值传给not,则返回nil。[[[3-17][17]]]
3. 如果not的返回值是t,即last-command的值并不是scroll-up,那么变量unscroll-to的的值将被设置为当前的point值。
现在定义unscroll就很简单了:
#+BEGIN_SRC emacs-lisp
(defun unscroll ()
"Jump to location specified by 'unscroll-to'."
(interactive)
(goto-char unscroll-to))
#+END_SRC
函数goto-char将光标移动到指定的位置。
** 窗口内容
对于这个解决方案有一些不完美的地方。在一次unscroll之后,光标确实返回到了正确的地方,但这时屏幕却看起来和按下C-v之前不一样了。例如,我在按下C-v C-v C-v之前可能正在屏幕的底部编辑一行代码。在我调用unscroll之后,虽然光标确实回到了之前的位置,但是那一行可能显示到了窗口的中间。
既然我们的目的是最小化意料之外的滚动所造成的破坏,那么我们不只希望仅仅恢复光标的位置,我们还希望之前编辑的行的位置也恢复到原处。
因此只保存point的值就不够了。我们还必须保存一个值来表示当前窗口中显示什么。Emacs提供了几个函数来描述窗口中显示什么,例如window-edges,window-height,current-window-configuration。目前我们只需要使用window-start,它表示对于给定的窗口,显示的第一个(窗口左上角)字符在buffer中的位置。这样我们只需要在命令间传递更多一点信息就可以了。
更新我们的例子很简单。首相我们要将变量unscroll-to的声明替换为两个新的变量:一个用于保存point的值,另一个用于保存窗口中第一个字符的位置。
#+BEGIN_SRC emacs-lisp
(defvar unscroll-point nil
"Cursor position for next call to 'unscroll'.")
(defvar unscroll-window-start nil
"Window start for next call to 'unscroll'.")
#+END_SRC
然后我们要修改scroll-up的修饰以及unscroll来使用这两个值。
#+BEGIN_SRC emacs-lisp
(defadvice scroll-up (before remember-for-unscroll
activate compile)
"Remember where we started from, for ‘unscroll'."
(if (not (eq last-command 'scroll-up))
(progn
(setq unscroll-point (point))
(setq unscroll-window-start (window-start)))))
(defun unscroll ()
"Revert to 'unscroll-point' and 'unscroll-window-start'."
(interactive)
(goto-char unscroll-point)
(set-window-start nil unscroll-window-start))
#+END_SRC
修饰的名称仍然是remember-for-unscroll,这会替换之前同名的修饰。
函数set-window-start和goto-char移动光标位置的方式类似,它会设置窗口开始的位置。不一样的是,set-window-start有两个参数。第一个参数表明操作的是哪个窗口。如果为nil,则默认使用当前选中的窗口。(传递给set-window-start的窗口对象可以通过类似get-buffer-window以及previous-window的函数得到。)
对于回滚我们可能还希望保持另一个信息,即窗口的hscroll,它保存着窗口横向翻滚的列数,默认为0 。我们可以添加另一个变量来保存:
#+BEGIN_SRC emacs-lisp
(defvar unscroll-hscroll nil
"Hscroll for next call to 'unscroll'.")
#+END_SRC
然后我们再次更新unscroll和scroll-up修饰来调用window-hscroll(获取窗口的hscroll值)以及set-window-hscroll(设置):
#+BEGIN_SRC emacs-lisp
(defadvice scroll-up (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (eq last-command 'scroll-up))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
(defun unscroll ()
"Revert to 'unscroll-point' and 'unscroll-window-start'."
(interactive)
(goto-char unscroll-point)
(set-window-start nil unscroll-window-start)
(set-window-hscroll nil unscroll-hscroll))
#+END_SRC
注意在这个scroll-up修饰的版本中,progn的使用:
#+BEGIN_SRC emacs-lisp
(progn
(setq ...)
(setq ...))
#+END_SRC
被合并成了一个setq,里面包含了多个“变量-值”对。这是一种简化写法,setq可以包含任意数量的变量。
** 错误检查
假如用户在调用任何scroll-up之前调用了unscroll会发生什么呢?变量unscroll-point,unscroll-window-start,以及unscroll-scroll将会包含他们的默认值,也就是nil。这个值在传递给函数goto-char,set-window-start以及set-window-scroll时是不合适的。当goto-char被调用时,unscroll的触发将会返回如下错误:“Wrong type argument: integer-or-maker-p, nil”。这表示一个函数需要接收一个数字或者标记(满足断言integer-or-marker-p),而收到的却是nil。(标记在本章前面的部分介绍过了。)
为避免用户被这些神秘的错误信息所折磨,在调用goto-char之前进行一个简单的检查并且生成一个更可读的错误信息是一个好主意:
#+BEGIN_SRC emacs-lisp
(if (not unscroll-point) ; 如果unscroll-point的值为nil
(error "Cannot unscroll yet"))
#+END_SRC
当错误发生时,unscroll将会被终止并且提示“Cannot unscroll yet”。
* 归纳出更一般的解决方法
当我们想要按C-b时按到C-v是很常见的一种情况。这也就是我们设计unscroll函数的原因。现在让我们来研究同样容易发生的想按下M-b(backward-word)却按下了M-v(scroll-down)。这是同样的问题,但也有点不一样。如果unscroll能够回撤任何方向的滚动就好了。
最直接的方法是像修饰scroll-down那样修饰scroll-up:
#+BEGIN_SRC emacs-lisp
(defadvice scroll-down (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (eq last-command 'scroll-down))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
#+END_SRC
(注意这两个函数,scroll-up和scroll-down,它们的修饰的名称,也就是remember-for-unscroll,可以一样,而且不会冲突。)
现在我们必须决定当错误的C-v和错误的M-v同时发生时unscroll如何运作。换句话说,假设你错误的按下了C-v C-v M-v。它是应该恢复到M-v之前的位置呢,还是应该恢复到最初的C-v之前?
我选择后者。但是这意味着对于scroll-up(以及scroll-down)的修饰,我们需要同时检测scroll-up和scroll-down。
#+BEGIN_SRC emacs-lisp
(defadvice scroll-up (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
(defadvice scroll-down (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'.'"
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
#+END_SRC
让我们花一点时间来确保你理解表达式
#+BEGIN_SRC emacs-lisp
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scorll-down)))
(setq ...))
#+END_SRC
阅读这段表达式最好的方法是一级一级的向里阅读。从这里开始
#+BEGIN_SRC emacs-lisp
(if (not ...)
(setq ...))
#+END_SRC
“如果为假,则设置一些变量。”下面更进一步:
#+BEGIN_SRC emacs-lisp
(if (not (or ...))
(setq ...))
#+END_SRC
“如果所有条件都为假,则设置一些变量。”最后,
#+BEGIN_SRC emacs-lisp
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scorll-down)))
(setq ...))
#+END_SRC
表示,“如果last-command不是scroll-up并且last-command不是scroll-down,那么设置一些变量。”
假设之后你希望更多的命令也按照这种方式来修饰;例如scroll-left和scroll-right:
#+BEGIN_SRC emacs-lisp
(defadvice scroll-up (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'. "
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)
(eq last-command 'scroll-left) ;new
(eq last-command 'scroll-right))) ;new
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
(defadvice scroll-down (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)
(eq last-command 'scroll-left) ;new
(eq last-command 'scroll-right))) ;new
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
(defadvice scroll-left (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)
(eq last-command 'scroll-left)
(eq last-command 'scroll-right)))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
(defadvice scroll-right (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)
(eq last-command 'scroll-left)
(eq last-command 'scroll-right)))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
#+END_SRC
这样写不仅繁琐且容易出错,而且对于每个我们需要回撤的新命令,每个之前写过的回撤命令都需要加入对于新的last-command的检测。
** 使用this-command
有两种方法可以改善这种情况。第一种,既然每个修饰都差不多,我们可以把它们提取一下:
#+BEGIN_SRC emacs-lisp
(defun unscroll-maybe-remember ()
(if (not (or (eq last-command 'scroll-up)
(eq last-command 'scroll-down)
(eq last-command 'scroll-left)
(eq last-command 'scroll-right)))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
(defadvice scroll-up (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(unscroll-maybe-remember))
(defadvice scroll-down (before remember-for-unscroll
activate compile)
"'Remember where we started from, for 'unscroll'."
(unscroll-maybe-remember))
(defadvice scroll-left (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(unscroll-maybe-remember))
(defadvice scroll-right (before remember-for-unscroll
activate compile)
"Remember where we started from, for 'unscroll'."
(unscroll-maybe-remember))
#+END_SRC
第二种,不去检测n种可能的last-command值,我们可以使用一个单独的变量来保存每种情况的last-command值。
当前用户触发的命令的名称会保存在变量this-command中。实际上,last-command的值是这样得到的:当Emacs执行一个命令时,this-command保存着命令的名字;当执行完成时,Emacs会将this-command的值赋给last-command。
当命令执行时,它会改变this-command的值。当下个命令执行时,这个值会保存在last-command中。
让我们选择一个符号来代表所有可回撤的命令:例如,unscrollable。现在我们可以修改一下unscroll-maybe-remeber:
#+BEGIN_SRC emacs-lisp
(defun unscroll-maybe-remember ()
(setq this-command 'unscrollable)
(if (not (eq last-command 'unscrollable))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
#+END_SRC
调用这个函数的命令会将this-command设置为unscrollable。现在我们只需要检测一个变量而不必检测四种不同情况的last-command(也许还会更多)了。
** 符号属性
我们改进版的unscroll-maybe-remeber工作的非常好,但是(你可能已经预料到了)我们还是可以做一些改进。首先就是变量this-command和last-command并不是只有我们自己使用。它们对于Emacs Lisp解释器很重要,而Emacs的其他功能也依赖于这两个值。而我们知道,有一些使用了这些滚动函数的Emacs特性并没有修改this-commandh和last-command。而且,我们更想要一个专有的值来标识所有可回滚的命令。
这里我们引入好用的符号属性(symbol properties)。Emacs Lisp符号不只用来保存函数定义,它还有一组关联的属性列表。属性列表是一组键值映射。每个名字是一个Lisp符号,每个值可以是任意Lisp表达式。
属性使用put函数来保存,使用get函数来读取。因此,如果我们将17保存在符号a-symbol的some-property的属性中:
#+BEGIN_SRC emacs-lisp
(put 'a-symbol 'some-property 17)
#+END_SRC
那么
#+BEGIN_SRC emacs-lisp
(get 'a-symbol 'some-property)
#+END_SRC
将返回17 。如果我们从一个符号读取一个并不存在的属性,则返回nil。
我们可以将unscrollable作为一个属性,而不是作为一个储存this-command和last-command的值的变量。我们可以将支持返回的命令的unscrollable属性设为t:
#+BEGIN_SRC emacs-lisp
(put 'scroll-up 'unscrollable t)
(put 'scroll-down 'unscrollable t)
(put 'scroll-left 'unscrollable t)
(put 'scroll-right 'unscrollable t)
#+END_SRC
这只需要在调用unscroll-maybe-remember之前执行一次就行了。
现在如果x是scroll-up,scroll-down,scroll-left,scroll-right之中的一个的话则(get x unscrollable)会返回t。对于其他的符号,因为unscrollable属性默认未定义,所以结果为nil。
现在我们可以将unscroll-maybe-remember中的
#+BEGIN_SRC emacs-lisp
(if (not (eq last-command 'unscrollable)) ...)
#+END_SRC
修改为
#+BEGIN_SRC emacs-lisp
(if (not (get last-command 'unscrollable)) ...)
#+END_SRC
而且我们还可以停止将unscrollable赋值给this-command:
#+BEGIN_SRC emacs-lisp
(defun unscroll-maybe-remember ()
(if (not (get last-command 'unscrollable))
(setq unscroll-point (point)
unscroll-window-start (window-start)
unscroll-hscroll (window-hscroll))))
#+END_SRC
** 标记
我们能否将这段代码改的更好呢?假设你不小心按下了几次scroll-down然后你想unscroll。但是在你这么做之前,你发现了一些你想要修改的代码,然后你进行了修改。然后你再unscroll。这时屏幕并没有被正确的恢复!
这是因为编辑buffer中前面的文字将会改变所有后面的文字的位置。添加或者删除n个字符将会对所有后续的字符位置添加或减少n。因此unscroll-point和unscroll-window-start所保存的值都会被影响n(如果n为0,那么你很幸运)。
使用标记(marker)而不是unscroll-point和unscroll-window-start的绝对位置将会是一个很好的选择。标记是一种就像数字一样用来保存buffer位置的特殊对象。但是如果由于插入或者删除造成了buffer位置的更改,那么标记也会跟着更改。
既然我们要将unscroll-point和unscroll-window-start修改为标记,我们就不需要将他们初始化为nil了。我们可以使用make-marker来将它们初始化为空的标记对象:
#+BEGIN_SRC emacs-lisp
(defvar unscrollfpoint (make-marker)
"Cursor position for next call to 'unscroll'.")
(defvar unscroll-window-start (make-marker)
"Window start for next call to ''unscroll'.")
#+END_SRC
函数set-marker被用来设置标记的位置。
#+BEGIN_SRC emacs-lisp
(defun unscroll-maybe-remember ()
(if (not (get last-command 'unscrollable))
(progn
(set-marker unscroll-point (point))
(set-marker unscroll-window-start (window-start))
(setq unscroll-hscroll (window-hscroll)))))
#+END_SRC
progn又回来了,因为setq被拆分成了几个不同的函数调用。我们不对unscroll-hscroll使用标记,因为它的值并不是buffer位置。
我们并不需要重写unscroll,因为goto-char和set-window-start的参数不管是标记还是数字都会很好的工作。所以前面的定义(为了方便这里在贴一次)还是能够工作:
#+BEGIN_SRC emacs-lisp
(defun unscroll ()
"Revert to 'unscroll-point' and 'unscroll-window-start'."
(interactive)
(goto-char unscroll-point)
(set-window-start nil unscroll-window-start)
(set-window-hscroll nil unscroll-hscroll))
#+END_SRC
** 附录:关于效率
当我们定义unscroll-point和unscroll-marker时,我们创建了空的符号对象并且在每次调用unscroll-remember时复用它们,而不是每次都创建新的并且扔掉旧的。这是一种优化。这并不仅仅是说我们应该尽可能的避免这种对象创建的消耗,更多的是因为标记要比其他变量的消耗更大。每次buffer做出修改的时候标记都会跟着修改。弃用的标记最终会被垃圾回收器回收掉,但是直到那时它都会降低编辑buffer的速度。
通常,如果你想要弃用一个标记对象m(即你不需要再使用它的值了),这么做将会是很好的选择:
#+BEGIN_SRC emacs-lisp
(set-marker m nil)
#+END_SRC
<<3-16>>[16]. 虽然在[[file:./2.org][第二章]],我们使用了defalias将scroll-ahead和scroll-behind替代了scroll-up和scroll-down,本章我们依然使用它们之前的名称。
<<3-17>>[17]. 如果你认为not的行为看起来跟null很像,你是对的--它们就是同一个函数。它们其中一个就是另一个的别名。你使用哪个只是一个可读性方面的问题。当要检测一个列表是否为空列表时使用null。当要对一个值取反的时候使用not。
================================================
FILE: 4.org
================================================
#+TITLE: 搜索和修改Buffers
#+SETUPFILE: ./resource/template.org
* 在本章:
*插入当前时间*
*记录戳*
*修改戳*
很多场景中你会想要在buffer中搜索一个字符串,可能还希望用另一个字符串替换它。本章中我们将会为此展示很多有效的方法。我们将会讲解那些执行搜索功能的函数,并且展示如何构建正则表达式,这会给你的搜索带来巨大的灵活性。
* 插入当前时间
当你编辑一个文件时插入当前的日期或者时间有时是很有用的。例如,在我写这一章的时候,是1996年8月18日星期五的晚上10点半。几天前,我在编辑一个Emacs Lisp代码文件时,我将注释
#+BEGIN_SRC emacs-lisp
;; Each element of ENTRIES has the form
;; (NAME (VALUE-HIGH . VALUE-LOW))
#+END_SRC
修改为
#+BEGIN_SRC emacs-lisp
;;NTRIES has the form
;; (NAME (VALUE-HIGH . VALUE-LOW))
;; [14 Aug 96] I changed this so NAME can now be a symbol,
;; a string, or a list of the form (NAME . PREFIX) [bg]
#+END_SRC
我在注释中插入了一个时间戳,因为这在当我以后要修改这段代码时,我可以知道这段代码是在之前什么时候做出的修改。
如果你知道函数current-time-string返回今天的日期和时间字符串,那么插入当前时间的命令是很简单的:[[[4-18][18]]]
#+BEGIN_SRC emacs-lisp
(defun insert-current-time ()
"Insert the current time"
(interactive "*")
(insert (current-time-string)))
#+END_SRC
本章后面的章节[[更多的星号魔法][更多的星号魔法]]将会解释(interactive "*")和insert的含义。
上面这个简单的函数非常不灵活,因为它只会插入类似“Sun Aug 19 22:34:53 1996”这种格式的字符串(标准C的库函数ctime和asctime的形式)。这在你希望的是日期,或者只是时间,或者12小时制而不是24小时制,或者日期的形式为”18 Aug 1996”或者“8/18/96”或者“18/8/96”时是非常笨重的。
幸运的是,我们只需要做一点额外的工作就可以获得更好的结果。Emacs有一些其他的时间相关的函数,特别是current-time,它会以一种原始形式返回当前时间,以及format-time-string,它以时间值以及格式作为参数(C函数的strftime的形式)。例如,
#+BEGIN_SRC emacs-lisp
(format-time-string "%l.%M %p" (current-time))
#+END_SRC
返回“10.38 PM”。(这里使用的格式代码%l,即“1-12小时”,%M,(0-59分),以及%P,“AM或者PM”。使用describe-function来查看format-time-string的完整各式列表)。
这样我们就可以很容易地提供两个命令,一个用来插入当前的时间,另一个用来插入当前的日期。我们还可以根据用户提供的格式配置变量来返回特定的各式。这两个函数分别命名为insert-time和insert-date。而格式配置变量分别为insert-time-format和insert-date-format。
** 用户选项和文档字符串
首先我们定义变量。
#+BEGIN_SRC emacs-lisp
(defvar insert-time-format "%X"
"*Format for \\[insert-time] (c.f. 'format-time-string').")
(defvar insert-date-format "%x"
"*Format for \\[insert-date] (c.f. 'format-time-string').")
#+END_SRC
关于文档字符串我们能看到两个新特性。
首先,每个都以星号(*)开始。defvar的文档字符串以星号开头有特殊的含义。它表示这个变量是一个用户选项(user option)。用户选项和其他Lisp变量没什么区别,除了下面这两种情况:
+ 用户选项可以使用set-variable命令以交互的方式进行设置,Emacs会问用户要一个变量名(Emacs会自动补全用户的输入)以及一个值。有时,可以不以Lisp语法输入值;也就是说,不必在输入的时候带着外面的括号。当你设置非用户选项的变量值时,你必须这样做:
#+BEGIN_SRC emacs-lisp
M-: (setq variable value) RET
#+END_SRC
(需要使用Lisp语法来设置值)。
而用户选项可以通过M-x edit-options RET[[[4-19][19]]]激活option-editing模式来进行编辑。
+ 关于文档字符串的第二个新特性是它们都包含一个特殊的结构\[command]。(是的,确实是\[...],但是因为是在Lisp字符串里面,反斜杠需要被转义:\\[...])。这个语法非常神奇。当文本字符串显示给用户时--例如当用户使用apropos或者describe-variable时--\[command]将会被替换为command所关联的键绑定。例如,如果C-x t会触发insert-time,那么文本字符串
#+BEGIN_SRC emacs-lisp
"*Format for \\[insert-time] (c.f. 'format-time-string')"
#+END_SRC
将显示为
#+BEGIN_SRC emacs-lisp
*Format for C-x t (c.f. 'format-time-string').
#+END_SRC
如果insert-time并没有键绑定,那么将会默认显示M-x insert-time。如果有多个键绑定到了insert-time,Emacs会自己选择一个。
如果你希望字符串\[insert-time]不被替换,如何阻止它被替换为对应的键绑定呢?对于这种情况有一个特殊的转义序列:\=。当\=出现在\[...]之前时,\[...]将不会发生替换。当然在Lisp字符串里面你需要这样写“...\\=\\[...]...”。
如果你并不希望一个变量作为用户选项,而你又希望它的文本字符串以星号开头时,\=也会起作用。
所有在多个函数之间共享的变量都应该使用defvar来声明。如何选择哪些变量作为用户选项存在呢?一个经验之谈是如果某个变量直接控制一个用户可见的并且想要控制的特性,并且设置这个变量很简单时(也就是没有复杂的数据结构和特定的编码值),那么就可以将它设置为用户选项。
** 更多的星号魔法
前面我们定义了用来控制insert-time和insert-date的变量,下面就是这两个简单函数的定义。
#+BEGIN_SRC emacs-lisp
(defun insert-time ()
"Insert the current time according to insert-time-format."
(interactive "*")
(insert (format-time-string insert-time-format
(current-time))))
(defun insert-date ()
"Insert the current date according to insert-date-format."
(interactive "*")
(insert (format-time-string insert-date-format
(current-time))))
#+END_SRC
这两个函数非常相似,除了一个使用insert-time-format而另一个使用insert-date-format。insert函数使用任意数量的参数(类型必须为字符串或者字符),顺序的将它们插入到当前文本位置的后面。
对于这两个函数最需要注意的是它们都以下面的结构开始
#+BEGIN_SRC emacs-lisp
(interactive "*")
#+END_SRC
之前你已经知道了interactive将一个函数转变为一个命令,以及指定用户交互输入时如何获取函数的参数。但是我们在之前并没有看到过*作为interactive的参数,而且,这两个函数并没有参数,那么这个*到底代表什么呢?
当星号作为interactive的第一个参数时,这表示“如果当前buffer为只读时终止这个函数”。在函数开始之前就去检测buffer是否为只读要比函数执行了一半才提示用户“Buffer is read-only”错误信息要更好。在本例中,如果我们忽略对于buffer只读的检测,insert函数将会触发它自己的“Buffer is read-only”错误,这当然也没有什么危害会发生。但是在其他更复杂的函数里,这可能会造成一些不可逆的副作用(例如修改了全局变量)。
* 记录戳(Writestamps)
以一种可配置的格式自动插入当前的时间和日期是非常简洁并且可能超过了大多数编辑器的功能,但是这并不是太有用。很显然更有用的能力是将一个记录戳(文件最后修改的日期、时间)保存在文件里。每次文件保存时记录戳会自动更新。
** 更新记录戳
首先我们要做的是每次文件保存时自动执行我们的writestamp-updating代码。就像我们在[[file:./2.org][第二章]]的章节[[file:2.org::*钩子][钩子]]中看到的,把代码跟某些常用动作(例如保存文件)关联的最好方式就是将函数添加到一个钩子变量里。使用M-x apropos RET hook RET,我们可以找到四个可能的钩子变量:after-save-hook, local-write-file-hooks, write-contents-hooks以及write-file-hooks。
首先我们排除掉after-save-hook。我们并不希望我们的记录戳在文件保存之后才修改,因为这样我们就永远无法保存文件了(死循环)。
其他候选人的差别比较微妙:
+ write-file-hooks
代码将在buffer保存时执行。
+ local-write-file-hooks
一个buffer-local版本的write-file-hooks。回忆一下[[file:2.org][第二章]]的章节[[file:2.org::*钩子][钩子]]中关于buffer局部变量的描述,即每个buffer都有自己不同的变量。write-file-hooks作用于每个buffer,而local-write-file-hooks做只对单个buffer起作用。因此,如果你希望保存Lisp文件时执行一个函数,而保存文本文件时执行另一个,那么local-write-file-hooks就是你的选择。
+ write-contents-hooks
local-write-file-hooks是一个buffer局部变量,每当buffer被保存到文件时它将会执行。但是--就像我提醒过你这很微妙--write-contents-hooks作用于buffer的内容,而其他两个钩子作用于编辑的文件。实际上,这意味着如果你改掉了buffer的主模式,你也改变了内容的行为方式,因此write-contents-hooks会被重置为nil而local-write-file-hooks却不会。另一方面,如果你更改了Emacs关于你正编辑哪个文件的想法,例如通过调用set-visited-file-name,那么local-write-file-hooks将会被重置为nil而write-contents-hooks却不会。
我们排除掉write-file-hooks,因为我们只想在拥有记录戳的buffer保存时才调用我们的函数,而并不是所有buffer都触发。而撇除语法上的吹毛求疵,我们会排除掉write-contents-hooks,因为我们希望所选择的钩子变量对于buffer的主模式的变更不做回应。这样就只剩下了local-write-file-hooks。
现在,我们要在local-write-file-hooks中放置什么样的函数呢?我们必须定位每个记录戳,删除掉它,并且用新的记录戳来替换它。最简单直接的方法是将每个记录戳用特殊的字符串标记括起来。例如我们可以使用 “ WRITESTAMP((”放在左边而“))”放在右边,这样它在文件里看起来是这样的:
#+BEGIN_SRC emacs-lisp
went into the castle and lived happily ever after.
The end. WRITESTAMP((12:19pm 7 Jul 97))
#+END_SRC
假设WRITESTATMP((...))当中的东西是由insert-date放入的(我们之前已经定义了),那么它的格式可以通过insert-date-format进行控制。
现在,假设文件里已经有了一些记录戳,[[[4-20][20]]]我们可以在保存文件时这么更新它们:
#+BEGIN_SRC emacs-lisp
(add-hook 'local-write-file-hooks 'update-writestamps)
(defun update-writestamps ()
"Find writestamps and replace them with the current time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(while (search-forward "WRITESTAMP((" nil t)
(let ((start (point)))
(search-forward "))")
(delete-region start (- (point) 2))
(goto-char start)
(insert-date))))))
nil)
#+END_SRC
这里有很多的新知识。让我们一行一行的来阅读这个函数。
首先我们看到函数体被包在了一个函数save-excursion中。save-excursion的作用是记录光标的位置,执行参数中的子表达式,然后将光标移动回原处。在这里它很有用,因为我们的函数体会将光标在buffer中到处移动,而在函数结束时我们希望函数的调用者感觉不到这些。在[[file:8.org][第八章]]中将会有更多关于save-excursion的信息。
下一步调用了save-restriction。它的作用方式跟save-excursion相似,也是记录了某些信息,然后执行它的参数,然后将信息恢复。这里它记录的是buffer的restriction,它是narrowing的结果。narrowing在[[file:9.org][第九章]]中将会做具体描述。现在我们只要知道narrowing是Emacs的一种只展示buffer的一部分的能力。因为update-writestamps将会调用widen,这会移除掉所有narrowing的效果,我们需要save-restriction来在我们做完之后恢复现场。
下一步我们要调用save-match-data,就像save-excursion和save-restriction,它保存了一些信息,执行它的参数,然后恢复信息。这一次保存的信息是最后一次搜索的结果。每次查找动作执行时,查找的结果将会被保存到一些全局变量里(我们马上会看到)。每次搜索都会替换掉前面的结果。我们的函数将会执行一次搜索,但是如果出现了其他函数调用我们的函数的情况,我们不希望破坏全局的数据。
下面调用widen。就像前面提到的,这会移除所有narrowing的效果。它使得整个buffer都可以被访问,因为我们需要找到整个buffer的记录戳,所以这是必须的。
下面我们使用(goto-char (point-min))将光标移动到了buffer的开头,然后开始函数的主循环,也就是搜索整个buffer的记录戳并将其更新。函数point-min返回point的最小值,通常为1。唯一(point-min)不为1的情况就是使用了narrowing。因为我们调用了widen,所以narrowing不会生效,因此代码也可以写成(goto-char 1)。但是使用point-min是一种很好的实践)。
主循环看起来是这样的:
#+BEGIN_SRC emacs-lisp
(while (search-forward "WRITESTAMP((" nil t)
...)
#+END_SRC
这是一个while循环,它跟其他语言中的while循环功能相似。第一个参数是每次循环时的判断表达式。如果表达式返回真,则其他参数被执行,循环继续。
表达式(search-forward "WRITESTAMP((" nil t)将会从当前位置开始,搜索第一个匹配的字符串。nil表示将会一直搜索到buffer的结尾。稍后将介绍更多细节。t表示如果没发现匹配项,search-foward将会简单的返回nil。(如果不设t,search-forward在未找到匹配项时将会触发一个错误,终止当前的命令。)如果搜索成功了,point将会移动到匹配的字符串之后的第一个字符,search-forward将会返回这个位置(可以通过使用match-beginning来找到搜索开始的位置,如图4-1所示)。
[[file:resource/4-1.png]]
图4-1 在搜索了WRITESTAMP((之后
while的循环体是
#+BEGIN_SRC emacs-lisp
(let ((start (point)))
#+END_SRC
这会创建一个临时变量start,用于保存point的位置,也就是WRITESTARMP((...)分隔符中日期字符串的开始位置。
start定义了之后,let的body包含如下:
#+BEGIN_SRC emacs-lisp
(search-forward "))")
(delete-region start (- (point) 2))
(goto-char start)
(insert-date)
#+END_SRC
这里search-forward会把point放置到两个反括号的后面。我们仍然知道时间戳的开头位置,因为它已经保存到了start中,如图4-2所示。
[[file:resource/4-2.png]]
图 4-2 在搜索了”))” 之后
这一次,我们只提供了第一个参数作为搜索字符串。前面我们还看到了两个额外参数:搜索的范围,以及是否触发错误。当省略的时候,他们默认为nil(不限制搜索范围)以及nil(如果搜索失败触发错误)。
在search-forward成功之后--如果失败了,则函数产生错误并且终止--delete-region将会删除记录戳中的日期,从start的位置开始到(- (point) 2)的位置(point左边两个字符)结束,结果如图4-3所示。
[[file:resource/4-3.png]]
图4-3 在删除了start和(- (point) 2)之间的区域之后。
下一步,(goto-char start)将会把光标移动到记录戳分隔符里里面,最后,(insert-date)插入当前的日期。
while循环会在找到匹配项时一直循环下去。每次找到匹配项,光标都必须在匹配项的右面。否则,循环将只能一直搜索到第一项而不会进行下去。
当while循环结束后,save-match-data返回,恢复搜索的全局数据;然后save-restriction返回,恢复所有生效的narrowing;然后save-excursion返回,将point恢复到原始位置。
update-writestamps在save-excursion调用之后的最后一个表达式,是一个
#+BEGIN_SRC emacs-lisp
nil
#+END_SRC
这是函数的返回值。Lisp函数的返回值就是函数体的最后一个表达式的值。(所有的Lisp函数都有一个返回值,但是至今为止我们所写的每个函数都没有返回有意义的返回值,而只是作为一种“副作用”存在。)本例中我们强制它返回nil。原因是local-write-file-hooks中的函数需要特殊处理。通常,钩子变量中的函数的返回值并不重要。但是对于local-write-file-hooks(以及write-file-hooks和write-contents-hooks)中的函数来说,非空的返回值表示,“这个钩子函数接管了将buffer写入文件的工作”。如果返回非空值,则钩子变量中的其他函数将不会被执行,而Emacs自己的写文件的函数将不会被执行。既然update-writestamps没有接替将buffer写入文件的工作,我们需要它的返回值为nil。
** 归纳更一般的记录戳
我们实现的记录戳工作了,但是仍然有一些问题。首先,我们的记录戳字符串“WRITESTAMP((”和“))”对于用户来说非常的缺乏美感并且不灵活。第二,用户可能并不希望使用insert-date来插入记录戳。
这些问题的修正很简单。我们可以引入三个新的变量:一个就像insert-date-format和insert-time-format那样描述要使用的时间格式;另外两个用来描述将记录戳括起来的分隔符。
#+BEGIN_SRC emacs-lisp
(defvar writestamp-format "%C"
"*Format for writestamps (c.f. 'format-time-string').")
(defvar writestamp-prefix "WRITESTAMP(("
"*Unique string identifying start of writestamp.")
(defvar writestamp-suffix "))"
"*String that terminates a writestamp.")
#+END_SRC
现在我们可以修改update-writestamps来使它更加灵活。
#+BEGIN_SRC emacs-lisp
(defun update-writestamps ()
"Find writestamps and replace them with the current time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(while (search-forward writestamp-prefix nil t)
(let ((start (point)))
(search-forward writestamp-suffix)
(delete-region start (match-beginning 0))
(goto-char start)
(insert (format-time-string writestamp-format
(current-time))))))))
nil)
#+END_SRC
在这个版本的update-writestamps里,我们将”WRITESTAMP((”和“))”替换成了writestamp-prefix和writestamp-suffix,并且将insert-date替换为了
#+BEGIN_SRC emacs-lisp
(insert (format-time-string writestamp-format
(current-time)))
#+END_SRC
我们还改变了delete-region的调用。前面它看起来是这样的:
#+BEGIN_SRC emacs-lisp
(delete-region start (- (point) 2))
#+END_SRC
之前我们的记录戳的后缀被写死为“))”,而它的长度为2。但是现在我们的后缀被储存在一个变量中,我们并不能提前知道它的长度。我们当然可以通过调用length来获得它:
#+BEGIN_SRC emacs-lisp
(delete-region start (- (point)
(length writestamp-suffix)))
#+END_SRC
但是一个更好的解决方案是使用match-beginning。记得我们在调用delete-region之前是
#+BEGIN_SRC emacs-lisp
(search-forward writestamp-suffix)
#+END_SRC
不管writestamp-suffix是什么,search-forward都会找到第一个匹配项,并且返回它之后的第一个位置。而关于匹配的其他额外信息,特别是匹配项的开始的位置,被存储在了Emacs的一个全局的匹配项变量里。访问这个数据需要通过函数match-beginning以及match-end。由于稍后可见的原因,match-beginning需要一个参数0来告诉你最后一次搜索的匹配项的开始的位置。本例中,这也就是记录戳后缀的开始的位置,也就是记录戳里面日期的末尾,也就是要删除的范围的结尾:
#+BEGIN_SRC emacs-lisp
(delete-region start (match-beginning 0))
#+END_SRC
** 正则表达式
假设用户选择“Written:”和“.”作为writestamp-prefix和writestamp-suffix的值,那么记录戳看起来将会是这样的:“Written: 19 Aug 1996.”。这是一个很有可能的用户选择,但是字符串“Written:”并不像“WRITESTAMP((”这么特殊。换句话说,文件中很有可能包含其他“Written:”字符串而它并不是一个记录戳。当updatewritestamps搜索writestamp-prefix时,它将会找到其中一个,然后它会搜索后缀,删掉它们之间所有的东西。更糟糕的是,这种异常删除的发生几乎是不可察的,因为当文件保存之后它就可能发生。
解决这个问题的一种方式是加强记录戳格式的限制,使错误的匹配更难发生。一种自然的可以做出的限制是将记录戳单独存于一行:换句话说,只有当writestamp-prefix作为一行的开始而writestamp-suffix作为一行的结束时,字符串才有可能是记录戳。
这样
#+BEGIN_SRC emacs-lisp
(search-forward writestamp-prefix ...)
#+END_SRC
就并不满足用来搜索记录戳了,因为这并不会只在行的开始搜索匹配项。
这就是正则表达式出场的好时机了。正则表达式(regular expression)--简写为regexp或者regex--是一种类似search-forward的第一个参数那样的搜索模式。并不像通常的搜索模式,正则表达式有一些语法规则提供给我们更强大的搜索功能。例如,在正则表达式‘^Written:’中,符号(^)是一个特殊符号,表示“这个模式必须匹配行的开始”。表达式‘^Written:’剩下的字符在正则中并没有什么特殊的含义,所以他们和普通的搜索模式所表达的意思一样。特殊的字符有时被称为元字符(metacharacters)或者(更有诗意的)魔法字符。
许多UNIX程序使用了正则,这包括sed,grep,awk以及pert。不幸的是每个程序的正则都或多或少的不一样;但是在所有情况下,大多数字符是非“魔法”的(特别是字母和数字)并且可以被用来搜索他们自己;更长的正则可以由短一些的正则拼接而成。下面是Emacs中使用的正则表达式的语法。
1. 点号(.)匹配除换行符外的所有单个字符。
2. 反斜杠后面跟任何元字符则匹配该字符本身。例如,\.将匹配点号。而且反斜杠本身是一个元字符,\\将会匹配\本身。
3. 中括号里的字符匹配任何括号里的字符。所以[aeiou]匹配任何a或者e或者i或者o或者u。这个规则有一些例外--正则表达式的方括号语法有自己的“子语法”,如下:
a. 连续的字符,例如abcd,可以简写为a-d。这个范围可以为任意长度,并且可以和其他单个字符混合。所以[a-dmx-z]可以匹配任何a, b, c, d, m, x, y, 或者z。
b. 如果第一个字符是(^),那么表达式匹配任何不在方括号内的字符。所以[^a-d]匹配除了a, b, c, 或者d之外的字符。
c. 要包括一个右中括号,它必须是第一个字符。所以[]a]匹配]或者a。同样的,[^]a]匹配任何除]和a之外的字符。
d. 要包括一个中横线,它必须出现在一个不能被表意为范围的地方;例如,它可以是第一个或者最后一个字符,或者跟在某个范围的后面。所以[a-e-z]匹配a, b, c, d, e,-,或者z。
e. 要包括一个(^),它必须出现在除第一个字符之外的地方。
f. 其他正则中的元字符,例如*和.在方括号中作为普通字符存在。
4. 正则表达式x可能有以下后缀:
a. 星号*,匹配0或多个x
b. 加号+,匹配1或多个x
c. 问号?,匹配0或1个x
所以a*表示a, aa, aaa甚至空字符串(0个a);[[[4-21][21]]]a+匹配a, aa, aaa,但是不能为空;a?匹配空字符串和a。可以注意到x+等同于xx*。
5. 正则表达式^x匹配任何行首x所匹配的值。
x$匹配任何行尾x匹配的值。
这表示^x$匹配一行只包含x的值。而你也可以把x去掉;^$匹配不包含任何字符的行。
6. 两个正则表达式x和y被\|分割表示匹配任何x匹配的或者y匹配的值。所以hello\|goodbye匹配hello或者goodbye。
7. 正则表达式x被转义的括号所包裹--\(和\)--匹配任何x匹配的东西。这可以被用于分组复杂的表达式。所以\(ab\)+匹配ab, abab, ababab, 等等。同样,\(ab\)|\(cd\)ef匹配abef或者cdef。
作为副作用,任何被括起来的子表达式匹配的文本被称为子匹配项(submatch)并且被储存在一个编号的记录器内。子匹配项根据\(从左到右出现的位置而编号为1到9。所以如果用正则表达式ab\(cd*e\)匹配文字abcddde,那么只会匹配到子匹配项cddde。如果使用ab\(cd\|ef\(g+h\)\)j\(k*\)匹配文字abefgghjkk,那么第一个子匹配项是efggh,第二个是ggh,第三个是kk。
8. 反斜杠后面跟一个数字n表示匹配和第n个括起来的子表达式相同的文本。所以表达式\(a+b\)\1匹配abab,aabaab,和aaabaaab,但是不匹配abaab(因为ab和aab不同)。
9. 有很多种方法可以匹配空字符串。
a. \`匹配在buffer开始处的空字符串。所以\`hello匹配buffer开头处的hello,而不匹配任何其他的hello。
b. \'匹配buffer末尾处的空字符串。
c. \=匹配当前point位置处的空字符串。
d. \b匹配单词开始或结尾处的空字符串。所以\bgnu\b匹配词“gnu”但是不能匹配单词“interegnum”中的“gnu”。
e. \B匹配任何除单词开始和结尾处的空字符串。所以\Bword匹配“sword”中的“word”而不匹配“words”中的“word”。
f. \<只匹配单词开始处的空字符串。
g. \>值匹配单词结束处的空字符串。
如你所见,正则表达式在很多情况下使用反斜杠。Emacs Lisp字符串语法也如此。而由于在编写Emacs时正则表达式是使用Lisp字符串写的,这两种使用反斜杠的规则将会引起一些令人烦恼的结果。例如,正则表达式ab\|cd,当以Lisp字符串写出时,需要写成“ab\\|cd”。更奇怪的是当你想要使用正则表达式\\匹配反斜杠\时:你必须写成“\\\\”。提示你输入正则表达式的Emacs命令(例如apropos和keeplines)允许你在输入时只写正则而不用写成Lisp字符串的形式。
** 正则引用
现在我们知道了如何使用正则表达式,看起来搜索行首的writestamp-prefix只需要在它前面加一个(^)而行尾的writestamp-suffix只需要在后面加一个$,就像这样:
#+BEGIN_SRC emacs-lisp
(re-search-forward (concat "^"
writestamp-prefix) ...) ; 错啦!
(re-search-forward (concat writestamp-suffix
"$") ...) ; 错啦!
#+END_SRC
函数concat将它的参数合成一个单独字符串。函数re-search-forward是search-forward的正则表达式版本。
这几乎是正确的。但是,它有一个常见的错误:writestamp-prefix或者writestamp-suffix都可能包含元字符。实际上,writestamp-suffix确实有,在我们的例子里即“.”。因为点号匹配任何字符(除了换行符),这个表达式:
#+BEGIN_SRC emacs-lisp
(re-search-forward (concat writestamp-suffix
"$") ...)
#+END_SRC
等同于表达式:
#+BEGIN_SRC emacs-lisp
(re-search-forward ".$" ...)
#+END_SRC
这会匹配任何行尾的字符,而我们只想要匹配点号(.)。
当像本例中那样构建一个正则表达式,而writestamp-prefix的内容却超出了程序员的控制时,移除字符串中包含的元字符的“魔力”而让他们只表达字面意思是必须的。Emacs为此提供了一个函数regexp-quote,它理解正则的语法然后将一个正则表达式字符串转换为对应的“非魔法”的字符串。例如(regexp-quote ".")会产生“\\.”。你应该总是使用regexp-quote来移除作为变量提供的字符串中的魔力。
我们现在知道了如何开始编写新版本的update-writestamps:
#+BEGIN_SRC emacs-lisp
(defun update-writestamps ()
"Find writestamps and replace them with the current time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(while (re-search-forward
(concat "^"
(regexp-quote writestamp-prefix))
nil t)
...))))
nil)
#+END_SRC
** 有限搜索
让我们编写while循环的body来完成新版本的update-writestamp。在re-search-forward完成后,我们需要知道当前行是否以writestamp-suffix结束。但是我们不能简单的这么写
#+BEGIN_SRC emacs-lisp
(re-search-forward (concat (regexp-quote writestamp-suffix)
"$"))
#+END_SRC
因为这可能会匹配到非本行的匹配项。我们只对本行是否匹配感兴趣。
我们的解决方式是只把搜索限制在本行。search-forward和re-search-forward的第二个可选参数,如果不是nil的话,是指搜索时不超过的位置。如果我们将当前行的末尾位置作为参数传入:
#+BEGIN_SRC emacs-lisp
(re-search-forward (concat (regexp-quote writestamp-suffix)
"$")
end-of-line-position)
#+END_SRC
那么搜索就会限制到本行之内,这正是我们需要的。那么问题是我们如何得到end-of-line-position的值呢?我们可以简单的使用endf-of-line将光标移动到行尾,然后得到point的值。但是要记住在这样做之后我们需要把光标移动到它原来的地方。移动光标然后恢复场景的工作正是save-excursion所做的。所以我么可以这么写:
#+BEGIN_SRC emacs-lisp
(let ((end-of-line-position (save-excursion
(end-of-line)
(point))))
(re-search-forward (concat (regexp-quote writestamp-suffix)
"$")
end-of-line-position))
#+END_SRC
这会创建一个临时变量end-of-line-position来限制re-search-forward的搜索范围;但是不使用这个变量更简单:
#+BEGIN_SRC emacs-lisp
(re-search-forward (concat (regexp-quote writestamp-suffix)
(save-excursion
(end-of-line)
(point))))
#+END_SRC
注意save-excursion表达式的返回值是它的最后一条语句(point)的值。
所以update-writestamps可以被写成:
#+BEGIN_SRC emacs-lisp
(defun update-writestamps ()
"Find writestamps and replace them with the current time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(while (re-search-forward
(concat "^"
(regexp-quote writestamp-prefix))
nil t)
(let ((start (point)))
(if (re-search-forward (concat (regexp-quote
writestamp-suffix)
"$")
(save-excursion
(end-of-line)
(point))
t)
(progn
(delete-region start (match-beginning 0))
(goto-char start)
(insert (format-time-string writestamp-format
(current-time))))))))))
nil)
#+END_SRC
** 更强大的正则能力
我们已经把我们最初的update-writestamps转换成了正则的形式,但是却并没有真正的展现出正则强大的能力。实际上,上面那长长的用于找到记录戳,检测同一行内的记录戳后缀,然后将其替换的代码可以被简化为下面的两个表达式:
#+BEGIN_SRC emacs-lisp
(re-search-forward (concat "^"
(regexp-quote writestamp-prefix)
"\\(.**\\)"
(regexp-quote writestamp-suffix)
"$"))
(replace-match (format-time-string writestamp-format
(current-time))
t t nil 1)
#+END_SRC
第一个表达式,使用下面的正则调用了re-search-forward:
#+BEGIN_SRC emacs-lisp
^prefix\(.*\)suffix$
#+END_SRC
这里的prefix和suffix是regexp-quote版本的writestamp-prefix和writestamp-suffix。这个正则表达式匹配以记录戳前缀开始,跟着任何字符串(使用\(...\)构建的子匹配项),以记录戳后缀结束的一行。
第二个表达式调用了replace-match,它将会替换部分或者所有前一次搜索的匹配项。它的用法如下:
#+BEGIN_SRC emacs-lisp
(replace-match new-string
preserve-case
literal
base-string
subexpression)
#+END_SRC
第一个参数是要插入的新字符串,本例中也就是format-time-string的返回值。剩下的参数都是可选参数,解释如下:
+ preserve-case
我们将它设为t,告诉replace-match从前往后匹配new-string。如果设为nil,replace-match将会尝试进行智能匹配。
+ literal
我们使用t来表示“按照字面理解new-string”。如果使用nil,那么replace-match将会使用一些特殊的语法规则理解new-string(可以使用describe-function replace-match来具体查看)。
+ base-string
我们使用nil来表示“更改当前buffer”。如果使用一个字符串,那么replace-match将会在那个字符串里执行替换。
+ subexpression
我们使用1表示“替换子匹配项1,而不是整个匹配的字符串”(这将包括前缀和后缀)。
所以在使用re-search-forward查找记录戳然后找到分隔符之间的“子匹配项”之后,我们调用replace-match将分隔符之间的文本删掉并且插入了一个根据writestamp-format生成的新字符串。
作为对于update-writestamps的最终改进,我们可以看到如果我们这样写
#+BEGIN_SRC emacs-lisp
(while (re-search-forward (concat ...) ...)
(replace-match ...))
#+END_SRC
那么concat函数在每次循环里都会调用,即使参数没有改变,每次都会生成一个新的字符串。这样效率很低。更好的方式是在循环之前计算出我们需要的字符串,然后存储在一个临时变量里。因此,最好的update-writestamps是这样的:
#+BEGIN_SRC emacs-lisp
(defun update-writestamps ()
"Find writestamps and replace them with the current time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(let ((regexp (concat "^"
(regexp-quote writestamp-prefix)
"\\(.*\\) "
(regexp-quote writestamp-suffix)
"$")))
(while (re-search-forward regexp nil t)
(replace-match (format-time-string writestamp-format
(current-time))
t t nil 1))))))
nil)
#+END_SRC
* 修改戳
好的,时间戳(timestamps)挺有用,而记录戳(writestamps)也不错,但是修改戳(modifystamps)可能更有用。一个修改戳是一个记录着文件最后修改时间的记录戳,这可能和文件最后存储到磁盘上的时间不一样。例如,如果你访问了一个文件并且在没做任何修改的情况下将其保存在磁盘上,你就不应该更新修改戳。
在本节,我们将大略的探索两种非常简单的方式来实现修改戳。
** 简单的方式#1
Emacs有一个称为first-change-hook的钩子变量。每当buffer自保存之后第一次被修改,变量中的函数将会被调用。使用这个钩子来实现修改戳只是把我们之前的update-writestamps函数从local-write-file-hooks变为first-change-hook。当然,我们还要把它的名字改为update-modifystamps,并且引入一些新的变量--modifystamp-format,modifystamp-prefix,以及modifystamp-suffix--而不影响原来记录戳的那些变量。update-modifystamps需要使用这些新的变量。
在此之前,first-change-hook是一个全局变量,而我们需要一个buffer局部的。如果我们将update-modifystamps添加到first-change-hook而first-change-hook是全局的,那么任何buffer保存的时候都会触发这个方法。我们需要将它变为buffer局部的,而其他buffer则继续使用默认的全局变量。
#+BEGIN_SRC emacs-lisp
(make-local-hook 'first-change-hook)
#+END_SRC
虽然可以使用make-localvariable或者make-variable-buffer-local来使普通变量变为buffer局部的(下面会看到),但是钩子变量必须使用make-local-hook。
#+BEGIN_SRC emacs-lisp
(defvar modifystamp-format "%C"
"*Format for modifystamps (c.f. 'format-time-string').")
(defvar modifystamp-prefix "MODIFYSTAMP (("
"*String identifying start of modifystamp.")
(defvar modifystamp-suffix "))"
"*String that terminates a modifystamp.")
(defun update-modifystamps ()
"Find modifystamps and replace them with the current time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(let ((regexp (concat "^"
(regexp-quote modifystamp-prefix)
" \\(.*\\) "
(regexp-quote modifystamp-suffix)
"$")))
(while (re-search-forward regexp nil t)
(replace-match (format-time-string modifystamp-format
(current-time))
t t nil 1))))))
nil)
(add-hook 'first-change-hook 'update-modifystamps nil t)
#+END_SRC
add-hook中的nil参数只是一个占位符。我们只关注最后一个参数t,它表示“只更改first-changehook的buffer局部备份”。
这种方式的问题是如果你在保存文件前对其进行了十处修改,那么修改戳会记录第一次的时间,而不是最后一次的时间。某些情况下这已经足够用了,但是我们还可以做得更好。
** 简单的方式#2
这一次我们再次使用local-write-file-hooks,但是我们只在buffer-modified-p返回true的时候才调用update-modifystamps,也就是说只在当前buffer被改动的情况下才调用它:
#+BEGIN_SRC emacs-lisp
(defun maybe-update-modifystamps ()
"Call 'update-modifystamps' if the buffer has been modified."
(if (buffer-modified-p)
(update-modifystamps)))
(add-hook 'local-write-file-hooks 'maybe-update-modifystamps)
#+END_SRC
现在我们有了跟方式#1相反的问题:最后修改的时间一直到文件保存的时候才会计算,这可能比最后一次修改的时间晚很久。如果你在2:00的时候修改了文件,而在3:00的时候做了保存,那么修改戳将会把3:00作为最后保存的时间。这更接近了,但是并不完美。
** 聪明的方式
理论上,我们可以在每次更改buffer之后调用update-modifystamps,但是实际中在每次按键之后都搜索整个文件并且对其进行修改是代价昂贵的一件事。但是每次buffer更改之后将时间记录下来就不那么难以接受。然后,当buffer保存到文件时,记录的时间就可以用来计算修改戳中的时间了。
钩子变量after-change-functions包含着每次buffer更改时要调用的函数。首先我们让它变为buffer-local的:
#+BEGIN_SRC emacs-lisp
(make-local-hook 'after-change-functions)
#+END_SRC
然后我们定义一个buffer-local的变量来保存这个buffer最后一次修改的时间:
#+BEGIN_SRC emacs-lisp
(defvar last-change-time nil
"Time of last buffer modification.")
(make-variable-buffer-local 'last-change-time)
#+END_SRC
函数make-variable-buffer-local使得它后面的变量在每个buffer都具有独立的、buffer-local的值。这根make-local-variable有些不同,其作用是使变量在当前buffer获得一个buffer-local的值,而让其他buffer共享一个相同的全局值。在这里,我们使用make-variable-buffer-local是因为所有buffer共享一个全局的last-change-time是没有意义的。
现在我们需要一个函数来在每次buffer改变的时候修改last-change-time的值。让我们将其命名为remember-change-time并且将它添加到after-change-functions里:
#+BEGIN_SRC emacs-lisp
(add-hook 'after-change-functions 'remember-change-time nil t)
#+END_SRC
after-change-functions中的函数有三个参数来描述刚刚发生的改变(参照[[file:7.org][第7章]]中的[[file:7.org::*Mode Meat][Mode Meat]])。但是remember-change-time并不关心刚才发生了什么更改;它只关心发生了更改这件事本身。所以我们可以选择忽略这些参数。
#+BEGIN_SRC emacs-lisp
(defun remember-change-time (&rest unused)
"Store the current time in 'last-change-time'."
(setq last-change-time (current-time)))
#+END_SRC
关键字&rest,后面跟着一个参数名称,只能出现在函数的参数列表最后。它表示“将剩下的参数收集到一个列表里并且赋给最后的参数”(本例中为unused)。函数可能还有其他的参数,包括&optional的可选参数,但是这些都要出现在&rest的前面。在所有其他参数按照正常格式分配完成后,&rest将其他剩下的参数放到一个列表里。所以如果一个函数这么定义
#+BEGIN_SRC emacs-lisp
(defun foo (a b &rest c)
...)
#+END_SRC
那么当(foo 1 2 3 4)调用时,a将为1,b为2,c将会是列表(3 4)。
在有些情况下,&rest非常有用,甚至是必须的;但在这里我们只是出于懒惰(或者节约,如果你希望这么称呼的话),来规避给三个我们并不希望使用的参数命名。
现在我们来修改update-modifystamps:它必须使用储存在last-change-time中的时间而不是使用(current-time)。从效率考虑,它还需要在执行完成后将last-change-time置为nil,这样可以避免以后当文件在未修改的情况下进行保存时对update-modifystamps的额外调用。
#+BEGIN_SRC emacs-lisp
(defun update-modifystamps ()
"Find modifystamps and replace them with the saved time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(let ((regexp (concat "^"
(regexp-quote modifystamp-prefix)
"\\(.*\\)"
(regexp-quote modifystamp-suffix)
"$")))
(while (re-search-forward regexp nil t)
(replace-match (format-time-string modifystamp-format
last-change-time)
t t nil 1))))))
(setq last-change-time nil)
nil)
#+END_SRC
最后,我们不希望在last-change-time为nil时调用update-modifystamps:
#+BEGIN_SRC emacs-lisp
(defun maybe-update-modifystamps ()
"Call 'update-modifystamps' if the buffer has been modified."
(if last-change-time ; 替换对于(buffer-modified-p)的检测
(update-modifystamps)))
#+END_SRC
maybe-update-modifystamps中仍然存在很大的问题。在阅读下一部分前,你能找出那是什么吗?
** 一个小Bug
缺陷是每次update-modifystamps重写修改戳时,会引起buffer的改变,这又会造成last-change-time的改变!这样只有第一次修改戳会被正确的修改。后续的修改戳会是一个与文件储存的时间相近的时间而不是最后一次修改的时间。
一个绕过这个问题的方法是当执行update-modifystamps时暂时将after-change-functions置为nil:
#+BEGIN_SRC emacs-lisp
(add-hook 'local-write-file-hooks
'(lambda ()
(if last-change-time
(let ((after-change-functions nil))
(update-modifystamps)))))
#+END_SRC
let创建了一个临时变量after-change-functions,用来在调用let体中的update-modifystamps时替代全局变量after-change-functions。当let退出后,临时变量after-change-functions就销毁了,而全局变量又再次发生作用。
这个方法有一个缺点:如果after-change-functions中有其他的函数,那么在你调用update-modifystamps时它们也会暂时失效,而这并不是你希望看到的。
一个更好的方法是在每次更新修改戳之前“截取”last-change-time的值。这样,当更新修改戳造成last-change-time改变时,新的last-change-time的值将不会影响其他的修改戳,因为update-modifystamps并不会使用当前储存在last-change-time中的值。
“截取”last-change-time最简单的方式是将其作为参数传递给update-modifystamps:
#+BEGIN_SRC emacs-lisp
(add-hook 'local-write-file-hooks
'(lambda ()
(if last-change-time
(update-modifystamps last-change-time))))
#+END_SRC
这需要修改update-modifystamps,使其具有一个参数,并且在调用format-time-string时使用它:
#+BEGIN_SRC emacs-lisp
(defun update-modifystamps (time)
"Find modifystamps and replace them with the given time."
(save-excursion
(save-restriction
(save-match-data
(widen)
(goto-char (point-min))
(let ((regexp (concat "^"
(regexp-quote modifystamp-prefix)
"\\(.*\\)"
(regexp-quote modifystamp-suffix)
"$")))
(while (re-search-forward regexp nil t)
(replace-match (format-time-string modifystamp-format
time)
t t nil 1))))))
(setq last-change-time nil)
nil)
#+END_SRC
你可能会觉得为了使修改戳工作,你写出了许多表达式,建立了很多变量,而这看起来很难维护。你是对的。所以在下一章,我们来看看如何在Lisp文件中封装相关的方法和变量。
<<4-18>>[18]. 如何找到它呢?当然是通过M-x apropos RET time RET。
<<4-19>>[19]. 在Emacs 20.1中,在本身编写时还没发布,将会引入一个全新的用于编辑用户选项的称为“customize”的系统。将用户选项加入到“customize”中需要使用特殊的函数defgroup和defcustom。
<<4-20>>[20]. 插入记录戳与插入日期或者时间很类似。编写这么一个函数就留给读者作为练习了。
<<4-21>>[21]. *在正则表达式中被计算机科学家称为“Kleene Closure”。
================================================
FILE: 5.org
================================================
#+TITLE: Lisp文件
#+SETUPFILE: ./resource/template.org
* 在本章:
*创建Lisp文件*
*加载文件*
*编译文件*
*eval-after-load*
*局部变量列表*
*补充:安全考虑*
到目前为止,我们编写的大多数Emacs Lisp都可以放进你的.emacs文件里。一种替代的方案是将Emacs Lisp的代码根据功能放进不同的文件里。这需要更多的努力,但是这同样也比把代码直接放到.emacs里多了一些好处:
+ .emacs中的代码在Emacs启动时总是会执行,即使我们在当前的工作中可能根本不需要它。这会使启动时间延长并且会消耗内存。相反的,分离的Lisp文件只有在我们需要的时候才去载入它。
+ .emacs中的代码通常并不会进行字节编译(byte-compiled)。字节编译会将Emacs Lisp转换成载入更高效、执行更快并且使用更少内存的格式(就像其他语言的程序一样,这会将代码变成不适于程序员阅读的格式)。字节编译过的Lisp文件通常以.elc(“Emacs Lisp, compiled”)作为后缀名,而未进行编译的文件通常以.el(“Emacs Lisp”)后缀。
+ 将所有代码放到.emacs里将会使文件膨胀成难以管理的乱麻。
前面章节所编写的代码就是这种可以根据功能划分到不同Lisp文件中的很好的例子,它们只应该在需要的时候载入,并且应该进行字节编译以提高执行效率。
* 创建Lisp文件
Emacs Lisp文件名通常以.el为后缀,所以作为开始,让我们创建timestamp.el并且将上一章中最后完成的代码放进去,如下所示。
#+BEGIN_SRC emacs-lisp
(defvar insert-time-format ...)
(defvar insert-date-format ...)
(defun insert-time () ...)
(defun insert-date () ...)
(defvar writestamp-format ...)
(defvar writestamp-prefix ...)
(defvar writestamp-suffix ...)
(defun update-writestamps () ...)
(defvar last-change-time ...)
(make-variable-buffer-local 'last-change-time)
(defun remember-change-time ...)
(defvar modifystamp-format ...)
(defvar modifystamp-prefix ...)
(defvar modifystamp-suffix ...)
(defun maybe-update-modifystamps () ...)
(defun update-modifystamps (time) ...)
#+END_SRC
先不要加入add-hook和make-local-hook。我们后面再来关注他们。现在,你需要注意的是当编写Lisp文件的时候,它应该能在任意时机被加载,甚至加载多次,而并不会带来你不希望的副作用。一个这种副作用的例子是,假如你不希望当前buffer的after-change-functions变为局部变量,而又把(make-local-hook 'after-change-functions)加入了到timestamp.el中。
* 加载文件
当把代码写入到timestamp.el中后,我们必须进行配置,以使我们在需要的时候能够正确的访问到它们。这是通过加载(loading)完成的,也就是使Emacs读取和执行它的内容。在Emacs中有许多方式来加载Lisp文件:交互式的(interactively)、非交互式的(non-interactively)、明确的(explicitly)、模糊的(implicitly)、以及使用或者不使用路径搜索(pathsearching)的。
** 找到Lisp文件
Emacs能够根据像/usr/local/share/emacs/site-lisp/foo.el这样的绝对路径来加载文件,但是通常更为方便的方式是直接使用如bo.el这样的文件名,而让Emacs在加载路径(load path)中去找到它。加载路径简单来说就是Emacs用来搜索要加载的文件的目录列表,跟UNIX shell中使用环境变量PATH来找到要执行的程序相似。Emacs的加载路径储存在一个字符串列表变量load-path里。
当Emacs启动的时候,load-path的初始设置大概看起来跟这个差不多:
#+BEGIN_SRC emacs-lisp
("/usr/local/share/emacs/19.34/site-lisp"
"/usr/local/share/emacs/site-lisp"
"/usr/local/share/emacs/19.34/lisp")
#+END_SRC
搜索路径时的顺序与其在列表中出现的顺序相同。要在load-path的头部加入一个路径,在你的.emacs文件中加入下面的代码:
#+BEGIN_SRC emacs-lisp
(setq load-path
(cons "/your/directory/here"
load-path))
#+END_SRC
要在其末尾加入,则使用:
#+BEGIN_SRC emacs-lisp
(setq load-path
(append load-path
'("/your/directory/here")))
#+END_SRC
注意到在第一个例子中,“/your/directory/here”只作为一个普通字符串,而在第二个例子中,它却出现在一个括起来的列表中。[[file:6.org][第六章]]将会解释Lisp中各种处理列表的方式。
如果你要求Emacs在加载路径中查找一个Lisp文件并且忽略掉后缀名的话--例如你使用foo而不是foo.el--Emacs会首先查找fool.elc,也就是foo.el的字节编译格式。如果找不到,那么它将会尝试foo.el,最后是foo。通常加载文件时最好忽略掉后缀名。因为这样不仅会使你得到更有效的查找行为,而且这还会让eval-after-load工作的更好(阅读本章后面的[[eval-after-load][eval-after-load]]章节获得更多信息)。
** 交互式加载
有两个Emacs命令用来交互式的加载Lisp文件:load-file和load-library。当你输入M-x load-file RET时,Emacs会提示你输入一个Lisp文件的绝对路径(例如/home/bobg/emacs/foo.el)而不去搜索load-path。这会使用通常的文件名提示方法,所以文件名会进行自动补全。而另一种方式,当你输入M-x load-library RET时,Emacs将会提示你输入库的基本名称(例如foo)并且尝试在load-path中找到它。这不会使用文件名提示方法,因此也就不会进行自动补全。
** 以代码加载
当在Lisp代码中加载文件时,你可以选择显式加载、条件加载或者自动加载。
*** 显式加载
可以显式的调用load(和交互式加载中的load-library行为类似)或者load-file加载文件。
#+BEGIN_SRC emacs-lisp
(load "lazy-lock")
#+END_SRC
会搜索load-path来查找lazy-lock.elc、lazy-lock.el或者lazy-lock。
#+BEGIN_SRC emacs-lisp
(load-file "/home/bobg/emacs/lazy-lock.elc")
#+END_SRC
将不会使用load-path。
显式加载应该在你确定需要马上加载某个文件时才使用,并且你应该确信文件并没有加载过或者你并不关心。事实上,如果使用下面的替代方式,你基本上不会用到显式加载这种方式。
*** 条件加载
当n处不同的Lisp代码想要载入同一个文件时,有两个Emacs Lisp函数,即require和provide,给出了一种方法来确保文件只会被加载一次而不是n次。
一个Lisp文件通常包含着一系列相关的函数。我们可以认为这些函数是一个单独的特性(feature)。加载这个文件会使得其包含的特性可用。
Emacs明确了特性这个概念。特性通过Lisp符号进行命名,使用provide进行声明,使用require进行请求。
它是这么工作的。首先,我们为timestamp.el提供的特性来选择一个代表的符号。让我们使用一个明显的,timestamp。我们通过在timestamp.el中写入
#+BEGIN_SRC emacs-lisp
(provide 'timestamp)
#+END_SRC
来表明timestamp.el提供了特性timestamp。通常它出现在文件的末尾,这样只有当前面所有语句都正确执行后,这个特性才会被标识为“provided”。(如果发生了什么异常,那么文件的加载将会在调用provide之前终止)。
现在假设在某处的代码需要使用时间戳功能。使用require:
#+BEGIN_SRC emacs-lisp
(require 'timestamp "timestamp")
#+END_SRC
这表示,“如果timestamp特性还不可用,那么加载timestamp”(这会使用load,并且会搜索load-path)。如果timestamp特性已经提供了(也就是timestamp之前已经加载了),那么什么都不做。
通常,对于require的调用通常都出现在Lisp文件的头部--就像C程序中通常以一大堆#includes开头一样。但是有些程序员喜欢在需要某个特性时才在那里调用require。这可能有很多个地方,而如果每次都会真的去加载文件的话,程序将会慢的像爬,每载入一个文件都会耗费大量的时间。使用“特性”将会使得文件只载入一次,大量的节省时间!
调用require时,如果文件名是特性名的“字符串等价式”,那么文件名可以被省略而根据特性名推断出来。符号的“字符串等价式”就是简单的符号名称的字符串格式。特性符号timestamp的字符串等价式为“timestamp”,所以我们可以这么写:
#+BEGIN_SRC emacs-lisp
(require 'timestamp)
#+END_SRC
来替换(require 'timestamp "timestamp")。(函数symbol-name可以得到符号的字符串等价式)。
如果require使得相关的文件被加载(这时其特性还未被提供),那么那个文件应该provide请求的特性。否则,require将会报告加载的文件并没有提供需要的特性。
*** 自动加载
当使用自动加载(autoloading)时,你可以推迟一个文件的加载直到它必须加载的时候--也就是直到你调用了它里面的方法。设置自动加载代价很小,因此通常都是在.emacs文件里做。
函数autoload将一个函数名称与一个定义它的文件联系在一起。当Emacs尝试调用一个还未被定义的函数时,它将根据autoload来载入指定的文件,并且假定它做出了定义。而不使用autoload,尝试执行一个未定义的函数将会报错。
下面是如何使用:
#+BEGIN_SRC emacs-lisp
(autoload 'insert-time "timestamp")
(autoload 'insert-date "timestamp")
(autoload 'update-writestamps "timestamp")
(autoload 'update-modifystamps "timestamp")
#+END_SRC
当第一次调用这四个函数insert-time,insert-date,updatewritestamps,或者update-modifystamps中的任意一个时,Emacs都将加载timestamp。这不仅会载入被调用的函数定义,并且会使另外的三个也被载入,所以后续的对于这几个函数的调用不会重新载入timestamp。
autoload函数有多个可选参数。第一个参数是文档字符串。文档字符串允许用户甚至在还未从文件中加载函数的定义之前就得到帮助(通过describe-function和apropos)。
#+BEGIN_SRC emacs-lisp
(autoload 'insert-time "timestamp"
"Insert the current time according to insert-time-format.")
(autoload 'insert-date "timestamp"
"Insert the current date according to insert-date-format.")
(autoload 'update-writestamps "timestamp"
"Find writestamps and replace them with the current time.")
(autoload 'update-modifystamps "timestamp"
"Find modifystamps and replace them with the given time.")
#+END_SRC
下一个可选参数定义了当函数被加载后是一个交互式命令还是一个普通函数。如果忽略或者为nil,函数将被认为是非交互的;否则它就是一个命令。函数被加载之前,像command-apropos这样的函数也可以使用这个信息来区分函数是交互式还是非交互式的。
#+BEGIN_SRC emacs-lisp
(autoload 'insert-time "timestamp"
"Insert the current time according to insert-time-format."
t)
(autoload 'insert-date "timestamp"
"Insert the current date according to insert-date-format."
t)
(autoload 'update-writestamps "timestamp"
"Find writestamps and replace them with the current time."
nil)
(autoload 'update-modifystamps "timestamp"
"Find modifystamps and replace them with the given time."
nil)
#+END_SRC
如果你在autoload中错误的将交互式函数标记为非交互的,或者相反,当函数被载入之后就不要紧了。真正的函数定义会将所有autoload所给出的信息替换掉。
最后一个可选参数我们这里不会讲。如果自动加载对象的类型不是函数的话,它会指定类型。就像它所指出的,键映射表和宏(我们后面的章节将会讲到)也可以被自动加载。
* 编译文件
就像在本章开始所提到的,当我们将Lisp代码储存在各自的文件里之后,我们可以对它进行字节编译(byte-compile)。字节编译将Emacs Lisp转换成更紧凑、执行更快的格式。就像在其他编程语言中的编译一样,程序员很难阅读字节编译的结果。但不像其他的编译,字节编译的结果在不同硬件平台和操作系统上是可移植的(但可能不兼容老版本的Emacs)。
字节编译过的Lisp代码比未编译的代码执行速度快很多。
字节编译的Lisp文件后缀名为.elc文件。就像前面提到的,不使用后缀名调用load和load-library将会优先加载.elc文件而不是.el文件。
有几种字节编译文件的方式。最直接的方式是
+ 在Emacs里:执行M-x byte-compile-file RET file.el RET。
+ 在UNIX shell里:执行emacs -batch -f batch-byte-compile file.el。
你可以对Lisp文件的路径执行byte-recompile-directory来进行字节编译。
当Emacs要载入的.elc文件的日期比对应的.el文件的日期旧时,Emacs将仍然会载入它,但是会提示一个警告。
* eval-after-load
如果你希望直到某个特定文件加载之后才执行某些代码,eval-after-load就是你需要的。例如,假如你搞出了一个比dired(Emacs的目录编辑模块)自身的dired-sort-toggle更好的函数定义。你不能简单的把它放入.emacs中,因为一旦你编辑一个目录,dired将会被自动加载,而这将会把你自己的定义替换掉。
你应该做的是:
#+BEGIN_SRC emacs-lisp
(eval-after-load
"dired"
'(defun dired-sort-toggle ()
...))
#+END_SRC
这将会在dired加载之后马上执行defun,将dired自己的dired-sort-toggle替换为其他的版本。但是要注意,这只有当使用dired这个名称加载时才会工作。如果使用名称dired.elc或者/usr/local/share/emacs/19.34/lisp/dired加载dired的话就不会执行。load或者autoload或者require必须使用同eval-after-load中一模一样的名称才可以。这也就是前面提到的为什么最好只用文件的基本名称加载文件的原因。
eval-after-load的另一个作用是当你希望在一个包中使用某个变量、函数或者按键映射,而你又不想强制这个包加载的时候:
#+BEGIN_SRC emacs-lisp
(eval-after-load
"font-lock"
'(setq lisp-font-lock-keywords lisp-font-lock-keywords-2))
#+END_SRC
这里使用了font-lock定义的变量lisp-font-lock-keywords-2。如果你在fontlock加载之前使用lisp-font-lock-keywords-2,你将会得到一个“Symbol's value as variable is void”错误。但是不要急着加载font-lock,因为这个setq只是为了将lisp-font-lock-keywords-2设置给font-lock的另一个变量lisp-font-lock-keywords,而这只有当font-lock由于什么原因加载的时候才会用到。所以我们使用eval-after-load来保证setq不会发生的太早而引起错误。
如果你调用eval-after-load而文件已经被加载会发生什么呢?那么后面的Lisp表达式会马上执行。如果同一个文件有多个eval-after-load会发生什么呢?它们会在文件加载时一个接一个的全部执行。
你可能发现eval-after-load的工作方式和钩子变量很相似。这是对的,但是一个重要的区别是钩子只执行Lisp函数(通常为lambda表达式的形式),而eval-after-load可以执行任何Lisp表达式。
* 局部变量列表
本章前面的内容已经足够创建并且根据需要加载Lisp代码文件了。但是对于timestamp的例子,事情还是有些不同。当调用update-writestamps时会自动载入timestamp,但是谁来调用update-writestamps并且加载timestamp呢?回想一下前一章中update-writestamps是由local-write-file-hooks调用的。那么如何将update-writestamps放进local-write-file-hooks里呢?而因为前面的章节[[创建Lisp文件]]中所提到的副作用,我们一定不能在加载文件时这么做。
我们需要一种方法将update-writestmpas加入到需要它的buffer的局部变量local-write-file-hooks里,这样当local-write-file-hooks第一次触发时就会自动加载timestamp。
一种很好的完成这个需求的手段是使用文件尾部的局部变量列表(local variables list)。当Emacs访问一个新文件的时候,它会扫描文件的尾部是否有这样的文本块[[[5-22][22]]]:
#+BEGIN_SRC emacs-lisp
Local variables:
var1: value1
var2: value2
...
End:
#+END_SRC
当Emacs找到这样一个块的时候,它会将每个value赋给对应的var,并且使其变为buffer的局部变量。Emacs甚至能解析出以某个前缀开头的这种块,只要它们的前缀相同。而在Lisp代码文件中必须将其作为注释,这样它们就不会被当做是Lisp代码执行:
#+BEGIN_SRC emacs-lisp
; Local variables:
; var1: value1
; var2: value2
; ...
; End:
#+END_SRC
values被当做引用来对待;它们在赋给对应的vars之前不会被计算。所以在包含下面这个块
#+BEGIN_SRC emacs-lisp
; Local variables:
; foo: (+ 3 5)
; End:
#+END_SRC
的文件中buffer局部变量foo的值为(+ 3 5),而不是8。
因此任何需要将update-writestamps加入到local-write-file-hooks中的文件都可以这样指定:
#+BEGIN_SRC emacs-lisp
Local variables:
local-write-file-hooks: (update-writestamps)
End:
#+END_SRC
实际上,文件可以根据需要建立起自己所有的变量:
#+BEGIN_SRC emacs-lisp
Local variables:
local-write-file-hooks: (update-writestamps)
writestamp-prefix: "Written:"
writestamp-suffix: "."
writestamp-format: "%D"
End:
#+END_SRC
使用这种方式设置local-write-file-hooks的一个问题是它会将local-write-file-hooks替换为上面例子中所示的一个新的列表,而不是一种更好的方式--保留原来local-write-file-hooks里的其他值并向其中添加update-writestamps。虽然这样做需要执行Lisp代码。即,你需要执行下面的表达式:
#+BEGIN_SRC emacs-lisp
(add-hook 'local-write-file-hooks 'update-writestamps)
#+END_SRC
Emacs在局部变量列表中引入一个“伪变量(pseudovariable)”eval来完成这个功能。
当
#+BEGIN_SRC emacs-lisp
eval: value
#+END_SRC
出现在局部变量列表中时,value将被计算。计算的结果将被忽略;它将不会存储到局部变量eval中。因此完整的解决方案是将
#+BEGIN_SRC emacs-lisp
eval: (add-hook 'local-write-file-hooks 'update-writestamps)
#+END_SRC
添加到局部变量里。
实际上,设置local-write-file-hooks的正确做法应该是编写一个子模式(minor mode),这将是[[file:7.org][第7章]]的主题。
* 补充:安全考虑
局部变量列表是一个潜在的安全漏洞,会导致用户受到“特洛伊木马”类型的攻击。例如一个变量的设置使得Emacs的工作不正常;或者一个eval有一些不可预知的副作用,例如删除文件或者使用你的名字伪造邮件。而攻击者只要引诱你访问一个在局部变量列表中包含这些设置的文件就可以了。只要你访问了这个文件,这些代码就会被执行。
保护你自己的方式是将
#+BEGIN_SRC emacs-lisp
(setq enable-local-variables 'query)
#+END_SRC
加入到你的.emacs文件里。这将会使Emacs在执行任何局部变量列表时都会询问你。也可以使用enable-local-eval来控制伪变量eval的执行。
<<5-22>>[22]. “文件的尾部”的意思是:文件的最后3000个字节--是的,这很随意--直到最后一行以CONTROL-L开头的行,如果存在的话。
================================================
FILE: 6.org
================================================
#+TITLE: 列表
#+SETUPFILE: ./resource/template.org
* 在本章:
*列表初探*
*列表细节*
*递归列表函数*
*迭代列表函数*
*其他有用的列表函数*
*破坏性列表操作*
*循环列表?!*
目前为止,我们已经看到了一些列表(list)的使用,但是我们并没有真的去探索它们如何工作以及为什么它们如此有用。既然列表作为Lisp的核心内容,本章我们就来全面的观察一下这个数据结构。
* 列表初探
就像我们已经看到的,Lisp中的列表就是一个括号包裹起来的0个或多个Lisp表达式的序列。列表可以嵌套;也就是说括号里的子表达式还可以包含一个或多个列表。下面是一些例子:
#+BEGIN_SRC emacs-lisp
(a b c) ; 三个符号组成的列表
(7 "foo") ; 一个数字和字符串组成的列表
((4.12 31178)) ; 列表只有一个元素:一个两个数字组成的自列表
#+END_SRC
空列表()等价于符号nil。
函数car和cdr[[[6-23][23]]]用来访问列表的一部分:car得到列表的第一个元素,cdr得到列表剩下的元素(除第一个元素之外)。
#+BEGIN_SRC emacs-lisp
(car'(abc)) -> a
(cdr'(abc)) -> (b c)
(car (cdr '(a b c))) -> b
#+END_SRC
(回忆一下引用(quote)表达式--可能是一个完整的列表--表示按照字面来解释表达式。所以'(a b c)表示列表包含a、b、和c,而不是用b和c作为参数调用a)
只包含一个元素的列表的cdr为nil:
#+BEGIN_SRC emacs-lisp
(cdr '(x)) -> nil
#+END_SRC
空列表的car和cdr都为nil:
#+BEGIN_SRC emacs-lisp
(car '()) -> nil
(cdr '()) -> nil
#+END_SRC
注意对于只包含nil的列表也是如此:
#+BEGIN_SRC emacs-lisp
(car '(nil)) -> nil
(cdr '(nil)) -> nil
#+END_SRC
但这并不表明()等价于(nil)。
对于这些你并不需要完全只听信我的言语。你只需要像[[file:1.org][第1章]]中[[file:1.org::*执行Lisp表达式][执行Lisp表达式]]章节中所描述的那样,到*scratch* buffer里自己尝试执行这些语句。
列表通过函数list,cons以及append创建。函数list可以使用任意数量的参数构建列表:
#+BEGIN_SRC emacs-lisp
(list 'a "b" 7) -> (a "b" 7)
(list '(x y z) 3) -> ((x y z) 3)
#+END_SRC
函数cons使用一个Lisp表达式和一个列表作为参数。它通过将表达式添加到列表的前面构成新列表:
#+BEGIN_SRC emacs-lisp
(cons 'a '(3 4 5)) -> (a 3 4 5)
(cons "hello" '()) -> ("hello")
(cons '(a b) '(c d)) -> ((a b) c d)
#+END_SRC
注意对列表使用cons构建新列表并不会影响之前的列表:
#+BEGIN_SRC emacs-lisp
(setq x '(a b c)) ;将(a b c)赋值给变量x
(setq y (cons 17 x)) ;cons 17给它并且赋值给y
y -> (17 a b c) ;正常工作
x -> (a b c) ;并不会改变x
#+END_SRC
函数append使用任意数量的列表作为参数,并将其顶层元素连接成一个新列表。这会高效的去掉每个列表的外层括号,把剩下的元素放到一起,然后使用一对新的括号把它们括起来:
#+BEGIN_SRC emacs-lisp
(append '(a b) '(c d)) -> (a b c d)
(append '(a (b c) d) '(e (f))) -> (a (b c) d e (f))
#+END_SRC
函数reverse使用一个列表作为参数,将其顶层元素反转成为一个新的列表。
#+BEGIN_SRC emacs-lisp
(reverse '(a b c)) -> (c b a)
(reverse '(1 2 (3 4) 5 6)) -> (6 5 (3 4) 2 1)
#+END_SRC
注意reverse并不会反转子列表中的元素。
* 列表细节
这一章节将解释Lisp中列表的内部工作机制。既然大部分Lisp程序都会不同程度的使用列表,理解它们的工作机制是很有益处的。这会让你理解列表擅长做什么、不擅长什么。
列表由更小的称为cons cells的数据结构构成。cons cell由两个Lisp表达式构成,你可能并不惊讶如何访问它们--使用car和cdr。
函数cons使用它的两个参数创建一个新的cons cell。不像前一小节中所讲的,cons的两个参数可以是任意Lisp表达式。第二个参数不能是一个已存在的列表。
#+BEGIN_SRC emacs-lisp
(cons 'a 'b) -> 一个由car a和cdr b组成的cons cell
(car (cons 'a 'b)) -> a
(cdr (cons 'a 'b)) -> b
#+END_SRC
生成的cons cell如图6-1所示。
[[file:resource/6-1.png]]
图6-1 (cons 'a 'b)的结果
当你将一些其他元素与一个列表执行cons时,例如
#+BEGIN_SRC emacs-lisp
(cons 'a '(b c))
#+END_SRC
结果是(a b c),也就是一个car为a,cdr为(b c)的cons cell。后面会更详细的讲述它。
对于cdr不是列表的cons cell有一种特殊的语法。它被称为dotted pair notation,而cons cells有时也被称为dotted pairs:
#+BEGIN_SRC emacs-lisp
(cons a b) -> (a . b)
(cons '(1 2) 3) -> ((1 2) . 3)
#+END_SRC
当如图6-2所示的那样,一个cons cell的cdr为nil时,可以省略掉点号和nil。
[[file:resource/6-2.png]]
图6-2 一个单元素list(a)
另一个省略的规则是当cons cell的cdr是另一个cons cell时,那么点号以及包裹cdr的括号都可以省略。见图6-3。
[[file:resource/6-3.png]]
图6-3 一个cons cell指向另一个
当把这条规则和前一条忽略cdr为nil的规则组合起来的时候,我们就会发现下面的列表我们已经很熟悉了:
#+BEGIN_SRC emacs-lisp
(a . (b . nil)) ≡ (a b . nil) ≡ (a b)
#+END_SRC
通常来说,Lisp的列表是一个由cons cells组成的链表,每个cell的cdr是另一个cell,最后一个cell的cdr为空。cons cells的car是什么并不重要。图6-4展示了一个列表作为另一个列表的一部分存在。
[[file:resource/6-4.png]]
图6-4 一个列表包含一个子列表
当你编写
#+BEGIN_SRC emacs-lisp
(setq x '(a b c))
#+END_SRC
这会将x指向这个由三个cons cell组成的链表的第一个cell。如果你编写
#+BEGIN_SRC emacs-lisp
(setq y (cdr x)) ; 现在y是(b c)
#+END_SRC
这会将y指向上面列表中的第二个cons cell。一个列表事实上只是一个指向cons cell的指针。
最后一个cdr不为nil的列表有时被称作improper list。通常association list总是improper lists。
有多个函数用来检测一个Lisp对象是列表还是组成列表的一部分。
+ consp检测它的参数是不是一个cons cell。对于(consp x),当x为除空表之外的任何列表时返回true,其他返回false。
+ atom检测它的参数是否为原子。(atom x)功能与(consp x)相反--任何不是cons cell的元素,包括nil、数字、字符串以及符号都是原子。
+ listp检测它的参数是否为列表。对于(listp x),如果x为cons cells或者nil则返回true,其他返回false。
+ null检测它的参数是否为nil。
现在你已经知道了cons cells,你可能会觉得(car nil)和(cdr nil)都定义为nil很奇怪,因为nil甚至不是一个cnos cell,因此它也没有car和cdr。实际上,有一些Lisp方言在你对nil调用car和cdr时会报错。大多数Lisps的行为跟Emacs Lisp一样,主要是为了方便--但是这个特例会造成一些奇怪的副作用,就像上面提到的,()和(nil)在car和cdr的时候的结果是一样的。
* 递归列表函数
传统的Lisp教材使用一系列简短的编程练习来阐明列表和cons cells的行为。让我们花一点时间看一下两个广为人知的例子,然后再往下进行。
在这个练习中我们的目标是定义一个名为flatten的函数,将指定的列表的所有内部的子列表都释放出来平铺到一层上。例如:
#+BEGIN_SRC emacs-lisp
(flatten '(a ((b) c) d)) -> (a b c d)
#+END_SRC
解决方案是使用递归,将car和cdr分别平铺,然后将他们合并到一层上来。假如输入的列表为
#+BEGIN_SRC emacs-lisp
((a (b)) (c d))
#+END_SRC
它的car是(a (b)),平铺之后是(a b)。cdr是((c d)),平铺之后的结果是(c d)。函数append可以将(a b)和(c d)组合起来并且保持平铺,结果是(a b c d)。所以flatten的核心代码是:
#+BEGIN_SRC emacs-lisp
(append (flatten (car lst))
(flatten (cdr lst)))
#+END_SRC
(我将lst作为flatten的参数名称。不能使用list,因为它是一个Lisp函数的名称)现在,flatten只能对列表工作,所以对于(flatten (car lst)),如果(car lst)不是一个列表的话将会报错。我们因此需要这么改进:
#+BEGIN_SRC emacs-lisp
(if (listp (car lst))
(append (flatten (car lst))
(flatten (cdr lst))))
#+END_SRC
这个if没有“else”分支。如果(car lst)不是列表怎么办?例如,假设lst为
#+BEGIN_SRC emacs-lisp
(a ((b) c))
#+END_SRC
car不是一个列表。这时,我们只要简单的平铺cdr,(((b) c)),得到(b c);然后用cons将car组装上去。
#+BEGIN_SRC emacs-lisp
(if (listp (car lst))
(append (flatten (car lst))
(flatten (cdr lst)))
(cons (car lst)
(flatten (cdr lst))))
#+END_SRC
最后,我们需要一个方法来终止这个递归。在处理列表越来越小的分片的递归函数里,你能用来作为结束分片的最小分片是nil,而nil几乎总是作为这种函数的“默认选择”。在本例中,平铺nil的结果就是nil,所以完整的函数定义为
#+BEGIN_SRC emacs-lisp
(defun flatten (lst)
(if (null lst) ; lst是nil吗?
nil ; 是的话,返回nil
(if (listp (car lst))
(append (flatten (car lst))
(flatten (cdr lst)))
(cons (car lst)
(flatten (cdr lst))))))
#+END_SRC
试着在*scratch* buffer里用这个函数处理一些列表,并且试着通过一些例子来理清函数逻辑。记住Lisp函数的返回值是其最后执行的表达式的值。
* 迭代列表函数
递归并不总是列表相关编程问题的正确解决方案。有时朴实直接的迭代也是需要的。在本例中,我们将会展示Emacs Lisp每次处理列表中一个元素的语法风格,有时这也被称为列表的“cdr-ing down”(因为每次迭代,列表都会因取其cdr而缩短)。
假设我们需要一个用来计数列表中符号个数,并且跳过像数字、字符串和子列表等其他元素的函数。这个递归函数是错误的:
#+BEGIN_SRC emacs-lisp
(defun count-syms (lst)
(if (null lst)
0
(if (symbolp (car lst))
(+ 1 (count-syms (cdr lst)))
(count-syms (cdr lst)))))
#+END_SRC
递归--特别是深度递归--引入了非常多的额外资源来记录嵌套函数的调用和返回值,而这些应该尽量避免。而且,这个问题用迭代的方式解决显然更合理,而代码通常应该反映出解决问题的合理方式,而不是自作聪明地将解决问题地方式复杂化。
#+BEGIN_SRC emacs-lisp
(defun count-syms (lst)
(let ((result 0))
(while lst
(if (symbolp (car lst))
(setq result (+ 1 result)))
(setq lst (cdr lst)))
result))
#+END_SRC
* 其他有用的列表函数
下面是其他一些Emacs定义的列表相关函数。
+ length返回列表的长度。对于improper list它不会工作。
#+BEGIN_SRC emacs-lisp
(length nil) -> 0
(length '(x y z)) -> 3
(length '((x y z))) -> 1
(length '(a b . c)) -> error
#+END_SRC
+ nthcdr对列表调用n次cdr。
#+BEGIN_SRC emacs-lisp
(nthcdr 2 '(a b c)) -> (c)
#+END_SRC
+ nth返回列表的第n个元素(第一个元素序号为0)。这与nthcdr的car等价。
#+BEGIN_SRC emacs-lisp
(nth 2 '(a b c))-> c
(nth 1 '((a b) (c d) (e f))) -> (c d)
#+END_SRC
+ mapcar使用一个函数和一个列表作为参数。它对列表包含的每个元素都调用一次函数,即将列表里的元素作为参数传给那个函数。mapcar的返回值是一个包含对每个元素调用函数之后的列表。所以如果你有一个字符串列表而你想要让其中的字符串首字母大写的话,可以这么写:
#+BEGIN_SRC emacs-lisp
(mapcar '(lambda (x)
(capitalize x))
'("lisp" "is" "cool")) -> ("Lisp" "Is" "Cool")
#+END_SRC
+ equal检测它的两个参数是否相等。它与[[file:3.org][第3章]]中的章节[[file:3.org::*保存和取出point][保存和取出point]]中介绍的eq并不相同。不像eq判断它的参数是否为同一个对象,equal判断的是两个对象是否具有相同的结构和内容。
这个区别很重要。例如:
#+BEGIN_SRC emacs-lisp
(setq x (list 1 2 3))
(setq y (list 1 2 3))
#+END_SRC
x和y是两个不同的对象。也就是说,第一次调用list创建了一个包含三个cons cells的链表,而第二次创建了另外一个包含三个cons cells的链表。所以(eq x y)值为nil,即使两个列表实际上包含着相同的结构和内容。也因此,(equal x y)为true。
在Lisp编程中,每当你希望判断两个对象是否相等时,你都需要注意调用eq还是equal更合适。另一点需要注意的是eq是一个瞬发操作,而equal可能需要递归比较两个参数的内部结构。
注意下面的eq值为true。
#+BEGIN_SRC emacs-lisp
(setq x (list 1 2 3))
(setq y x)
(eq x y)
#+END_SRC
+ assoc会帮助你以键值的方式使用列表。当列表的形式为
#+BEGIN_SRC emacs-lisp
((key1 . value1)
(key2 . value2)
...
(keyn . valuen))
#+END_SRC
被称为association list,或者简写为assoc list[[[6-24][24]]]。函数assoc会找到列表中第一个的car为指定参数的子列表。所以:
#+BEGIN_SRC emacs-lisp
(assoc 'green
'((red . "ff0000")
(green . "00ff00")
(blue . "0000ff"))) -> (green . "00ff00")
#+END_SRC
如果没有匹配的子列表,assoc返回nil。
这个函数使用equal来检测每个键keyn是否匹配输入参数。另一个函数,assq,功能与assoc相同但是使用eq来做匹配。
有些程序员不喜欢使用dotted pairs,所以他们建立的字典看起来可能不是这样的:
#+BEGIN_SRC emacs-lisp
((red . "ff000")
(green . "00ff00")
(blue . "00ff"))
#+END_SRC
而是这样的:
#+BEGIN_SRC emacs-lisp
((red "ff0000")
(green "00ff00")
(blue "000ff"))
#+END_SRC
这没问题,因为对于assoc来说,列表中的每个元素仍然为dotted pair:
#+BEGIN_SRC emacs-lisp
((red . ("ff0000"))
(green . ("00ff00"))
(blue . ("0000ff")))
#+END_SRC
唯一的区别是在前面的例子里,assoc list中的每一项都只需要储存在一个单独的cons cell里,而现在需要两个。而在前面的列表中获取与key匹配的值时只需要这么做:
#+BEGIN_SRC emacs-lisp
(cdr (assoc 'green ...)) -> "00ff00"
#+END_SRC
而现在必须这么做:
#+BEGIN_SRC emacs-lisp
(car (cdr (assoc 'green ...))) -> "00ff00"
#+END_SRC
* 破坏性列表操作
目前为止,我们所看到的所有列表操作都是非破坏性的。例如,当你把一个对象cons到一个已存在的列表上时,结果是产生了一个全新的cons cell,它的cdr指向了原来未做改动的列表。任何其他引用之前列表的对象或变量都未受影响。同样的,append会创建一个新列表以及新cons cells来保存参数中列表的元素。它不会将x最后的cdr指向y,或者将y最后的cdr指向z,因为这样的话最后的nil指针就改变了。而这样的话就影响了x和y原来的使用。实际上append对这些列表分别创建了一个未命名的拷贝,如图6-5所示。注意z不需要拷贝;append总是直接使用最后一个参数[[[6-25][25]]]。
[[file:resource/6-5.png]]
图 6-5: append函数不会影响它的参数
下面是非破坏性的append在Lisp代码中的表示:
#+BEGIN_SRC emacs-lisp
(setq x '(a b c))
(setq y '(d e f))
(setq z '(g h i))
(append x y z) -> (a b c d e f g h i)
#+END_SRC
因为append并不会修改它的参数,所以这些变量储存的仍然是之前的值:
#+BEGIN_SRC emacs-lisp
x -> (a b c)
y -> (d e f)
z -> (g h i)
#+END_SRC
但是如果做出了破坏性的修改,那么每个变量都会指向append时制作出的长链表的一部分,如图6-6所示。执行破坏性append的函数称为nconc。
#+BEGIN_SRC emacs-lisp
(nconc x y z) -> (a b c d e f g h i)
x -> (a b c d e f g h i)
y -> (d e f g h i)
z -> (g h i)
#+END_SRC
[[file:resource/6-6.png]]
图6-6 不像append, nconc会影响它的参数
通常破坏性的修改列表并不明智。很多其他的变量和数据结构可能正在使用你修改的列表,所以最好不要修改它以致造成不可预知的影响。
另一方面,有时你确实希望破坏性的修改一个列表。可能你希望利用nconc的高效并且你确实地知道没有其他代码会因为列表的改变而受到影响。
使用破坏性操作的最常见的一个场景是改变assoc list中的值。例如,假如你有一个对应保存着人员名称和它们email的assoc list:
#+BEGIN_SRC emacs-lisp
(setq e-addrs
'(("robin" . "rl@sherwood.uk")
("marian" . "mf@sherwood.uk")
...))
#+END_SRC
现在假设有人的email地址改变了。你需要这样来更新它:
#+BEGIN_SRC emacs-lisp
(setq e-addrs (alist-replace e-addrs "john" "johnl@exile.fr"))
#+END_SRC
而alist-replace实际上是一个非常低效地递归操作,它的机制是重新拷贝整个列表:
#+BEGIN_SRC emacs-lisp
(defun alist-replace (alist key new-value)
(if (null alist)
nil
(if (and (listp (car alist))
(equal (car (car alist))
key))
(cons (cons key new-value)
(cdr alist))
(cons (car alist)
(alist-replce (cdr alist) key new-value)))))
#+END_SRC
不仅仅是低效(特别是当输入很大时),而且有可能你确实希望改变任何引用这个数据结构的对象和变量。显然,alist-replace并没有改变原数据结构。它创建了一个全新的拷贝,而任何引用老数据的对象都没有得到更新。以代码来表示这种情况就是:
#+BEGIN_SRC emacs-lisp
(setq alist '((a . b) (c . d))) ; alist 是一个 assoc list.
(setq alist-2 alist) ; alist-2 指向了同一个列表
(setq alist (alist-replace alist 'c 'q)) ; alist 是一个新列表
alist -> ((a . b) (c . q)) ;alist 响应了改动
alist-2 -> ((a . b) (c . d)) ;alist-2 仍然指向之前的列表
#+END_SRC
这里引入setcar和setcdr[[[6-26][26]]]。给出一个cons cell和一个新值,这两个函数会将cell的car或者cdr替换为新值。例如:
#+BEGIN_SRC emacs-lisp
(setq x (cons 'a 'b)) -> (a . b)
(setcar x 'c)
x -> (c . b)
(setcdr x 'd)
x -> (c . d)
#+END_SRC
我们现在可以轻松的编写alist-replace的破坏性版本了:
#+BEGIN_SRC emacs-lisp
(defun alist-replace (alist key new-value)
(let ((sublist (assoc key alist)))
(if sublist
(setcdr sublist new-value))))
#+END_SRC
这会查找alist的子列表中谁的car与key匹配--例如,("john" . "jl@nottingham.co.uk")--并且将cdr替换为new-value。而由于这会改变原数据结构--也就是说,这并没有创建任何新的拷贝--所有引用这个cons cell的的变量和其他对象,特别是包含它的assoc list,都会反映出这个改变。
还有另一个重要的破坏性列表操作:nreverse,reverse的非拷贝版本。
#+BEGIN_SRC emacs-lisp
(setq x '(a b c))
(nreverse x) -> (c b a)
x -> (a)
#+END_SRC
为什么上面的例子中最后x等于(a)呢?这是因为x仍然指向同一个cons cell,在前面的操作中已经倒转过来了。(a b c)由三个cons cells组成,car分别为a、b、c。一开始,x是通过指向链表的第一个cons cell引用列表的--它的car为a而cdr指向下一个cons cell(也就是包含b的那个cell)。但是在nreverse之后,所有cons cells的cdrs都变了。现在car为c的cons cell变为了链表的第一个元素,而它的cdr变成了包含b的cons cell。同时,x的值却没变:它仍然指向之前的cons cell,也就是car为a的cell。但是现在这个cell由于变成了链表的末尾,所以cdr却变成了nil。因此,x等价于(a)。
如果你需要x也适应列表的改变,那么你必须这么写
#+BEGIN_SRC emacs-lisp
(setq x (nreverse x)) -> (c b a)
#+END_SRC
* 循环列表?!
由于我们可以破坏性地修改我们创建的列表,我们就可以不受只用预定义元素构建列表的限制。列表可以引用自己的一部分!例如:
#+BEGIN_SRC emacs-lisp
(setq x '(a b c))
(nthcdr 2 x) -> (c)
(setcdr (nthcdr 2 x) x) ;先不要这么做!
#+END_SRC
这个例子会发生什么呢?开始我们创建了一个包含三个元素的列表并且将其赋给x。然后我们通过nthcdr找到最后一个cons cell。最后,我们将这个cell的cdr替换为x--也就是这个列表中的第一个cell。现在这个list变成环了:之前的列表的尾巴指回了头部。
这个列表长什么样呢?好吧,它的开头看起来是这样的:
#+BEGIN_SRC emacs-lisp
(a b c ab c ab c a b c a b c a b c a b c a b c . . .
#+END_SRC
而这永远不会停止。我在上面写“先不要这么做!”的原因是如果你在*scratch* buffer里执行这段代码的话,Emacs将会试着去显示结果--而这永远不可能完成。这将会进入一个死循环,虽然你可以用C-g终止这个过程。现在你可以去试试了,当然在Emacs卡死之后尽快按下C-g。你等的时间越久,*scratch* buffer中填充的a b c就越多。
显然,打印并不是环状结构能把Emacs搞得无限循环的唯一一件事。任何迭代执行这个列表里所有元素的动作都不会终止。下面是一个很好的例子:
#+BEGIN_SRC emacs-lisp
(eq x (nthcdr 3 x)) -> t ; 第三个cdr与x指向同一个对象
(equal x (nthcdr 3 x)) -> 永不停止!
#+END_SRC
既然循环列表会导致Emacs进入无限循环,那它有什么用呢?通常我们都不会想让列表变为环状,但是如果你不将其认为是列表,而是相互连接在一起的cons cells的话,你就可以构建任何种类的链表结构了,比如树和lattices。有些数据结构是自引用的,例如环。如果你曾经需要构建这类数据结构的话,你就不会被Emacs可能会为了显示它而造成无限循环这件事吓倒了。不要在需要展示结果的情况下使用它就可以了。例如,如果你将上面的setcdr改为下面这样
#+BEGIN_SRC emacs-lisp
(setqx '(a b c))
(progn
(setcdr (nthcdr 2 x) x)
nil)
#+END_SRC
那么Emacs将不会尝试展示setcdr的结果,而现在x就是一个我们可以操作的但是却不用全部展示的环状数据结构了。
#+BEGIN_SRC emacs-lisp
(nth 0 x) -> a
(nth 1 x) -> b
(nth 412 x) -> b
#+END_SRC
<<6-23>>[23]. 读作“could-er”。这些名称是最初Lisp设计时电脑架构的历史遗留。
<<6-24>>[24]. 我一直找不到统一的读法到底应该是a-SOAK,a-SOASH或者a-SOCK list。这三种我都听到过。有些人会将其读作“a-list”来避免这个问题。
<<6-25>>[25]. 因为是直接指向的,所以append的最后一个参数甚至不用是一个列表!自己试试看。
<<6-26>>[26]. 也称为rplaca和rplacd,跟car和cdr的历史原因相同。
================================================
FILE: 7.org
================================================
#+TITLE: 子模式
#+SETUPFILE: ./resource/template.org
* 在本章:
*段落填充*
*模式*
*定义子模式*
*Mode Meat*
有时我们希望扩展(extension)只影响某些特定类型的buffer而不是所有的buffer。本章我们将通过思考这个问题来提高我们在Emacs编程中的灵活性。例如,你在Lisp模式下按下C-M-a会跳转到最近的函数定义,但是你不希望在编辑文档的时候也这样。Emacs的“模式(mode)”机制使得C-M-a只会在Lisp模式下才产生效果。
关于Emacs中模式的相关主题是很复杂的。我们将以学习“子模式(minor modes)”来作为一个轻松良好的开始。在buffer中,子模式是可以与主模式共同存在的,它的作用是添加较少的一些特定功能的新行为。每个Emacs用户都对像Lisp、C以及Text这种主模式很熟悉,但是他们可能对于出现在模式栏(mode line)中的例如“Fill”这种表示自动填充的标识并不熟悉。
我们将基于Emacs自身的段落填充功能创建一个子模式。我们的子模式,Refill,将会在你编辑段落的时候进行动态填充。
* 段落填充
填充一个段落是将段落中所有行的长度变得适当的过程。每行的长度都应该大概相等并且不会越过右边距(right margin)。过长的行应该在词之间的空格处进行拆分。短行应该用后面行的文字进行填充。填充有时还包括左右对齐(justification),也就是通过在每一行添加空格来使得左右边距相等。
大多数现代文字处理软件都会保证段落的填充。每次文字修改时,段落中的文字会“流动”以完成正确的布局。一些Emacs的批评者指出Emacs在填充段落时的表现不如其他软件。Emacs虽然提供了auto-fill-mode,但是这只在当前行生效,而且只有当超过“右边距”并且插入空格时才会触发。在删除字符时并不会触发;除了当前行之外的行都不会被填充;并且在行的左边插入文字而使右边的文字超过右边距时也不会触发。
作为Emacs狂热者,对于像neplus ultra这样的编辑器的支持者们你可以给出下面的三个答复之一:
1. 像动态段落填充这种华丽的特性只能被用来掩饰这个软件其他不如Emacs的地方(你可以根据所需列出来)。
2. 你认为内容要比格式更重要,所以不需要自动的段落填充,当你觉得自己需要时,只需要简单的按下M-q来触发fill-paragraph就好了。
3. 做一点简单的Lisp hacking,Emacs就可以像别的程序那样完成动态段落填充了(然后你也可以问一下他们的编辑器是否也能模仿Emacs的这种行为)。
本章是关于选择3的。
为了确保当前段落一直正确地填充,我们需要在每次执行插入和删除后进行一次检查。这在计算上可能开销很大,所以我们希望能够对其进行开关;由于并不是所有buffer都需要这个功能,因此当它打开时,我们只希望它在当前buffer生效。
* 模式
Emacs使用模式这个概念来封装一系列编辑行为。换句话说,使用不同的模式,Emacs在buffers里的表现是不同的。举一个小例子,在Text模式中TAB键插入一个ASCII的制表符,而在Emacs Lisp模式中这将会通过插入或者删除空格来将代码缩进到正确的列上。再举一个例子,当你在Emacs Lisp模式的buffer里执行命令indent-for-comment时,你将会得到一个以Lisp注释符“;”开头的空注释。而当你在C模式的buffer里,你得到的是C语言的注释/* */。
每个buffer总是属于一个主模式(major mode)。主模式指定了buffer用于某种特定类型的编辑行为,例如Text、Lisp或者C。名为Fundamental的主模式并不为特定类型的编辑存在,你可以认为它是一种空模式。通常buffer的主模式的选择是根据你访问的文件的名称,或者buffer中的一些设置进行的。你可以通过执行模式的命令来改变主模式,例如text-mode、emacs-lisp-mode、或者c-mode。[[[7-27][27]]]当你这么做之后,buffer就使用新的主模式替换之前的模式了。
与此不同的是,子模式向buffer里添加一系列功能而并不完全改变buffer原本的编辑方式。子模式可以与主模式以及其他子模式单独打开关闭。buffer除了主模式之外还可能在0、1、2、3或者多个子模式之下。举几个子模式的例子:auto-save-mode,使buffer在编辑的时候每隔一段时间就存储到特定名称的文件里(当系统崩溃时这些缓存文件就可以避免编辑的丢失);font-lock-mode,根据当前buffer的语法以不同颜色显示文本(如果显示器支持);line-number-mode,在buffer的模式栏里显示当前编辑的行号(在底部)。
通常,如果一个包需要在不同的buffer中分别打开与关闭,那么它就应该被实现为子模式而不是主模式。这与我们上一部分中所描述的段落填充的需求是一致的,因此可以知道我们的段落填充功能应该是一个子模式。我们将在[[file:9.org][第九章]]中再关注主模式的实现。
* 定义子模式
在定义子模式时需要有下面这些步骤。
1. 选择一个名字。我们的模式名称为refill。
2. 定义一个名为name-mode的变量。使其成为buffer局部的。buffer的子模式在这个变量的值为非空的情况下表示打开,否则关闭。
#+BEGIN_SRC emacs-lisp
(defvar refill-mode nil
"Mode variable for refill minor mode.")
(make-variable-buffer-local 'refill-mode)
#+END_SRC
3. 定义一个名为name-mode的命令。[[[7-28][28]]]这个命令应该具有一个可选参数。如果不提供参数,它将打开或关闭模式。如果提供参数,且参数的prefix-numeric-value大于0则打开模式,否则关闭模式。也就是说,C-u M-x name-mode RET总是执行打开,而C-u - M-x name-mode RET总是关闭模式(查看[[file:2.org][第2章]]中的[[file:2.org::*补充:原始的前置参数][补充:原始的前置参数]]获得更多信息)。下面是开关Refill模式的命令定义:
#+BEGIN_SRC emacs-lisp
(defun refill-mode (&optional arg)
"Refill minor mode."
(interactive "P")
(setq refill-mode
(if (null arg)
(not refill-mode)
(> (prefix-numeric-value arg) 0)))
(if refill-mode
code for turning on refill-mode
code for turning off refill-mode))
#+END_SRC
setq语句看起来有些奇怪,但这在子模式定义中是一种常见的格式。如果arg为nil(没有前置参数),它会将refill-mode设置为(not refill-mode)--也就是refill-mode之前值的相反值,t或者nil。否则,它将refill-mode设置为
#+BEGIN_SRC emacs-lisp
(> (prefix-numeric-value arg) 0)
#+END_SRC
的值,当arg的值为大于0的数字时为t,否则为nil。
4. 向minor-mode-alist中添加一项,它是一个这种形式的assoc list(查看[[file:6.org][第六章]]中[[file:6.org::*其他有用的列表函数][其他有用的列表函数]]章节):
#+BEGIN_SRC emacs-lisp
((model string1)
(mode2 string2)
...)
#+END_SRC
新的项会将name-mode关联到一个将会在buffer的模式栏中使用的短字符串。模式栏(mode line)是每个Emacs窗口底部的信息栏;它会显示每个buffer的主模式名称以及其他处于激活状态的子模式名称,以及其他的一些信息。描述子模式的短字符串应该以空格开头,因为它会追加到信息栏的模式相关部分。下面的例子展示了对于Refill模式该如何做:
#+BEGIN_SRC emacs-lisp
(if (not (assq 'refill-mode minor-mode-alist))
(setq minor-mode-alist
(cons '(refill-mode " Refill")
minor-mode-alist)))
#+END_SRC
(最外层的if保证了(refill-mode " Refill")不会二次添加到minor-mode-alist里,例如当两次加载refill.el。)这让使用了refill-mode的buffer的模式栏看起来是这样的:
#+BEGIN_SRC emacs-lisp
--**-Emacs: foo.txt (Text Refill) --L1--Top---
#+END_SRC
在定义子模式时还有一些其他步骤在这个例子中没涉及。例如,子模式可能有一个keymap,一个与之关联的语法表(syntax table),或者一个abbrev表,但是因为refill-mode用不到,我们这里暂且忽略。
* Mode Meat
现在我们有了基本结构,让我们开始定义Refill mode的内容。
我们已经弄清了refill-mode的基本特性:每次插入和删除都必须保证当前段落的正确缩进。当buffer改变时触发代码执行的正确做法,你可以回想一下[[file:4.org][第四章]],就是当refill-mode激活时向钩子变量after-change-functions添加一个函数(关闭时移除)。我们将会添加一个refill函数(还未定义)来确保当前段落会被正确缩进。
#+BEGIN_SRC emacs-lisp
(defun refill-mode (&optional arg)
"Refill minor mode."
(interactive "P")
(setq refill-mode
(if (null arg)
(not refill-mode)
(> (prefix-numeric-value arg) 0)))
(make-local-hook 'after-change-functions)
(if refill-mode
(add-hook 'after-change-functions 'refill nil t)
(remove-hook 'after-change-functions 'refill t)))
#+END_SRC
add-hook和remove-hook后面的参数保证了我们修改的只是buffer局部的after-change-functions。不管在调用这个函数时refill-mode有没有打开,我们都调用(make-local-hook 'after-change-functions)来使其变为buffer局部的。这是因为在这两种情况--打开refill-mode或关闭--我们都需要对每个buffer单独操作after-change-functions。总是先调用make-local-hook是最简单的方式,而且如果一个钩子变量已经是buffer局部的,再次调用也没有什么副用。
现在剩下的事情就是定义refill函数了。
** Naive的首次尝试
就像第四章中提到的,钩子变量after-change-functions有些特殊,因为其中的函数需要三个参数(普通的钩子函数通常不需要参数)。三个参数指明了在after-change-functions执行之前,buffer的改变发生的地方。
+ 改变开始处,称为start
+ 改变结束处,称为end
+ 影响的文本长度,称为len
start和end指向buffer改变发生之后的位置。len指向与改变发生之前相比影响的文本长度。在插入发生之后,len为0(因为并不影响之前buffer中的文本),而新插入的文本在start和end之间。在删除发生之后,len为删除的文本的长度,文本已经被丢掉,而start和end为同一个数字,因为删除文本使它们指向了同一处,也就是删除内容的两端合二为一了。
现在我们知道了refill的参数应该是什么,我们可以做出一个朴素的尝试来对其进行定义:
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(fill-paragraph nil))
#+END_SRC
这种实现是非常不严谨的,因为每次按键都调用fill-paragraph代价太大了!它还有一个问题,就是每次向行尾添加一个空格时,fill-paragraph都会把它立即删除--它会在缩进的时候自动把尾部空格删除掉--因此,当你打字的时候,你将会花费最多的时间在行尾,唯一向两个单词间插入空格的方式就是先把两个单词打出来,likethis,然后向其中插入一个空格。但是这个尝试证明了我们的理论,并且给了我们一个可以对其进行改进的起点。[[[7-29][29]]]
** 限制refill
要优化refill,让我们先对问题进行一下分析。首先,是否每次整个段落都需要重排?
不。当插入和删除文本时,只有被影响的行和下面的行需要重排。前面的行并不需要。如果插入文本,行可能会变得太长,有些文本会挤入下一行(这可能会导致下一行也变得太长,因此这个过程是会重复的)。如果文本被删除,文本可能会变得太短,后续的行需要拿出一些文本来填补(这可能会导致下一行变得太短,因此这个过程也是会重复的)。所以变化并不会影响前面的行。
实际上,有一种情况的变化会影响前面一行。考虑下面这一段:
#+BEGIN_SRC text
Glitzy features like on-the-fly filling of paragraphs are
needed only to hide the programs's many inadequacies
compared to Emacs
#+END_SRC
假设我们删除第三行开头处的"compared":
#+BEGIN_SRC text
Glitzy features like on-the-fly filling of paragraphs are
needed only to hide the programs's many inadequacies
to Emacs
#+END_SRC
单词"to"现在可以移动到上一行的末尾处,就像这样:
#+BEGIN_SRC text
Glitzy features like on-the-fly filling of paragraphs are
needed only to hide the programs's many inadequacies to
Emacs
#+END_SRC
前面的例子应该可以告诉你前面的一行也需要重排--并且只有当前的行的第一个词被缩短或者删除的时候才会出现。
所以我们可以将段落重排操作限制为当前行,可能会影响前一行,以及后续的行。我们不使用fill-paragraph,因为它会自己判断段落边界,相反的我们自己选择"段落边界",然后使用fill-region。
我们为fill-region选择的边界应该包含段落中整个受影响的部分。对于插入,左边界就是简单的start,也就是插入的点,右边界是当前段落的结尾。对于删除,左边界是前一行的开始(也就是,包含start的前一行),右边界是行末尾。所以下面就是我们新的refill函数的概要:
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if this is an insertion
start
beginning of previous line))
(right end ofparagraph))
(fill-region left right ...)))
#+END_SRC
对于插入,完善这个函数是很简单的。之前说过,调用refill时,len为0则表示插入,非0则表示删除。
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (zerop len) ; len是否为0?
start
beginning of previous line))
(right end ofparagraph))
(fill-region left right ...)))
#+END_SRC
要计算前一行的开始,我们先要把光标移动到start,然后将光标移动到前一行的末尾(很奇怪,这可以通过(beginning-of-line 0)来得到),然后使用(point)来得到这个值,所有这些都放在save-excursion里:
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (zerop len)
start
(save-excursion
(goto-char start)
(beginning-of-line 0)
(point))))
(right end ofparagraph))
(fill-region left right ...)))
#+END_SRC
我们可以对段落的结束采用类似的计算方式,但是我们可以更方便的利用fill-region的特性:它将为我们找到段落结尾。fill-region的第五个参数(有两个必要参数和三个可选参数),如果非空,将会告诉fill-region一直重排到下一段之前。所以实际上我们并不需要计算right。
我们新版本的refill还没完成。我们必须首先解决fill-region会将光标放置到影响区域的末尾的问题。显然每次输入都把光标移动到段落末尾是不可接受的!将fill-region的调用包装在save-excursion里会解决这个问题。
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (zerop len)
start
(save-excursion
(goto-char start)
(beginning-of-line O)
(point))))
(save-excursion
(fill-region left end nil nil t)))))
#+END_SRC
(fill-region的第二个参数被忽略了,因为我们使用了它找寻段落结尾的特性。我们传递end只是因为这很方便而且对于读者来说并不是完全无意义的。)
** 小调整
好的,上面的只是基本的想法,还剩下许多事情要做。例如,当计算left时,如果前一行已经不在这个段落那么就没有必要再计算前一行了。所以我们应该得到行的开始以及前一行的开始,然后使用更大的那个值。
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (zerop len)
start
(max (save-excursion
(goto-char start)
(beginning-of-line 0)
(point))
(save-excursion
(goto-char start)
(backward-paragraph 1)
(point))))))
(save-excursion
(fill-region left end nil nil t))))
#+END_SRC
(函数max会返回参数里更大的那个。)
现在我们有三个地方调用了save-excursion,而这是个代价有点大的函数。更好的做法是将其中两个合并在一起然后计算两个需要的值。
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (zerop len)
start
(save-excursion
(max (progn
(goto-char start)
(beginning-of-line 0)
(point))
(progn
(goto-char start)
(backward-paragraph 1)
(point)))))))
(save-excursion
(fill-region left end nil nil t))))
#+END_SRC
下一步,回想我们关于重排前一行的观察:"前面的一行也需要重排--并且只有当前行的第一个词被缩短或者删除的时候才会出现。" 但是在我们的代码里,我们在删除的时候每次都会计算前一行。让我们看看在删除发生在非第一个词之外的地方时能否避免这个计算。
我们可以通过将下面的代码
#+BEGIN_SRC emacs-lisp
(if (zerop len)
start
find previous line)
#+END_SRC
修改为
#+BEGIN_SRC emacs-lisp
(if (or (zerop len)
(not (before-2nd-word-p start)))
start
find previous line)
#+END_SRC
来实现。before-2nd-word-p是一个用来告诉它的参数,一个buffer位置,是否出现在第二个单词之前的函数。
现在我们必须写出before-2nd-word-p。它应该找出当前行的第二个单词的位置,并且跟它的参数进行比较。
如何才能找到行中的第二个单词呢?
我们可以到行的开始,然后调用forward-word来跳过第一个单词。这个方法的问题是我们得到的是第一个单词的末尾,而非第二个单词的开头,它们之间可能有很多空格。
我们可以到行的开始,然后调用forward-word两次(实际上,我们可以调用forward-word一次,传入参数2),然后调用backward-word,这就会把我们置于第二个单词的开头。这不错,但是我们认识到forward-word和backward-word定义的"word"跟我们需要的定义并不相同。根据这些函数,标点符号(例如破折号)会分开单词,所以(例如)"forward-word"是两个单词。这对我们来说并不好,因为我们的函数只认为被空格分割才算两个单词。
我们可以到行的开始,然后跳过所有非空格的字符(第一个单词),然后跳过所有空格字符(第一个单词之后的空格),然后我们就在第二个单词的开头了。这听起来好一些;让我们试一下。
#+BEGIN_SRC emacs-lisp
(defun before-2nd-word-p (pos)
"Does POS lie before the second word on the line?"
(save-excursion
(goto-char pos)
(beginning-of-line)
(skip-chars-forward "^ ")
(skip-chars-forward " ")
(< pos (point))))
#+END_SRC
函数skip-chars-forward非常实用。它会向前移动光标,直到遇到一个你所指定的字符集里包含或者不包含的字符。字符集的工作方式跟正则表达式中的方括号语法一样(参考[[file:4.org][第四章]]中的[[file:4.org::*正则表达式][正则表达式]]中的规则3).所以
#+BEGIN_SRC emacs-lisp
(skip-chars-forward "^ ")
#+END_SRC
表示"跳过不是空格的所有字符",而
#+BEGIN_SRC emacs-lisp
(skip-chars-forward " ")
#+END_SRC
表示"跳过所有空格"。
这个方式的一个问题就是当一行里没有空格时,
#+BEGIN_SRC emacs-lisp
(skip-chars-forward "^ ")
#+END_SRC
将会直接跳到下一行!我们不希望这样。所以我们通过向第一个skip-chars-forward添加一个换行符来确保我们不会略过太多:
#+BEGIN_SRC emacs-lisp
(skip-chars-forward "^ \n") ; 跳到第一个空格或者换行符
#+END_SRC
另一个问题是有时tab("\t")制表符也有可能用来像空格一样分割单词。所以我们必须这样来修改我们的两个skip-chars-forward调用:
#+BEGIN_SRC emacs-lisp
(skip-chars-forward "^ \t\n")
(skip-chars-forward " \t")
#+END_SRC
还有其他的类似空格和制表符一样的被认为是空格的字符吗?也许有。换页符(ASCII 12)通常被认为是空格。而如果buffer使用了非ASCII的编码,有可能还有一些其他的字符会被认为是分隔单词的空格。例如,对于Latin-1这样8位字符编码,字符数字32和160都是空格--虽然160表示"非折断空格",即行不应该在此处折断。
与其我们关心这些细节,为什么不让Emacs自己判断呢?这就是语法表(syntax tables)发挥作用的时候了。语法表是一个与模式关联的将字符对应到"语法类别(syntax classes)"的映射表。类别包括"word constituent"(通常包括单词、省略号,有时包括数字),"balanced brackets"(通常为(), [], {}, 有时包括<>),"comment delimiters"(对于Lisp mode来说就是“;”, 对于C mode则为/*和*/),"punctuation",以及当然的,"whitespace"。
语法表被一些像forward-word和backward-word这样的函数用来找出一个词的类别是什么。因为不同的buffer有不同的语法表,同一个词的的定义可能会各有不同。我们将会使用语法表来找出在当前buffer中哪些字符被认为是空格。
我们所需要做的就是将我们两次的skip-chars-forward调用替换为skip-syntax-forward,就像这样:
#+BEGIN_SRC emacs-lisp
(skip-syntax-forward "^ ")
(skip-syntax-forward " ")
#+END_SRC
对于每个语法类别,都有一个对应的code letter。[[[7-30][30]]]空格是"whitespace"的code letter,所以上面的两行表示"跳过所有非空格"和"跳过所有空格"。
不幸的是,前面的skip-syntax-forward调用也有跳到下一行的问题。更坏的是,这次我们不能简单的将\n添加到skip-syntax-forward的参数里,因为\n并不是换行符语法类别的code letter。实际上,在不同buffer里的换行字符的code letter是不同的。
我们能做的是请求Emacs告诉我们换行字符的code letter是什么,然后使用这个结果来构建skip-syntax-forward的参数:
#+BEGIN_SRC emacs-lisp
(skip-syntax-forward (concat "^ "
(char-to-string
(char-syntax ?\n))))
#+END_SRC
函数char-syntax会返回字符的code letter。然后我们使用char-to-string将其转换为一个字符串并且添加到"^ "。
这是before-2nd-word-p的最终形态:
#+BEGIN_SRC emacs-lisp
(defun before-2nd-word-p (pos)
"Does POS lie before the second word on the line?"
(save-excursion
(goto-char pos)
(beginning-of-line)
(skip-syntax-forward (concat "^ "
(char-to-string
(char-syntax ?\n))))
(skip-syntax-forward " ")
(< pos (point))))
#+END_SRC
记住计算before-2nd-word-p的代价可能会超过它本身想提供的好处(即,在refill中避免调用end-of-line和backward-paragraph)。如果你感兴趣的话,你可以试着使用性能分析器(参见[[file:C.org][附录C]])来查看哪个版本的refill更快,是使用before-2nd-word-p的还是不使用的。
** 排除不希望的重排
在每次插入发生的时候我们并不需要重排段落。一个微小的并不会将任何文本推到右边界的插入并不会影响除它之外的任何其他行,所以如果当前改变是一次插入,并且start和end在同一行,并且行的末尾并没有超过右边界,那么我们没有必要调用fill-region。
这意味着我们需要把fill-region的调用包裹在一个if里,如下所示:
#+BEGIN_SRC emacs-lisp
(if (and (zerop len) ; 如果是插入...
(same-line-p start end) ; ...并且没有跨行
(short-line-p end)) ; ...并且行仍然够短
nil ; 那么什么都不做
(save-excursion
(fill-region ...))) ; 否则,refill
#+END_SRC
我们现在必须定义same-line-p和short-line-p。
现在看来编写same-line-p应该很简单。我们只需要简单的检测end是否在start和行尾之间就可以了。
#+BEGIN_SRC emacs-lisp
(defun same-line-p (start end)
"Are START and END on the same line?"
(save-excursion
(goto-char start)
(end-of-line)
(<= end (point))))
#+END_SRC
编写short-line-p也差不多同样明了。用于控制右边界的变量称为fill-column,而current-column返回一个点的横座标。
#+BEGIN_SRC emacs-lisp
(defun short-line-p (pos)
"Does line containing POS stay within 'fill-column'?"
(save-excursion
(goto-char pos)
(end-of-line)
(<= (current-column) fill-column)))
#+END_SRC
下面是refill的新的定义:
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (or (zerop len)
(not (before-2nd-word-p start)))
start
(save-excursion
(max (progn
(goto-char start)
(beginning-of-line 0)
(point))
(progn
(goto-char start)
(backward-paragraph 1)
(point)))))))
(if (and (zerop len)
(same-line-p start end)
(short-line-p end))
nil
(save-excursion
(fill-region left end nil nil t)))))
#+END_SRC
** 尾空格
我们还是没有解决fill-region会删除每行最后尾部空格的问题,也就是当你进行编辑的时候,你需要输入likethis,然后将光标移动到中间再插入一个空格!
我们的策略是当光标在行末空格的后面,或者光标在行末空格之间的时候不进行refill。这个条件可以被实现为
#+BEGIN_SRC emacs-lisp
(and (eq (char-syntax (preceding-char))
?\ )
(looking-at "\\s *$"))
#+END_SRC
当光标前的字符为空格而光标后面只有空格的时候为真。让我们仔细看一下它。
首先我们计算(char-syntax (preceding-char)),这将会得到光标前面的字符的语法类别,然后跟'?\'进行比较。这个奇怪的结构--问号,斜杠,空格--是Emacs Lisp中书写空格字符的方式。回想空格字符是"whitespace"语法类别的code letter,所以这个是用来检测前面的字符是否为空格的。
下一步我们调用looking-at,一个用来检测光标后的文本是否符合一个给定的正则表达式的函数。这个例子里的正则表达式是\s *$(之前说过,在Lisp字符串里斜杠需要加倍)。在Emacs Lisp正则表达式里,\s表示引入基于当前buffer语法表的一个语法类别。\s之后的字符表示使用哪个语法类别。在这个例子里,也就是空格,表示"whitespace"。所以'\s '表示"匹配一个whitespace字符",而\s *$表示"匹配0个或多个whitespace字符,直到行末尾"。
我们为refill的最后版本添加上这个检测。
#+BEGIN_SRC emacs-lisp
(defun refill (start end len)
"After a text change, refill the current paragraph."
(let ((left (if (or (zerop len)
(not (before-2nd-word-p start)))
start
(save-excursion
(max (progn
(goto-char start)
(beginning-of-line 0)
(point))
(progn
(goto-char start)
(backward-paragraph 1)
(point)))))))
(if (or (and (zerop len)
(same-line-p start end)
(short-line-p end))
(and (eq (char-syntax (preceding-char))
gitextract_lzy1usx8/
├── 0.org
├── 1.org
├── 10.org
├── 2.org
├── 3.org
├── 4.org
├── 5.org
├── 6.org
├── 7.org
├── 8.org
├── 9.org
├── A.org
├── B.org
├── C.org
├── D.org
├── E.org
├── LICENSE
├── README.org
├── html/
│ ├── 0.html
│ ├── 1.html
│ ├── 10.html
│ ├── 2.html
│ ├── 3.html
│ ├── 4.html
│ ├── 5.html
│ ├── 6.html
│ ├── 7.html
│ ├── 8.html
│ ├── 9.html
│ ├── A.html
│ ├── B.html
│ ├── C.html
│ ├── D.html
│ ├── E.html
│ ├── README.html
│ └── html.el
└── resource/
├── org.css
└── template.org
Condensed preview — 38 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,474K chars).
[
{
"path": "0.org",
"chars": 7070,
"preview": "#+TITLE: Writing GNU Emacs Extensions\n#+SETUPFILE: ./resource/template.org\n\nBob Glickstein\nO'REILLY\n\n献给我的父母, 如果没有他们……\n好吧"
},
{
"path": "1.org",
"chars": 12205,
"preview": "#+TITLE: 自定义Emacs\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*Backspace和Delete*\n*Lisp*\n*按键和字符串*\n*C-h绑定到什么*\n*C-h应该绑定到什么"
},
{
"path": "10.org",
"chars": 69877,
"preview": "#+TITLE: 一个综合示例\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*纽约时报规则*\n*数据表示*\n*用户界面*\n*建立模式*\n*追踪未授权的修改*\n*解析Buffer*\n*词语查找器*"
},
{
"path": "2.org",
"chars": 22574,
"preview": "#+TITLE: 简单的新命令\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*游历窗口*\n*逐行滚动*\n*其他光标和文本移动命令*\n*处理符号链接*\n*修饰Buffer切换*\n*补充:原始的前置"
},
{
"path": "3.org",
"chars": 16326,
"preview": "#+TITLE: 协作命令\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*症状*\n*解药*\n*归纳出更一般的解决方法*\n\n本章将讲述不同命令协同工作,以完成在一个命令里保存信息而在另一个命令里获"
},
{
"path": "4.org",
"chars": 28694,
"preview": "#+TITLE: 搜索和修改Buffers\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*插入当前时间*\n*记录戳*\n*修改戳*\n\n很多场景中你会想要在buffer中搜索一个字符串,可能还希望用"
},
{
"path": "5.org",
"chars": 10849,
"preview": "#+TITLE: Lisp文件\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*创建Lisp文件*\n*加载文件*\n*编译文件*\n*eval-after-load*\n*局部变量列表*\n*补充:安全考"
},
{
"path": "6.org",
"chars": 13813,
"preview": "#+TITLE: 列表\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*列表初探*\n*列表细节*\n*递归列表函数*\n*迭代列表函数*\n*其他有用的列表函数*\n*破坏性列表操作*\n*循环列表?!*\n"
},
{
"path": "7.org",
"chars": 18316,
"preview": "#+TITLE: 子模式\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*段落填充*\n*模式*\n*定义子模式*\n*Mode Meat*\n\n有时我们希望扩展(extension)只影响某些特定类型的"
},
{
"path": "8.org",
"chars": 12447,
"preview": "#+TITLE: 求值和纠错\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*有限版本的save-excursion*\n*eval*\n*宏函数*\n*反引用和去引用*\n*返回值*\n*优雅的失败*\n*"
},
{
"path": "9.org",
"chars": 13328,
"preview": "#+TITLE: 主模式\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*我的Quips文件*\n*主模式框架*\n*改变段落的定义*\n*Quip命令*\n*键位表*\n*Narrowing*\n*继承模式"
},
{
"path": "A.org",
"chars": 652,
"preview": "#+TITLE: 附录A 总结\n#+SETUPFILE: ./resource/template.org\n\n现在你可以开始你的Emacs Lisp编程生涯了。我花费了多年的经验才学会了本书中讨论的技术和工具。\n\n在我写本书的前言时,我说过本"
},
{
"path": "B.org",
"chars": 8176,
"preview": "#+TITLE: 附录B Lisp快速参考\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*基础*\n*数据类型*\n*控制结构*\n*代码对象*\n\n这个附录总结了Emacs中常用的Lisp语法,以及一"
},
{
"path": "C.org",
"chars": 3762,
"preview": "#+TITLE: 附录C 调试和性能分析\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*求值*\n*调试器*\n*Edebug*\n*性能分析器*\n\n这个附录描述了一些Emacs提供的用来测试和调试你"
},
{
"path": "D.org",
"chars": 2119,
"preview": "#+TITLE: 附录D 分享你的代码\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*准备源文件*\n*文档*\n*版权*\n*发布*\n\n如果你写了一个buling buling的新Emacs模式,或"
},
{
"path": "E.org",
"chars": 2837,
"preview": "#+TITLE: 附录E 获取以及编译Emacs\n#+SETUPFILE: ./resource/template.org\n\n* 在本章:\n*获取包*\n*解包,编译,以及安装Emacs*\n\n* 获取包\n本书中描述的所有软件包,除了TEX,都"
},
{
"path": "LICENSE",
"chars": 35147,
"preview": " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n\n Copyright (C) 2007 Free "
},
{
"path": "README.org",
"chars": 1149,
"preview": "#+TITLE: Writing GNU Emacs Extesions 翻译\n#+AUTHOR: slegetank\n#+OPTIONS: \\n:\\n\n\n《Writing GNU Emacs Extensions》是由Bob Glicks"
},
{
"path": "html/0.html",
"chars": 19008,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/1.html",
"chars": 32427,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/10.html",
"chars": 218331,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/2.html",
"chars": 63258,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/3.html",
"chars": 53561,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/4.html",
"chars": 83966,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/5.html",
"chars": 32420,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/6.html",
"chars": 57835,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/7.html",
"chars": 57236,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/8.html",
"chars": 44997,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/9.html",
"chars": 41839,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/A.html",
"chars": 9892,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/B.html",
"chars": 35019,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/C.html",
"chars": 15657,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/D.html",
"chars": 13653,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/E.html",
"chars": 14182,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/README.html",
"chars": 12874,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n\"http://www.w3.org/TR/xh"
},
{
"path": "html/html.el",
"chars": 890,
"preview": "(defun WGEECN/export-org-to-html (org-name)\n \"Export org file to html for WGEECN.\"\n (let ((org-path (format \"../%s\" or"
},
{
"path": "resource/org.css",
"chars": 7315,
"preview": "html {\n\tfont-family: sans-serif;\n\t-ms-text-size-adjust: 100%;\n\t-webkit-text-size-adjust: 100%\n}\n\nbody {\n\tmargin: 0\n}\n\nar"
},
{
"path": "resource/template.org",
"chars": 99,
"preview": "#+HTML_HEAD: <link rel=\"stylesheet\" type=\"text/css\" href=\"../resource/org.css\" />\n#+OPTIONS: \\n:\\n\n"
}
]
About this extraction
This page contains the full source code of the slegetank/WGEECN GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 38 files (1.0 MB), approximately 416.1k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.