博客 / 詳情

返回

讓 Emacs 略帶感性

=> 上一篇:在 Emacs 緩衝區裏行走的姿勢

前言

現在,我們嘗試用 Elisp 編程來解一道應用題。這道應用題對我而言,頗為重要,對你而言,可作學習 Elisp 編程一例。假設 Emacs 的當前緩衝區內存在一些形如以下內容的片段:

@ 這是一段 C 代碼 #
int foo(void) {
    return 42;
}
@

同時,當前緩衝區內也有一些其他內容,但我們無需關心。現在,光標是落在上述片段內的,例如落在數字 424 上。我們看到的現象是如此,但是能否通過 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 所支持的正則表達式,有一個功能是允許我們從它所匹配的字符串中捕獲一些文字。例如,捕獲上述最後一個示例中 xi 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-beginningorez-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-beginningorez-area-end 的定義中,皆在 while 表達式中判斷光標是否已抵達緩衝區首部和尾部,即

(<= (point) (point-min))

(>= (point) (point-max))

實際上,Emacs 為上述這兩種情況的判斷提供了函數 bobpeobp,故而可用 (bobp)(eobp) 分別代替上述表達式。故而將 orez-area-beginningorez-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-beginningorez-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 程序實現如下圖所示的效果嗎?所需的全部知識,你都是具備的。

Orez 代碼片段編輯過程

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.