Skip to content

Latest commit

 

History

History
706 lines (562 loc) · 46.7 KB

4.org

File metadata and controls

706 lines (562 loc) · 46.7 KB

搜索和修改Buffers

在本章:

插入当前时间 记录戳 修改戳

很多场景中你会想要在buffer中搜索一个字符串,可能还希望用另一个字符串替换它。本章中我们将会为此展示很多有效的方法。我们将会讲解那些执行搜索功能的函数,并且展示如何构建正则表达式,这会给你的搜索带来巨大的灵活性。

插入当前时间

当你编辑一个文件时插入当前的日期或者时间有时是很有用的。例如,在我写这一章的时候,是1996年8月18日星期五的晚上10点半。几天前,我在编辑一个Emacs Lisp代码文件时,我将注释

;; Each element of ENTRIES has the form
;; (NAME (VALUE-HIGH . VALUE-LOW))

修改为

;;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]

我在注释中插入了一个时间戳,因为这在当我以后要修改这段代码时,我可以知道这段代码是在之前什么时候做出的修改。

如果你知道函数current-time-string返回今天的日期和时间字符串,那么插入当前时间的命令是很简单的:[18]

(defun insert-current-time ()
  "Insert the current time"
  (interactive "*")
  (insert (current-time-string)))

本章后面的章节更多的星号魔法将会解释(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的形式)。例如,

(format-time-string "%l.%M %p" (current-time))

返回“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。

用户选项和文档字符串

首先我们定义变量。

