Elisp 08:文本跨行提取

上一章:命令行程序界面

在上一章的结语里,我说这个教程是否会有第二部分,取决于我是否遇到了新的文本处理问题。结果很快如愿以偿。

问题

下面是 XML 文件 foo.xml 的内容:


  foo


  
  foo


  bar


  
  bar

我需要从 ... 块里提取以下条目:


foo

bar

文本跨行匹配

现在假设已用 Elisp 函数 find-file 将 foo.xml 文件内容全部载入了缓冲区,即

(find-file "foo.xml")

然后发现,之前学过的 Elisp 知识几乎派不上用场了。之前学过的文本匹配和提取方法仅适用于单行文本,而现在面临的问题是多行文本的匹配和提取,即从当前缓冲区内提取


  
  foo


  
  bar

莫说提取,仅仅是如何匹配 ... 块就已经不好解决了。例如,以下程序

(find-file "foo.xml")

(let ((x (buffer-string)))
  (string-match "\\(.+\\)" x)
  (princ\' (match-string 1 x)))

输出 nil,意味着 string-match 在当前缓冲区内容中匹配 ... 块失败。导致失败的原因也很简单,因为正则表达式 . 虽然可以匹配任意一个字符,但它不包括换行符。

瞒天过海

实现文本的跨行匹配,并非不可行,但是需要比现在更多的 Elisp 的正则表达式知识 1。但是,我想说的是,对于上述问题,现有的 Elisp 知识其实也是足够用,只需要转换一下思路。

文本为什么是多行的?是因为在输入文本的时候,每一行末尾由人或程序添加了换行符。倘若能将这些换行符临时替换为一个很特殊的记号,那么多行文本就变成了单行文本。在文本匹配和处理结束后,再将这个特殊记号再替换为换行符,单行文本又复原为多行文本。此为瞒天过海之计。

将当前缓冲区内所有的换行符替换为一个特殊记号,可基于第 6 章所讲的缓冲区变换方法予以实现。本章给出一个更快捷的方法。Elisp 函数 replace-string 可在当前缓冲区内使用指定字串替换所有目标字串,例如

(let ((x "")
      (y "")
      (one-line (generate-new-buffer "one-line")))
  (find-file "foo.xml")
  (setq x (buffer-string))
  (with-current-buffer one-line
    (insert x)
    (goto-char (point-min))
    (replace-string "\n" "")
    (setq y (buffer-string)))
  (princ\' y))

执行上述程序后,在新创建的缓冲区 one-line 里存放的便是 foo.xml 缓冲区的单行化结果。倘若将上述代码里的 (princ\' y) 语句替换为

(string-match "\\(.+\\)" y)
(princ\' (match-string 1 y))

便可提取 ... 块,尽管提取的结果是错的。

为了更方便观察错误,需要构造一个简单的例子:

(setq x "abcabcabc")
(string-match "a\\(.+\\)a" x)
(princ\' (match-string 1 x))

这个例子会输出什么呢?虽然我很期望它输出 bc,但事实上它输出的是 bcabc。这是因为 + 是很贪婪的,它总是希望能匹配最长的结果,而不是最短的。* 也是如此。在 Elisp 的正则表达式里,在它们的后面加一个 ?,便可以抑制它们的贪婪,例如

(setq x "abcabcabc")
(string-match "a\\(.+?\\)a" x)
(princ\' (match-string 1 x))

此时,程序的输出结果便是 bc 了。

递增搜索

Elisp 函数 re-search-forward 可以在缓冲区内搜索与正则表达式匹配的文本的同时,将插入点移动到缓冲区的匹配位置。基于该函数,再借助 Elisp 正则表达式的文本捕获功能,便可从上一节构造的 one-line 缓冲区内提取多个 ... 块了。

为了演示 re-search-forward 的用法,我将上一节的那段示例代码改造为以下代码:

(let ((x "")
      (one-line (generate-new-buffer "one-line"))
      (output (generate-new-buffer "output")))
  (find-file "foo.xml")
  (setq x (buffer-string))
  (with-current-buffer one-line
    (insert x)
    (goto-char (point-min))
    (replace-string "\n" "")
    (goto-char (point-min))
    (while t
      (if (re-search-forward "\\(.+?\\)" nil t 1)
          程序分支 1
        程序分支 2))))

re-search-forward 是迄今为止我用过的最为复杂的 Elisp 函数了,它有 4 个参数,但只有第 1 个参数是必须的,其他 3 个参数皆为可选——倘若不设定它们的值,re-search-forward 会使用它们的默认值。这 4 个参数释义如下:

  • 第一个参数,是用于文本匹配的 Elisp 正则表达式。
  • 第二个参数,用于设定最大搜索范围。由于 re-search-forward 是在当前缓冲区内进行文本匹配搜索,搜索的起始位置是插入点所在位置,终止位置可通过它的第二个参数设定,若该参数值为 nil,则将当前缓冲区的尽头作为搜索范围的终止位置。
  • 第三个参数值若为 nil,在未搜索到匹配文本时,re-search-forward 便会报错。若该参数值为 tre-search-forward 会返回 nil。若该参数值即不是 nil,也不是 t,则 re-search-forward 函数将插入点移动到搜索区域的尽头,然后返回 nil
  • 第四个参数 COUNT,可令 re-search-forward 的搜索过程维持到第 COUNT 次匹配后结束,倘若未设定这个参数,其值默认为 1。

若充分理解了 re-search-forward 函数的用法,则上述代码虚设的程序分支 1 对应的代码便可写出来了,不再需要新的 Elisp 知识,即

(let ((y (match-string 1)))
  (with-current-buffer output
    (insert (concat y "\n"))))

就是将 re-search-forward 捕获的文本用 match-string 函数取出后插入 output 缓冲区。在此需要注意,若正则表达式捕获的文本属于当前缓冲区,match-string 函数无需写第 2 个参数。

对于程序分支 2,即 re-search-forward 匹配失败情况的处理,现有的 Elisp 知识是真的不够用了。因为该程序分支属于一个无限迭代过程,要从后者跳出,需要像其他编程语言那样,需要有 returnbreak 语法,可提前终止迭代过程。

catch/throw

Elisp 语言没有 returnbreak,但是它有 catch/throw 表达式。

下面的示例

(catch 'foo
  (princ\' "foo")
  (princ\' "bar"))

可输出

foo
bar

现在,倘若我将上述代码修改为

(catch 'foo
  (princ\' "foo")
  (throw 'foo nil)
  (princ\' "bar"))

那么位于 throw 表达式之后的代码便会被 Elisp 解释器忽略,因而现在的代码只能输出

foo

倘若将上述代码修改为

(princ\' (catch 'foo
           (princ\' "foo")
           (throw 'foo nil)
           (princ\' "bar")))

输出结果则变为

foo
nil

因为 throw 的第 2 个参数 nil 会被 Elisp 作为 catch 表达式的求值结果。

catch/throw 在 Elisp 语言里称为「非本地退出」,基于它们便可模拟其他编程语言里的 returnbreak 以及异常机制。

基于 catch/throw,便可实现上一节所述的程序分支 2 了,例如

(throw 'break nil)

然后只需将 while 表达式放在 catch 块里,由后者捕捉 throw 抛出的 'break,即

(catch 'break
  (while t
    (if (re-search-forward "\\(.+?\\)" nil t 1)
        程序分支 1
      (throw 'break nil))))

恢复多行文本

现在,以下代码

(let ((x "")
      (one-line (generate-new-buffer "one-line"))
      (output (generate-new-buffer "output")))
  (find-file "foo.xml")
  (setq x (buffer-string))
  (with-current-buffer one-line
    (insert x)
    (goto-char (point-min))
    (replace-string "\n" "")
    (goto-char (point-min))
    (catch 'break
        (while t
          (if (re-search-forward "\\(.+?\\)" nil t 1)
              (let ((y (match-string 1)))
                (with-current-buffer output
                  (insert (concat y "\n"))))
            (throw 'break nil))))))

已基本解决本章开始所提出的问题了,因为 output 缓冲区内存放着从 foo.xml 文件里提取的两个 ... 块,接下来,我只需将其中的 替换为 \n,问题便完全解决了。但是,我觉得这个任务可以留作本章习题。

save-excursion

在当前缓冲区内,insertreplace-string 以及 re-search-forward 等函数,皆有副作用,它们会移动插入点。在文本处理时,要记住当前的插入点所在的位置,然后调用这些函数之后,需要再将插入点恢复原位。这是前面几节代码多次出现

(goto-char (point-min))

的主要原因。Elisp 提供了 save-excursion 语法,它可以自动将插入点的位置保存下来,然后执行一些可能会移动插入点的运算,最后再将插入点恢复原位。例如

(save-excursion
  (insert x))

(let ((p (point)))
  (insert x)
  (goto-char p))

等价。

因此,本章第二个习题是,基于 save-excursion 语法修改上一节习题的答案。

结语

本章介绍了 Elisp 缓冲区里更多的运算以及非本地退出语法。掌握了这些知识,可从任何文本文档内提取符合模式的由多行文本构成的文本块。


  1. https://www.emacswiki.org/ema...

你可能感兴趣的:(lispemacselisp)