欢迎光临
我们一直在努力

Elisp 08:文本跨行提取问题文本跨行匹配瞒天过海递增搜索catch/throw恢复多行文本save-excursion结语

上一章:命令行程序界面

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

问题

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

<bib>
  <title>foo</title>
</bib>
<attachment>
  <resource src="files/foo.html"/>
  <title>foo</title>
</attachment>
<bib>
  <title>bar</title>
</bib>
<attachment>
  <resource src="files/bar.html"/>
  <title>bar</title>
</attachment>

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

<resource src="files/foo.html"/>
<title>foo</title>
<resource src="files/bar.html"/>
<title>bar</title>

文本跨行匹配

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

(find-file "foo.xml")

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

<attachment>
  <resource src="files/foo.html"/>
  <title>foo</title>
</attachment>
<attachment>
  <resource src="files/bar.html"/>
  <title>bar</title>
</attachment>

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

(find-file "foo.xml")

(let ((x (buffer-string)))
  (string-match "<attachment>//(.+//)</attachment>" x)
  (princ/' (match-string 1 x)))

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

瞒天过海

实现文本的跨行匹配,并非不可行,但是需要比现在更多的 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" "<linebreak/>")
    (setq y (buffer-string)))
  (princ/' y))

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

(string-match "<attachment>//(.+//)</attachment>" y)
(princ/' (match-string 1 y))

便可提取 <attachment>...</attachment> 块,尽管提取的结果是错的。

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

(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 缓冲区内提取多个 <attachment>...</attaqchment> 块了。

为了演示 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" "<linebreak/>")
    (goto-char (point-min))
    (while t
      (if (re-search-forward "//(<attachment>.+?</attachment>//)" 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
  • 第四个参数 CUNT,可令 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 "//(<attachment>.+?</attachment>//)" 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" "<linebreak/>")
    (goto-char (point-min))
    (catch 'break
        (while t
          (if (re-search-forward "//(<attachment>.+?</attachment>//)" nil t 1)
              (let ((y (match-string 1)))
                (with-current-buffer output
                  (insert (concat y "/n"))))
            (throw 'break nil))))))

已基本解决本章开始所提出的问题了,因为 output 缓冲区内存放着从 foo.xml 文件里提取的两个 <attachment>...</attachment> 块,接下来,我只需将其中的 <linebreak/> 替换为 /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…

https://segmentfault.com/a/1190000039781880

赞(0)
未经允许不得转载:ITyet » Elisp 08:文本跨行提取问题文本跨行匹配瞒天过海递增搜索catch/throw恢复多行文本save-excursion结语
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址