(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').")

关于文档字符串我们能看到两个新特性。

首先,每个都以星号(*)开始。defvar的文档字符串以星号开头有特殊的含义。它表示这个变量是一个用户选项(user option)。用户选项和其他Lisp变量没什么区别,除了下面这两种情况:

  • 用户选项可以使用set-variable命令以交互的方式进行设置,Emacs会问用户要一个变量名(Emacs会自动补全用户的输入)以及一个值。有时,可以不以Lisp语法输入值;也就是说,不必在输入的时候带着外面的括号。当你设置非用户选项的变量值时,你必须这样做:
    

M-: (setq variable value) RET
        



(需要使用Lisp语法来设置值)。 而用户选项可以通过M-x edit-options RET[19]激活option-editing模式来进行编辑。

  • 关于文档字符串的第二个新特性是它们都包含一个特殊的结构\[command]。(是的,确实是\[…],但是因为是在Lisp字符串里面,反斜杠需要被转义:\[…])。这个语法非常神奇。当文本字符串显示给用户时–例如当用户使用apropos或者describe-variable时–\[command]将会被替换为command所关联的键绑定。例如,如果C-x t会触发insert-time,那么文本字符串

    "*Format for \\[insert-time] (c.f. 'format-time-string')"
        



将显示为



*Format for C-x t (c.f. 'format-time-string').



如果insert-time并没有键绑定,那么将会默认显示M-x insert-time。如果有多个键绑定到了insert-time,Emacs会自己选择一个。


如果你希望字符串\[insert-time]不被替换,如何阻止它被替换为对应的键绑定呢?对于这种情况有一个特殊的转义序列:\=。当\=出现在\[…]之前时,\[…]将不会发生替换。当然在Lisp字符串里面你需要这样写“…\=\[…]…”。



如果你并不希望一个变量作为用户选项,而你又希望它的文本字符串以星号开头时,\=也会起作用。

所有在多个函数之间共享的变量都应该使用defvar来声明。如何选择哪些变量作为用户选项存在呢?一个经验之谈是如果某个变量直接控制一个用户可见的并且想要控制的特性,并且设置这个变量很简单时(也就是没有复杂的数据结构和特定的编码值),那么就可以将它设置为用户选项。

更多的星号魔法

前面我们定义了用来控制insert-time和insert-date的变量,下面就是这两个简单函数的定义。

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

这两个函数非常相似,除了一个使用insert-time-format而另一个使用insert-date-format。insert函数使用任意数量的参数(类型必须为字符串或者字符),顺序的将它们插入到当前文本位置的后面。

对于这两个函数最需要注意的是它们都以下面的结构开始

(interactive "*")

之前你已经知道了interactive将一个函数转变为一个命令,以及指定用户交互输入时如何获取函数的参数。但是我们在之前并没有看到过*作为interactive的参数,而且,这两个函数并没有参数,那么这个*到底代表什么呢?

当星号作为interactive的第一个参数时,这表示“如果当前buffer为只读时终止这个函数”。在函数开始之前就去检测buffer是否为只读要比函数执行了一半才提示用户“Buffer is read-only”错误信息要更好。在本例中,如果我们忽略对于buffer只读的检测,insert函数将会触发它自己的“Buffer is read-only”错误,这当然也没有什么危害会发生。但是在其他更复杂的函数里,这可能会造成一些不可逆的副作用(例如修改了全局变量)。

记录戳(Writestamps)

以一种可配置的格式自动插入当前的时间和日期是非常简洁并且可能超过了大多数编辑器的功能,但是这并不是太有用。很显然更有用的能力是将一个记录戳(文件最后修改的日期、时间)保存在文件里。每次文件保存时记录戳会自动更新。

更新记录戳

首先我们要做的是每次文件保存时自动执行我们的writestamp-updating代码。就像我们在第二章的章节钩子中看到的,把代码跟某些常用动作(例如保存文件)关联的最好方式就是将函数添加到一个钩子变量里。使用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。回忆一下第二章的章节钩子中关于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((”放在左边而“))”放在右边,这样它在文件里看起来是这样的:

went into the castle and lived happily ever after.
The end. WRITESTAMP((12:19pm 7 Jul 97))

假设WRITESTATMP((…))当中的东西是由insert-date放入的(我们之前已经定义了),那么它的格式可以通过insert-date-format进行控制。

现在,假设文件里已经有了一些记录戳,[20]我们可以在保存文件时这么更新它们:

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

这里有很多的新知识。让我们一行一行的来阅读这个函数。

首先我们看到函数体被包在了一个函数save-excursion中。save-excursion的作用是记录光标的位置,执行参数中的子表达式,然后将光标移动回原处。在这里它很有用,因为我们的函数体会将光标在buffer中到处移动,而在函数结束时我们希望函数的调用者感觉不到这些。在第八章中将会有更多关于save-excursion的信息。

下一步调用了save-restriction。它的作用方式跟save-excursion相似,也是记录了某些信息,然后执行它的参数,然后将信息恢复。这里它记录的是buffer的restriction,它是narrowing的结果。narrowing在第九章中将会做具体描述。现在我们只要知道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是一种很好的实践)。

主循环看起来是这样的:

(while (search-forward "WRITESTAMP((" nil t)
  ...)

这是一个while循环,它跟其他语言中的while循环功能相似。第一个参数是每次循环时的判断表达式。如果表达式返回真,则其他参数被执行,循环继续。

