文本解析

上一章:緩衝區和文件正則表達式

本章介紹 Elisp 的變量、列表、符號、函數的遞歸以及一些更便捷的插入點移動函數。這些知識將圍繞一個實際問題的解決過程逐步展開。編程

問題

假設有一份文檔 foo.md,內容以下:segmentfault

# Hello world!

下面是 C 語言的 Hello world 程序源文件 hello.c 的內容:

```
#include <stdio.h>

int main(void) {
    printf("Hello world!\n")
    return 0;
}
```

... ... ...

其中有一部份內容被包含在以 \`\`\` 爲開頭的兩個文本行之間,如何使用 Elisp 編寫一個程序,從 foo.md 中識別它們?安全

注:這個網站的 Markdown 解析器不夠健全,沒法理解字符轉義,致使沒法輸入三個連續的反引號。編程語言

解析器

foo.md 文件中每一行文本無非爲如下三種狀況之一。這三種狀況是函數

  1. \`\`\` 開頭的文本行;
  2. 位於兩個 \`\`\` 開頭的文本行之間的文本行;
  3. 非上述兩種狀況的文本行。

假設我要編寫的程序是 simple-md-parser.el,只要它可以斷定每一行文本的狀況,並將斷定結果記錄下來,那麼問題便得以解決。這個程序雖然簡單,但着實稱得上是解析器(Parser)。學習

變量和列表

simple-md-parser.el 對 foo.md 文件每一行文本的斷定結果可存儲於 Elisp 的列表類型的變量裏。網站

在 Elisp 語言裏,變量是綁定到某種類型的數據對象的符號,於是定義一個變量,就是將一個符號與一個數據對象綁定起來。例如,code

(setq x "Hello world!")

將一個符號 x 與一個字符串類型的數據綁定起來,因而便定義了變量 xorm

列表變量,就是一個符號綁定到了列表類型的實例,後者可由 list 函數建立,例如

(setq x (list 1 2 3 4 5))

將符號 x 綁定到列表對象 (1 2 3 4 5),因而便定義了一個列表變量 x

也能夠定義空列表變量,例如

(setq x '())

單引號 ' 在 Elisp 表示引用。Elisp 解釋器遇到它領起的符號或列表時,將後者自己做爲求值結果。這是 Lisp 語言特性之一。經過下面的例子,也許有助於理解這一特性:

(setq x (list 1 2 3 4 5))
(princ\' x)

(setq x '(list 1 2 3 4 5))
(princ\' x)

(setq x '(1 2 3 4 5))
(princ\' x)

上述程序的輸出爲

(1 2 3 4 5)
(list 1 2 3 4 5)
(1 2 3 4 5)

基於上述程序的輸出,可發現

(setq x '(list 1 2 3 4 5))

是將符號 x 綁定到了 (list 1 2 3 4 5) 這個列表,由於代碼中的 '(list 1 2 3 4 5) 阻止了 Elisp 解釋器對 (list 1 2 3 4 5) 進行求值,而是直接將改語句自己做爲求值結果。

還能夠看出如下兩行代碼等價:

(setq x (list 1 2 3 4 5))
(setq x '(1 2 3 4 5))

假若理解了上述內容,就不難理解爲什麼 '() 表示空列表了。

列表是單向的

Elisp 的列表是單向的,訪問列表首部元素,要比訪問其尾部元素容易得多。使用 car 函數能夠得到列表首部元素。例如

(setq x '(1 2 3 4 5))
(princ\' (car x))

輸出 1

cdr 函數能夠去掉列表首部元素,將剩餘部分做爲求值結果。例如

(princ\' (cdr '(1 2 3 4 5)))

輸出 (2 3 4 5)

若是要得到列表的尾部元素,就須要使用 cdr 不斷砍掉列表首部,直至列表剩下最後一個元素。好在解決本章開始所提出的問題,並不須要獲取列表尾部元素,此事事可暫且放下不表。

同訪問列表首部和尾部元素相似,向列表的尾部追加元素,要比在列表的首部追加元素困可貴多。Elisp 提供了 cons 函數,可將一個元素添加到列表的首部,而後返回新的列表。例如

(setq x '(1 2 3 4 5))
(setq x (cons 0 x))
(princ\' x)

輸出 (0 1 2 3 4 5)

求值

從如今開始,我就再也不說函數的返回結果了,而是說求值結果,雖然在大多數狀況下能夠將它們理解爲一回事,可是應當尊重 Lisp 語言的一些術語。

上一章含糊地說起,Elisp 程序由 Elisp 解釋器解釋執行。這個過程具體是怎樣進行的呢?這個過程本質上是由 Elisp 解釋器對程序裏的每一個表達式進行依序求值的過程構成。

表達式,也叫塊(Form)。在 Elisp 語言裏,變量的定義和使用,函數的定義和使用,皆爲表達式。即便一個數字,一個字符串或其餘某種類型的一個實例,也是表達式。

如下語句,每一行皆爲一個表達式:

42
"Hello world!"
(setq x 42)
(princ\' (buffer-string))

表達式能夠嵌套,嵌套結構一般是用成對的括號表達的,例如函數的定義即是典型的嵌套結構:

(defun princ\' (x)
  (princ x)
  (princ "\n"))

沒錯,Elisp 解釋器也會對函數的定義求值,求值結果是函數的名字。

在 Elisp 解釋器看來,任何表達式皆有其值,因此它對 Elisp 程序的解釋和執行,本質上就是對程序裏的全部表達式逐一進行求值。

須要注意的是,表達式 (princ\' "Hello world!) 的求值結果並不是是在終端裏輸出的 Hello world!。一個程序向終端裏寫入信息,本質上是這個程序向一個文件寫入信息。該工做是 Elisp 解釋器在求值過程當中的副業,它的主業是對錶達式進行求值,求值結果在 Elisp 解釋器以外不可見。

將一個符號綁定到一個數據對象或一組表達式,亦即定義一個變量或函數,在某種意義上也能夠視爲 Elisp 解釋器的副業。

符號

如今已經明白了,變量就是一個符號綁定到了某種類型的數據對象。事實上,函數也是相似的東西。在定義一個函數時,例如

(defun princ\' (x)
  (princ x)
  (princ "\n"))

不過是將一個符號 princ\' 綁定到了一組表達式罷了。定義一個函數,本質上是將一個符號綁定到一個匿名的函數上。這種匿名的函數,叫做 Lambda 表達式。假若不打算深究這些知識,也無妨,可是多少應該知道,Lambda 表達式是 Lisp 的精髓之一。

符號能夠用做變量和函數的名字,可是符號還有一個用途,就是用其自己。因爲單引號 ' 可以阻止 Elisp 對一個名字作任何解讀,只是將這個名字自己做爲求值結果,所以經過這種辦法,在程序裏能夠直接使用符號自己。

如今回到本章要解決的問題,還記得 foo.md 文件內的每一行文本只多是三種狀況之一嗎?我能夠用符號來表示這三種狀況:

'開頭是三個連續的反引號的文本行
'被包含在開頭是三個連續的反引號的兩個文本行之間的文本行
'開頭不是三個連續的反引號並且也沒有被開頭是三個連續的反引號的兩個文本行包含的文本行

不是開玩笑,由於 Elisp 真的支持這麼長的符號。可是,符號太長了,寫代碼也挺累的。簡化一下,上述三種狀況簡化且進一步細分爲如下四種狀況:

'代碼塊開始
'代碼塊
'代碼塊結束
'未知

爲何要將開頭是 \`\`\` 的兩個文本行之間所包含的文本區域稱爲「代碼塊」呢?由於 foo.md 文件裏的內容其實 Markdown 標記文本。

逐行遍歷緩衝區

彷佛一切都走在正確的道路上,到了考慮如何讀取 foo.md 文件的每一行文本的時候了。

上一章已指出,使用 find-file 函數可將指定文件讀取至緩衝區,而後使用 goto-char 函數將緩衝區內的插入點移動到指定位置。Elisp 提供了更大步幅的插入點移動函數 forward-line,該函數可將光標移動到當前所在的文本行的後面或前面的文本行的開頭。在緩衝區內,插入點所在的文本行,其首尾的座標可分別經過 line-beginning-positionline-end-position 得到,將它們做爲參數值傳遞於 buffer-substring,即可由後者獲取插入點所在的文本行的內容存入一個字符串對象並將其做爲求值結果。簡而言之,基於這幾個函數,可以以字符串對象的形式抓取緩衝區內任一行文本。例如,如下程序可抓取 foo.md 文件的第三行內容:

(find-file "foo.md")
(forward-line 2)
(princ\' (buffer-substring (line-beginning-position) (line-end-position)))

爲何將插入點移動到當前緩衝區的第三行是 (forward-line 2) 呢?這是由於,(find-file "foo.md") 打開文件後,插入點默認是在當前緩衝區第一行的行首。forward-line 函數的參數值是相對於插入點當前所在的文本行的相對偏移行數,從第一行向後移動 2 行,就是第三行了。forward-line 的參數值也能夠爲負數,可讓插入點移動到當前文本行以前的某行。

注意,爲了方便獲取插入點所在的文本行內容,我定義了 current-line 函數:

(defun current-line ()
  (buffer-substring (line-beginning-position) (line-end-position)))

假若定義一個函數,在該函數內部使用 (forward-line 1) 將插入點移動到下一行,而後再調用該函數自身,即可逐行讀取緩衝區內容。例如

(defun every-line ()
  (princ\' (current-line))
  (forward-line 1)
  (every-line))

(find-file "foo.md")
(every-line)

every-line 是遞歸函數。在一個函數的定義裏調用該函數自身,即遞歸函數。任何一種編程語言的解釋器在遇到遞歸函數時,會陷入對函數的定義反覆進行求值的過程裏。遞歸函數猶如汽車的發動機,它周而復始的運轉。至於汽車能夠將人從一個地方載到另外一個地方,不過是發動機的反作用罷了。

上述程序的確能逐行將當前緩衝區內容逐行顯示出來,可是程序最終會崩潰,臨終遺言是

Lisp nesting exceeds ‘max-lisp-eval-depth’

由於在 every-line 函數的定義中,未檢測插入點是否移動到緩衝區內容的盡頭,遞歸過程沒法終止,致使 Elisp 解釋器一直沒法獲得求值結果。可是,Elisp 解釋器對遞歸深度有限制,默認是 800 次,遞歸深度超過這個限度,解釋器便報錯而退出。

條件表達式

如何判斷插入點移動到了當前緩衝區的盡頭呢?還記得上一章用過的函數 point 嗎?它能夠給出插入點的當前座標。還記得 point-minpoint-max 嗎?它們能夠分別給出當前緩衝區的起止座標。所以,當 point 的結果與 point-max 的結果相等時,便意味着插入點到了當前緩衝區的盡頭。此刻,欠缺的知識是 Elisp 的條件表達式。

在 Elisp 語言裏,= 是一個函數,能夠用它判斷兩個數值是否相等。例如

(= (point) (point-max))

即可判斷當前插入點是否到了當前緩衝區的盡頭。上述邏輯表達式若成立,求值結果就是 t,不然求值結果是 nil。在 Elisp 語言裏,符號 t 表示真,nil 表示假。另外,nil 也等價於 '(),可是我以爲最好仍是不要混用。

如今差很少明白,爲何 Elisp 定義變量時,不像那些非 Lisp 語言那樣,用 =,而是用 setq。那些非 Lisp 語言的變量定義語法雖然簡潔一些,可是它們犧牲了 = 的意義,由於在判斷兩個數值是否相等時,每每使用 == 或其餘符號。不要在乎我說的,這只是個人幻想。

基於邏輯表達式的求值結果執行相應的程序分支,在 Elisp 語言裏可經過 if 表達式。if 表達式的形式以下:

(if 邏輯表達式
    程序分支 1
  程序分支 2)

Elisp 解釋器對邏輯表達式的求值結果假若爲真,便轉而解釋執行程序分支 1,不然解釋執行程序分支 2。基於 if 表達式,即可從新定義 every-line 函數了。

(defun every-line ()
  (if (= (point) (point-max))
      (princ "")
  (princ\' (current-line))
  (forward-line 1)
  (every-line)))

這個函數可以如我所願,在插入點抵達當前緩衝區盡頭時,終止遞歸過程,求值結果是輸出空字符串對象。可是,這個函數的語義卻有些混亂,在其定義裏,如下四行代碼,

(princ "")
    (princ\' (current-line))
    (forward-line 1)
    (every-line)))

其中哪些些應該算是「程序分支 1」,哪些算是「程序分支 2」呢?Elisp 的語法並非縮進型語法,所以上述第一行代碼雖然比後面三行代碼的縮進更深無助於它有別於後者。爲了讓語義明確,須要使用 progn 語法。progn 可將一組語句整合到一塊兒,將最後一條語句的求值結果做爲求值結果。例如,

(defun every-line ()
  (if (= (point) (point-max))
      (princ "")
    (progn 
      (princ\' (current-line))
      (forward-line 1)
      (every-line))))

如今,every-line 函數中的條件表達式的語義便很清晰了,不管邏輯表達式的結果是真仍是假,對應的程序分支是一個表達式,而不是多個。

字符串匹配

如今我有能力得到當前緩衝區裏任意一行文本了,可是爲了解決本章開始時提出的問題,還須要判斷一行文本是否以 \`\`\` 開頭。從每行文本的開頭截取 3 個字符,判斷它是否是 \`\`\`,這個小問題即可得以解決。事實上,Elisp 提供了完善的正則表達式,可用於匹配具備特定模式的文本,可是我如今不打算用它。由於正則表達式有些複雜,甚至須要爲它單獨開闢一章。

substring 函數可從一個字符串對象裏截取落入指定範圍內的子集並將其做爲求值結果。例如

(princ\' (substring "天地一指也,萬物一馬也" 0 4))

輸出

天地一指

判斷兩個字符串對象的內容是否相同,不能使用 =,應該使用 string=,切記。例如,

(string= "Hello" "Hello")

求值結果爲 t,而

(string= "Hello" "World")

求值結果爲 nil

如下代碼可判斷插入點所在的文本行的開頭是否爲 \`\`\`

(string= (substring (current-line) 0 3) "```")

