=> 上一篇:在 Emacs 緩衝區裏行走的姿勢
前言
現在,我們嘗試用 Elisp 編程來解一道應用題。這道應用題對我而言,頗為重要,對你而言,可作學習 Elisp 編程一例。假設 Emacs 的當前緩衝區內存在一些形如以下內容的片段:
@ 這是一段 C 代碼 #
int foo(void) {
return 42;
}
@
同時,當前緩衝區內也有一些其他內容,但我們無需關心。現在,光標是落在上述片段內的,例如落在數字 42 的 4 上。我們看到的現象是如此,但是能否通過 Elisp 程序感知光標正處於這樣的區域內呢?
為了讓問題更明確一些,可將上述片段抽象為以下形式:
@ 片段名稱 #
片段內容
@
上述形式中,片段名稱不會包含 # 字符,片段內容中也不存在任何一行文字只含有字符 @ 的情況。於是,我們的問題便可以明確為,當光標處於片段內容區域,此時能否通過 Elisp 感知光標處於上述形式的片段內呢?為了便於描述,我們將上述抽象的片段形式稱為 Orez 形式……開始明目張膽夾帶私貨。
我們可以從光標當前位置出發,向後(向緩衝區首部方向)遍歷緩衝區,並探測何時遇到以 @ 開頭且以 # 結尾的一行文字,並且也向前(向緩衝區尾部方向)遍歷緩衝區,並探測何時遇到只包含 @ 的一行文字,若這兩個方向的探測皆有所得,便可判定光標正處於 Orez 形式區域。
上述算法並不困難,關鍵在於,如何判斷一行文字是否含有 @ 開頭且以 # 結尾,以及是否只包含 @。這兩個關鍵問題,我們可基於 Emacs 提供的字符串匹配函數予以解決。
正則表達式
正則表達式,是一種微型語言,可用於描述文字模式——文字的「形狀」。例如,一段文字,我們知道它是以 @ 開頭且以 # 結尾,且除首尾外,其他文字皆非 #,對於這種形式的文字,用正則表達式可表述為 ^@[^#]+#$。倘若你從未了解過正則表達式,應該會覺得這是藴含某種神秘力量的咒語。事實上,只要略加解釋,你便會明白一切都很簡單。
^表示一段文字的首部。[^#]表示一個字符,它不是#。[^#]+表示存在一個或多個非#字符。#就是字符#。$表示一段文字的尾部。
也可以讓上述正則表達式所表達的文字模式更為寬泛一些,例如 ^[ \t]*@[^#]+#[ \t]*$,其中 [ \t] 表示一個字符,它可以是空格,也可以是製表符(即使用 Tab 鍵輸入的字符),而 [ \t]* 則表示存在 0 個或 1 個或更多個字符,它們或為空格,或為製表符。
倘若某段文字符合某個正則表達式所表達的文字模式,便稱該正則表達式匹配該段文字。我們可以用 Emacs 提供的 string-match 做一些正則表達式匹配試驗。例如
(let ((x "@ i am foo #"))
(if (string-match "^@[^#]+#$" x)
(message "hit!")
(message "failed!"))) ;; 會輸出 hit!
再例如
(let ((x " @ i am foo #"))
(if (string-match "^@[^#]+#$" x)
(message "hit!")
(message "failed!"))) ;; 會輸出 failed!
再例如
(let ((x " @ i am foo #"))
(if (string-match "^[ \t]*@[^#]+#$" x)
(message "hit!")
(message "failed!"))) ;; 會輸出 hit!
凡是能讓 string-match 的求值結果為真,即為 t 的正則表達式和字符串,稱二者匹配。Emacs 所支持的正則表達式,有一個功能是允許我們從它所匹配的字符串中捕獲一些文字。例如,捕獲上述最後一個示例中 x 的 i am foo 部分,只需將與之匹配的正則表達式修改為
^[ \t]*@[ \t]*\\([^#]+\\)[ \t]*#$
其中 \\( 和 \\) 表示可捕獲它們所包圍的部分,即 [^#]+。捕獲結果可通過 match-string 獲取,例如
(let ((x " @ i am foo #"))
(if (string-match "^[ \t]*@\\([^#]+\\)#$" x)
(message "%s" (string-trim (match-string 1 x)))
(message "failed!"))) ;; 會輸出 i am foo
match-string 的第 1 個參數表示獲取第幾個捕獲,由於上述代碼中只有一處捕獲,故該參數為 1。string-trim 函數用於消除字符串前導與末尾空白字符。
也許你已經感受到了正則表達式的強大,它能對字符串實現模糊匹配,可是你應該也能感受到它的弊端,一旦要匹配的文本較為複雜,為其所寫的正則表達式很快你便難解其意了,亦即複雜的正則表達式幾乎不具備可維護性。
rx 記法
為了讓正則表達式具備可維護性,Emacs 提供了 rx 記法,亦即你可以通過 rx 表達式構造正則表達式。例如
(rx line-start (zero-or-more (any " \t"))
"@"
(one-or-more (not "#"))
"#"
(zero-or-more (any " \t")) line-end)
其求值結果為
"^[ \t]*@[^#]+#[ \t]*$"
也可以用 rx-let 表達式,定義一些局部變量,將其作為一些正則表達式的「簡寫」,例如以下代碼與上文的 rx 表達式等效。
(rx-let ((padding (zero-or-more (any " \t")))
(name-area (one-or-more (not "#"))))
(rx line-start padding "@" name-area "#" padding line-end))
注意,在 rx 記法中,使用局部變量作為正則表達式記號,只能用 rx-let,而不能用 let。
若需要構造帶有捕獲的正則表達式,在 rx 記法可使用 group。例如
(rx-let ((padding (zero-or-more (any " \t")))
(name-area (one-or-more (not "#"))))
(rx line-start padding "@" (group name-area) "#" padding line-end))
求值結果為
"^[ \t]*@\\([^#]+\\)#[ \t]*$"
雖然正則表達式要比 rx 記法更簡約,但是 rx 記法更容易讓我們理解正則表達式的結構,故而以後我們儘量在 Elisp 中使用 rx 記法,而非正則表達式。以下是 rx 記法的應用示例:
(let ((x " @ i am foo #")
(re (rx-let ((padding (zero-or-more (any " \t")))
(name-area (one-or-more (not "#"))))
(rx line-start padding "@" (group name-area) "#" line-end))))
(if (string-match re x)
(message "%s" (match-string 1 x))
(message "failed!"))) ;; 會輸出 i am foo
感知
希望你還沒有忘記我們的任務,從當前緩衝區的光標所在位置向後探測,尋找正則表達式 ^@[^#]+#$ 可匹配的一行文字,此事現在已無任何難點,函數 orez-area-beginning 可以獲得 Orez 形式區域的起點,若光標並未在 Orez 形式區域內部,則該函數的結果為 nil。
(defun orez-area-beginning ()
(let (re line)
(setq re (rx line-start "@"
(one-or-more (not "#"))
"#" line-end))
(catch 'break
(while t
(setq line (buffer-substring-no-properties (pos-bol) (pos-eol)))
(if (string-match re line)
(throw 'break (point))
(progn
(when (<= (point) (point-min))
(throw 'break nil))
(forward-line -1))))
nil)))
為了便於你理解上述代碼,我將其翻譯成了以下 C 語言偽代碼:
int orez_area_beginning(void) {
Regex re = 由 rx 記法構造的正則表達式;
while (1) {
String line = 當前的一行文字;
if (re 與 line 匹配) {
return point();
} else {
if (point() <= point_min()) {
return -1; /* 返回無效位置,表示探測失敗 */
}
forward_line(-1); /* 後退一行 */
}
}
return -1; /* 返回無效位置,表示探測失敗 */
}
向前探測過程,要比向後探測略微簡單一些,下面我直接以 orez-area-end 函數實現該過程,且不再以 C 偽代碼予以註釋。
(defun orez-area-end ()
(let (re line)
(setq re (rx line-start "@" line-end))
(catch 'break
(while t
(setq line (buffer-substring-no-properties (pos-bol) (pos-eol)))
(if (string-match re line)
(throw 'break (point))
(progn
(when (>= (point) (point-max))
(throw 'break nil))
(forward-line))))
nil)))
基於 orez-area-beginning 和 orez-area-end 的結果便可確定光標是否落在 Orez 形式區域。
(defun orez-area? ()
(if (and (orez-area-beginning) (orez-area-end))
t
nil))
上述代碼使用了布爾運算中的「與」運算 and。Elisp 的布爾運算還有「或」運算 or 以及前文在構造 rx 記法時用過的「非」運算 not。基於這三種運算,可以構造複雜的邏輯表達式。
bobp 和 eobp
orez-area-beginning 和 orez-area-end 的定義中,皆在 while 表達式中判斷光標是否已抵達緩衝區首部和尾部,即
(<= (point) (point-min))
和
(>= (point) (point-max))
實際上,Emacs 為上述這兩種情況的判斷提供了函數 bobp 和 eobp,故而可用 (bobp) 和 (eobp) 分別代替上述表達式。故而將 orez-area-beginning 和 orez-area-end 重新定義為
(defun orez-area-beginning ()
(let (re line)
(setq re (rx line-start "@"
(one-or-more (not "#"))
"#" line-end))
(catch 'break
(while (not (bobp))
(setq line (buffer-substring-no-properties (pos-bol) (pos-eol)))
(if (string-match re line)
(throw 'break (point))
(forward-line -1)))
nil)))
(defun orez-area-end ()
(let (re line)
(setq re (rx line-start "@" line-end))
(catch 'break
(while (not (eobp))
(setq line (buffer-substring-no-properties (pos-bol) (pos-eol)))
(if (string-match re line)
(throw 'break (point))
(forward-line)))
nil)))
現場保存
若光標在 Orez 形式區域,而你也真的試着用過 in-orez-area? 函數,便會發現,Emacs 對該函數求值後,光標會被移動到 Orez 形式區域的末尾。原因是 orez-area-beginning 和 orez-area-end 函數使用了逐行移動光標函數 forward-line。若想在應用 in-orez-area? 之後能將光標復原,你可以先用一個局部變量保存光標位置,時候再將光標移至該位置,例如
(defun in-orez-area? ()
(let ((x (point)))
(if (and (orez-area-beginning) (orez-area-end))
(progn
(goto-char x)
t)
(progn
(goto-char x)
nil))))
Emacs 為了不讓你如此費心,它提供了 save-excursion 表達式,可完成等效工作,其用法如下
(defun in-orez-area? ()
(save-excursion
(if (and (orez-area-beginning) (orez-area-end))
t
nil)))
練習:若 Orez 形式更為複雜,例如片段名稱可能跨越多行,行間以 \ 連接,例如
@ 這是可跨越 \
多行的片段名稱 #
片段內容
@
此時,你該如何實現 orez-area-beginning 函數呢?
總結
Orez 是我編寫的文學編程工具。所謂文學編程,即程序的文檔與代碼是混合態,即文檔片段和代碼片段彼此糾纏。Orez 可從文學程序裏抽取可編譯/解釋的完整代碼,也可將文學程序轉化為用於文檔排版的源文件,由 TeX 或類似的排版軟件生成程序文檔。
我之所以需要在 Emacs 裏識別 Orez 形式區域,是因為文學程序裏可能存在多種編程語言的代碼片段,Emacs 很難以統一的模式編輯它們。倘若能識別 Orez 區域,將這些代碼片段臨時提取到另一個窗口中的緩衝區,並開啓相應的編程語言模式,則 Emacs 便可作為文學編程所用的專業編輯器了。
現在完成這一目的所需的 Elisp 語法和 Emacs 函數,我已經基本掌握了,甚至這一目的也已經初步得以實現。你雖然沒有這一追求,但你已經具備了駕馭 Emacs 的基本能力了,剩下的只是思考你的追求並嘗試實現它們。
練習:你能在 Emacs 裏,用 Elisp 程序實現如下圖所示的效果嗎?所需的全部知識,你都是具備的。