表达式(search-forward “WRITESTAMP((” nil t)将会从当前位置开始,搜索第一个匹配的字符串。nil表示将会一直搜索到buffer的结尾。稍后将介绍更多细节。t表示如果没发现匹配项,search-foward将会简单的返回nil。(如果不设t,search-forward在未找到匹配项时将会触发一个错误,终止当前的命令。)如果搜索成功了,point将会移动到匹配的字符串之后的第一个字符,search-forward将会返回这个位置(可以通过使用match-beginning来找到搜索开始的位置,如图4-1所示)。

resource/4-1.png

图4-1 在搜索了WRITESTAMP((之后

while的循环体是

(let ((start (point)))

这会创建一个临时变量start,用于保存point的位置,也就是WRITESTARMP((…)分隔符中日期字符串的开始位置。

start定义了之后,let的body包含如下:

(search-forward "))")
(delete-region start (- (point) 2))
(goto-char start)
(insert-date)

这里search-forward会把point放置到两个反括号的后面。我们仍然知道时间戳的开头位置,因为它已经保存到了start中,如图4-2所示。

resource/4-2.png

图 4-2 在搜索了”))” 之后

这一次,我们只提供了第一个参数作为搜索字符串。前面我们还看到了两个额外参数:搜索的范围,以及是否触发错误。当省略的时候,他们默认为nil(不限制搜索范围)以及nil(如果搜索失败触发错误)。

在search-forward成功之后–如果失败了,则函数产生错误并且终止–delete-region将会删除记录戳中的日期,从start的位置开始到(- (point) 2)的位置(point左边两个字符)结束,结果如图4-3所示。

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调用之后的最后一个表达式,是一个

nil

这是函数的返回值。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那样描述要使用的时间格式;另外两个用来描述将记录戳括起来的分隔符。

(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.")

现在我们可以修改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-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)

在这个版本的update-writestamps里,我们将”WRITESTAMP((”和“))”替换成了writestamp-prefix和writestamp-suffix,并且将insert-date替换为了

(insert (format-time-string writestamp-format
                            (current-time)))

我们还改变了delete-region的调用。前面它看起来是这样的:

(delete-region start (- (point) 2))

之前我们的记录戳的后缀被写死为“))”,而它的长度为2。但是现在我们的后缀被储存在一个变量中,我们并不能提前知道它的长度。我们当然可以通过调用length来获得它:

(delete-region start (- (point)
                        (length writestamp-suffix)))

但是一个更好的解决方案是使用match-beginning。记得我们在调用delete-region之前是

(search-forward writestamp-suffix)

不管writestamp-suffix是什么,search-forward都会找到第一个匹配项,并且返回它之后的第一个位置。而关于匹配的其他额外信息,特别是匹配项的开始的位置,被存储在了Emacs的一个全局的匹配项变量里。访问这个数据需要通过函数match-beginning以及match-end。由于稍后可见的原因,match-beginning需要一个参数0来告诉你最后一次搜索的匹配项的开始的位置。本例中,这也就是记录戳后缀的开始的位置,也就是记录戳里面日期的末尾,也就是要删除的范围的结尾:

(delete-region start (match-beginning 0))

正则表达式

假设用户选择“Written:”和“.”作为writestamp-prefix和writestamp-suffix的值,那么记录戳看起来将会是这样的:“Written: 19 Aug 1996.”。这是一个很有可能的用户选择,但是字符串“Written:”并不像“WRITESTAMP((”这么特殊。换句话说,文件中很有可能包含其他“Written:”字符串而它并不是一个记录戳。当updatewritestamps搜索writestamp-prefix时,它将会找到其中一个,然后它会搜索后缀,删掉它们之间所有的东西。更糟糕的是,这种异常删除的发生几乎是不可察的,因为当文件保存之后它就可能发生。

解决这个问题的一种方式是加强记录戳格式的限制,使错误的匹配更难发生。一种自然的可以做出的限制是将记录戳单独存于一行:换句话说,只有当writestamp-prefix作为一行的开始而writestamp-suffix作为一行的结束时,字符串才有可能是记录戳。

这样

(search-forward writestamp-prefix ...)

就并不满足用来搜索记录戳了,因为这并不会只在行的开始搜索匹配项。

这就是正则表达式出场的好时机了。正则表达式(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);[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。

  1. 反斜杠后面跟一个数字n表示匹配和第n个括起来的子表达式相同的文本。所以表达式\(a+b\)\1匹配abab,aabaab,和aaabaaab,但是不匹配abaab(因为ab和aab不同)。
  2. 有很多种方法可以匹配空字符串。 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只需要在后面加一个$,就像这样:

(re-search-forward (concat "^"
                           writestamp-prefix) ...) ; 错啦!

(re-search-forward (concat writestamp-suffix
                           "$") ...) ; 错啦!

函数concat将它的参数合成一个单独字符串。函数re-search-forward是search-forward的正则表达式版本。

这几乎是正确的。但是,它有一个常见的错误:writestamp-prefix或者writestamp-suffix都可能包含元字符。实际上,writestamp-suffix确实有,在我们的例子里即“.”。因为点号匹配任何字符(除了换行符),这个表达式:

(re-search-forward (concat writestamp-suffix
                           "$") ...)

等同于表达式:

(re-search-forward ".$" ...)

这会匹配任何行尾的字符,而我们只想要匹配点号(.)。

当像本例中那样构建一个正则表达式,而writestamp-prefix的内容却超出了程序员的控制时,移除字符串中包含的元字符的“魔力”而让他们只表达字面意思是必须的。Emacs为此提供了一个函数regexp-quote,它理解正则的语法然后将一个正则表达式字符串转换为对应的“非魔法”的字符串。例如(regexp-quote “.”)会产生“\.”。你应该总是使用regexp-quote来移除作为变量提供的字符串中的魔力。

我们现在知道了如何开始编写新版本的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 (re-search-forward
                (concat "^"
                        (regexp-quote writestamp-prefix))
                nil t)
          ...))))
  nil)