即可判斷當前文本行是否以 \`\`\` 開頭,可是在實際狀況裏,這個表達式過於樂觀了,由於並非全部的文本行包含的字符個數多於 3 個,例如 foo.md 文件裏有不少空行,這些空行只包含一個字符 \n,即換行符。在上例中,若當前文本行包含的字符個數少於 3 個,substring 函數便會報錯:

Args out of range: "", 0, 3

而後 Elisp 解釋器終止工做,程序也就沒法再運行下去。若要解決這一問題,就須要特殊狀況特殊處理:

(setq x (current-line))
(setq y "```")
(setq n (length y))
(if (< (length x) n)
    nil
  (string= (substring x 0 n) y))

< 也是一個函數,用於比較兩個數值的大小。對於表達式 (< a b),若 a 小於 b,則求值結果爲 t,不然爲 nillength 函數可得到字符串對象的長度,即字符串對象包含的字符個數。

length 也可用於獲取列表的長度——列表包含的元素個數,例如

(length '(1 2 3))

求值結果爲 3。

實現解析器

只需綜合利用上述的所有知識,即可寫出 simple-md-parser.el。下面給出它的所有實現:

(defun princ\' (x)
  (princ x)
  (princ "\n"))

(defun current-line ()
  (buffer-substring (line-beginning-position) (line-end-position)))

(defun text-match (src dest)
  (setq n (length dest))
  (if (< (length src) n)
      nil
    (string= (substring src 0 n) dest)))

(defun every-line (result in-code-block)
  (if (= (point) (point-max))
      result
    (progn
      (if (text-match (current) "```")
          (progn
            (if in-code-block
                (progn
                  (setq result (cons '代碼塊結束 result))
                  (setq in-code-block nil))
              (progn
                (setq result (cons '代碼塊開始 result))
                (setq in-code-block t))))
        (progn
          (if in-code-block
              (setq result (cons '代碼塊 result))
            (setq result (cons '未知 result)))))
      (forward-line 1)
      (every-line result in-code-blcok))))

(find-file "foo.md")
(princ\' (every-line '() nil))

every-line 函數的定義乍看有些複雜,但實際上它所表達的邏輯很簡單。對於當前緩衝區內每一行文本,該函數首先判斷它是否以 \`\`\` 開頭,假若是,就須要進一步判斷該行文本的上一行是否在代碼塊裏,而後方能肯定當前以 \`\`\` 爲開頭的文本行是 '代碼塊開始,仍是'代碼塊結束。該函數的第二個參數即是用於記錄當前文本行的上一行文本是否屬於 '代碼塊。此外,該函數也展現了做爲求值結果的列表 result 如何從一個空列表對象開始在函數的遞歸過程當中逐步增加。

列表反轉

上一節實現的解析器,其中 every-line 函數的求值結果是一個列表對象。這個列表對象其實是倒着的,即 foo.md 文件的倒數第一行所屬的狀況對應於列表對象的第一個元素;第二行所屬狀況,對應於列表對象的第二個元素;依此類推。

假若想將這個列表反轉過來,須要再寫一個函數:

(defun reverse-list (x y)
  (if (null x)
      y
    (reverse-list (cdr x) (cons (car x) y))))

Elisp 函數 null 可用於判斷一個列表是否爲 '()

這個函數的用法如如下示例:

(setq x '(5 4 3 2 1))
(princ\' (reverse-list x '()))

輸出 (1 2 3 4 5)

利用 reverse-list 函數,即可以對上一節實現的 simple-md-parser.el 進一步完善了,這應該是本章的習題。

結語

本章所實現的 simple-md-parser.el 程序,僅僅是 Elisp 語言的初學者代碼,有些繁瑣,甚至也不夠安全。在後面三章裏,我對這些代碼進行了必定程度的簡化和完善,並在這些工做裏學習更多的 Elisp 語法和函數。

下一章:變量

相關文章
相關標籤/搜索