Elisp 08:文本跨行提取

上一章:命令行程序界面html

在上一章的結語裏,我說這個教程是否會有第二部分,取決於我是否遇到了新的文本處理問題。結果很快如願以償。正則表達式

問題

下面是 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> 塊裏提取如下條目:segmentfault

<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> 塊失敗。致使失敗的緣由也很簡單,由於正則表達式 . 雖然能夠匹配任意一個字符,但它不包括換行符。code

瞞天過海

實現文本的跨行匹配,並不是不可行,可是須要比如今更多的 Elisp 的正則表達式知識 1。可是,我想說的是,對於上述問題,現有的 Elisp 知識其實也是足夠用,只須要轉換一下思路。xml

文本爲何是多行的?是由於在輸入文本的時候,每一行末尾由人或程序添加了換行符。假若能將這些換行符臨時替換爲一個很特殊的記號,那麼多行文本就變成了單行文本。在文本匹配和處理結束後,再將這個特殊記號再替換爲換行符,單行文本又復原爲多行文本。此爲瞞天過海之計。htm

將當前緩衝區內全部的換行符替換爲一個特殊記號,可基於第 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
  • 第四個參數 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 "\\(<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...
相關文章
相關標籤/搜索