有限搜索

让我们编写while循环的body来完成新版本的update-writestamp。在re-search-forward完成后,我们需要知道当前行是否以writestamp-suffix结束。但是我们不能简单的这么写

(re-search-forward (concat (regexp-quote writestamp-suffix)
                           "$"))

因为这可能会匹配到非本行的匹配项。我们只对本行是否匹配感兴趣。

我们的解决方式是只把搜索限制在本行。search-forward和re-search-forward的第二个可选参数,如果不是nil的话,是指搜索时不超过的位置。如果我们将当前行的末尾位置作为参数传入:

(re-search-forward (concat (regexp-quote writestamp-suffix)
                           "$")
                   end-of-line-position)

那么搜索就会限制到本行之内,这正是我们需要的。那么问题是我们如何得到end-of-line-position的值呢?我们可以简单的使用endf-of-line将光标移动到行尾,然后得到point的值。但是要记住在这样做之后我们需要把光标移动到它原来的地方。移动光标然后恢复场景的工作正是save-excursion所做的。所以我么可以这么写:

(let ((end-of-line-position (save-excursion
                              (end-of-line)
                              (point))))
  (re-search-forward (concat (regexp-quote writestamp-suffix)
                             "$")
                     end-of-line-position))

这会创建一个临时变量end-of-line-position来限制re-search-forward的搜索范围;但是不使用这个变量更简单:

(re-search-forward (concat (regexp-quote writestamp-suffix)
                           (save-excursion
                             (end-of-line)
                             (point))))

注意save-excursion表达式的返回值是它的最后一条语句(point)的值。

所以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 (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)

更强大的正则能力

我们已经把我们最初的update-writestamps转换成了正则的形式,但是却并没有真正的展现出正则强大的能力。实际上,上面那长长的用于找到记录戳,检测同一行内的记录戳后缀,然后将其替换的代码可以被简化为下面的两个表达式:

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

第一个表达式,使用下面的正则调用了re-search-forward:

^prefix\(.*\)suffix$

这里的prefix和suffix是regexp-quote版本的writestamp-prefix和writestamp-suffix。这个正则表达式匹配以记录戳前缀开始,跟着任何字符串(使用\(…\)构建的子匹配项),以记录戳后缀结束的一行。

第二个表达式调用了replace-match,它将会替换部分或者所有前一次搜索的匹配项。它的用法如下:

(replace-match new-string
               preserve-case
               literal
               base-string
               subexpression)

第一个参数是要插入的新字符串,本例中也就是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的最终改进,我们可以看到如果我们这样写

(while (re-search-forward (concat ...) ...)
  (replace-match ...))

那么concat函数在每次循环里都会调用,即使参数没有改变,每次都会生成一个新的字符串。这样效率很低。更好的方式是在循环之前计算出我们需要的字符串,然后存储在一个临时变量里。因此,最好的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))
        (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)

修改戳

好的,时间戳(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则继续使用默认的全局变量。

(make-local-hook 'first-change-hook)

虽然可以使用make-localvariable或者make-variable-buffer-local来使普通变量变为buffer局部的(下面会看到),但是钩子变量必须使用make-local-hook。

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

add-hook中的nil参数只是一个占位符。我们只关注最后一个参数t,它表示“只更改first-changehook的buffer局部备份”。

这种方式的问题是如果你在保存文件前对其进行了十处修改,那么修改戳会记录第一次的时间,而不是最后一次的时间。某些情况下这已经足够用了,但是我们还可以做得更好。

简单的方式#2

这一次我们再次使用local-write-file-hooks,但是我们只在buffer-modified-p返回true的时候才调用update-modifystamps,也就是说只在当前buffer被改动的情况下才调用它:

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

现在我们有了跟方式#1相反的问题:最后修改的时间一直到文件保存的时候才会计算,这可能比最后一次修改的时间晚很久。如果你在2:00的时候修改了文件,而在3:00的时候做了保存,那么修改戳将会把3:00作为最后保存的时间。这更接近了,但是并不完美。

聪明的方式

理论上,我们可以在每次更改buffer之后调用update-modifystamps,但是实际中在每次按键之后都搜索整个文件并且对其进行修改是代价昂贵的一件事。但是每次buffer更改之后将时间记录下来就不那么难以接受。然后,当buffer保存到文件时,记录的时间就可以用来计算修改戳中的时间了。

钩子变量after-change-functions包含着每次buffer更改时要调用的函数。首先我们让它变为buffer-local的:

(make-local-hook 'after-change-functions)

然后我们定义一个buffer-local的变量来保存这个buffer最后一次修改的时间:

(defvar last-change-time nil
  "Time of last buffer modification.")
(make-variable-buffer-local 'last-change-time)

函数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里:

(add-hook 'after-change-functions 'remember-change-time nil t)

after-change-functions中的函数有三个参数来描述刚刚发生的改变(参照第7章中的Mode Meat)。但是remember-change-time并不关心刚才发生了什么更改;它只关心发生了更改这件事本身。所以我们可以选择忽略这些参数。

(defun remember-change-time (&rest unused)
  "Store the current time in 'last-change-time'."
  (setq last-change-time (current-time)))

关键字&rest,后面跟着一个参数名称,只能出现在函数的参数列表最后。它表示“将剩下的参数收集到一个列表里并且赋给最后的参数”(本例中为unused)。函数可能还有其他的参数,包括&optional的可选参数,但是这些都要出现在&rest的前面。在所有其他参数按照正常格式分配完成后,&rest将其他剩下的参数放到一个列表里。所以如果一个函数这么定义

(defun foo (a b &rest c)
  ...)

那么当(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的额外调用。

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

最后,我们不希望在last-change-time为nil时调用update-modifystamps:

(defun maybe-update-modifystamps ()
  "Call 'update-modifystamps' if the buffer has been modified."
  (if last-change-time ; 替换对于(buffer-modified-p)的检测
      (update-modifystamps)))

maybe-update-modifystamps中仍然存在很大的问题。在阅读下一部分前,你能找出那是什么吗?

一个小Bug

缺陷是每次update-modifystamps重写修改戳时,会引起buffer的改变,这又会造成last-change-time的改变!这样只有第一次修改戳会被正确的修改。后续的修改戳会是一个与文件储存的时间相近的时间而不是最后一次修改的时间。

一个绕过这个问题的方法是当执行update-modifystamps时暂时将after-change-functions置为nil:

(add-hook 'local-write-file-hooks
          '(lambda ()
            (if last-change-time
                (let ((after-change-functions nil))
                  (update-modifystamps)))))

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:

(add-hook 'local-write-file-hooks
          '(lambda ()
            (if last-change-time
                (update-modifystamps last-change-time))))

这需要修改update-modifystamps,使其具有一个参数,并且在调用format-time-string时使用它:

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

你可能会觉得为了使修改戳工作,你写出了许多表达式,建立了很多变量,而这看起来很难维护。你是对的。所以在下一章,我们来看看如何在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”。