Elisp 06:缓冲区变换

garfileo

前言:不知多久能学会 Elisp

上一章:文本匹配

在第一章「Hello world!」和第二章「文本解析」里已初步介绍了缓冲区的基本知识。使用 Elisp 语言编写文本处理程序时,充分利用缓冲区,似乎是着实是在发挥 Elisp 的一项长处。因而本章要思考和解决的一个现实问题是,缓冲区可以用来做什么。

文本变换

将文本由一种形式变换为另一种形式,在「物理」上,可体现为一个字符串变换为另一个字符串,也可体现为一个文件变换为另一个文件。这是其他编程语言里常见的想法,而 Elisp 语言提供了一个新的思想,文本变换可以体现为缓冲区变换。

为什么要进行文本变换呢?因为人类总希望用更少的语言去讲更多的话。

例如,假设有一份文件 foo.md,其内容为

# 今天要整理厨房

我在一份 Elisp 教程里提醒自己,今天一定要整理厨房……

现在我想将上述内容变换为

<h1>今天要整理厨房</h1>

<p>我在一份 Elisp 教程里提醒自己,今天一定要整理厨房……</p>

完成这样的变换,前面几章所述的 Elisp 语法和函数已经足够用了。

算法设计

要解决上一节所述的文本变换问题,首先需要设计一个有针对性的算法。这个算法自然是很简单的,简单到了任何一本以教授算法为宗旨的教科书都不愿涉及的程度。

假设 x 为 foo.md 文件里的任意一行文本,对于上一节提出的问题额演,它只可能属于以下三种情况之一:

  1. ^#+[[:blank:]]+.+$
  2. ^[[:blank:]]*$
  3. 不属于上述两种情况的情况。

还记得上一章所讲的正则表达式吗?上述第一种情况,就是以一个或多个 # 开头且 # 之后可以有一个或多个空格的文本行。第二种情况是空行。

只需基于上述三种情况,对 x 进行变换。第一种情况,将 x 变为 <hn>...</hn> 的形式,n# 的个数。第二种情况,将 x 变换为空字符串。第三种情况,则在 x 的开头和结尾增加 <p></p>。如此,问题便得以解决。下文将逐步实现这个算法。

文本变换函数

为一个具体的问题设计一个具体的算法,我认为,这相当于是要站在一个高处对问题的俯瞰。算法设计出来之后,在着手实现算法时,我建议这个过程应当自下而上进行。因为底层的逻辑是最简单的。

在实现「### foo」到「<h3>foo</h3>」的变换时,为了追求简单,我甚至可以假设已经将前者拆分为「###」和「foo」两个部分了,然后只需要根据前者包含的了多少个 #,便可以确定 <hn>...</hn> 里的 n 是多少了。于是,上一节里第一种情况的变换,其实现如下:

(defun section-n (level name)
  (let ((n (length level)))
    (format "<h%d>%s</h%d>" n name n)))

其中,Elisp 函数 length 函数在第二章里已经用过,它可以算出字符串包含多少个字符,也可以算出列表包含多少个元素。Elisp 函数 format 是第一次使用,该函数可以构造一个字符串模板,然后特定类型的变量或数据对象的求值结果填充到模板里,从而生成一个字符串。如果学过 C 语言,对这种构造字符串的方法一定不陌生,因为 C 语言里常用的 printf 便是类似的函数。

section-n 的用法示例如下:

(section-n "###" "#foo")

求值结果为字符串 "<h3>foo</h3>"。倘若不放心,就使用之前章节里定义的 princ\' 函数,将结果在终端里显现出来:

(princ\' (section-n "###" "#foo"))

这是最后一次如此罗嗦。

上一节的三种情况里,后两种情况对应的文本变换更为简单,下面直接给出,不再讲解了。

(defun empty-line (text) "")

(defun paragraph (text)
  (format "<p>%s</p>" text))

读一行,变换一行

现在,可以打开 foo.md 文件,将其内容读取到缓冲区了。所需代码,在第二章便已给出,亦即

(find-file "foo.md")

find-file 过程结束后,当前缓冲区的名字是 foo.md,其中存放的是 foo.md 文件的全部内容,且插入点位于缓冲区首部,亦即坐标为 1。

逐行读取缓冲区内容的过程,一开始在第二章我是使用递归函数实现的,后来在第四章里,将递归函数改写成了迭代形式:

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

(defun every-line ()
  (while (< (point) (point-max))
    (princ\' (current-line))
    (forward-line 1)))

要实现对当前缓冲区内容的变换,可将文本匹配和变换过程嵌入上述的 every-line 函数的定义里,但是我想做的更优雅一些。

首先,将文本匹配和变换过程定义为一个函数

(defun translate (text)
  (if (string-match "^\\(#+\\)[[:blank:]]+\\(.+\\)$" text)
      (section-n (match-string 1 text) (match-string 2 text))
    (if (string-match "^$" text)
        (empty-line text)
      (paragraph text))))

Elisp 并未提供类似其他编程语言里 if ... else if ... else 这种条件表达式,因此上述代码是基于嵌套的 if ... else ... 表达式实现了三种情况的文本匹配及变换。

不过,Elisp 提供了 cond 表达式,它的逻辑与 if ... else if ... else 等价,可用于消除 if ... else ... 表达式嵌套。cond 表达式的结构如下:

(cond
 (逻辑表达式 1
  程序分支 1)
 (逻辑表达式 2
  程序分支 2)
 (...
  ...))

基于 cond 表达式,可将 translate 函数重新定义为:

(defun translate (text)
  (cond
   ((string-match "^\\(#+\\)[[:blank:]]+\\(.+\\)$" text)
    (section-n (match-string 1 text) (match-string 2 text)))
   ((string-match "^$" text)
    (empty-line text))
   (t (paragraph text))))

然后在 every-line 函数里调用 translate 便可对缓冲区内容逐行予以变换,即

(defun every-line ()
  (while (< (point) (point-max))
    (translate (current-line))
    (forward-line 1)))

倘若在 every-line 函数的定义里,使用 princ\' 将文本变换结果逐行输出到终端,可以查看变换过程是否正确。例如

(defun every-line ()
  (while (< (point) (point-max))
    (princ\' (translate (current-line)))
    (forward-line 1)))

但是,如果我想将变换后的文本保存到另一个缓冲区里,该如何实现呢?

另一个缓冲区

首先,肯定是创建一个新的缓冲区,它可以叫 html,且可与符号 html-buffer 绑定,成为一个变量的值。我将这件事放在 foo.md 文件被打开之后进行,亦即

(find-file "foo.md")
(setq html-buffer (generate-new-buffer "html"))

然后在 every-line 函数里,将当前缓冲区切换为 other 缓冲区,插入变换后的文本,再将当前缓冲区切回,继续进行下一行文本的变换和保存。于是,every-line 函数定义里的迭代过程可描述为

(while (< (point) (point-max))
  (setq text (translate (current-line)))
  (setq md-buffer (current-buffer))
  (set-buffer html-buffer)
  (insert (concat text "\n"))
  (set-buffer md-buffer)
  (forward-line 1))

上述代码使用了 Elisp 函数 concat,它可以将多个字符串连接成一个字符串。

在上述代码里,当前缓冲区每次向 html-buffer 缓冲区切换之前,我已使用变量 textmd-buffer 已分别将变换后的文本以及当前缓冲区记了下来,故而在 html-buffer 为当前缓冲区时,能够插入 text 的值,且能通过 (set-buffer md-buffer) 将当前缓冲区切回。由于这样的缓冲区切换操作较为繁琐,因此 Elisp 提供了一个更方便的函数 with-current-buffer,可在维持当前缓冲区不变的情况下,将数据写入另一个给定的缓冲区。该函数的用法如下:

(with-current-buffer 缓冲区或缓冲区的名字
  一组表达式)

基于这个函数,上述迭代过程可改写为

(while (< (point) (point-max))
  (setq text (translate (current-line)))
  (with-current-buffer html-buffer
    (insert (concat text "\n")))
  (forward-line 1))

不过,上述代码里定义了一个全局变量 text,不够安全,可使用 let 表达式将其变为局部变量:

(let (text)
  (while (< (point) (point-max))
    (setq text (translate (current-line)))
    (with-current-buffer html-buffer
      (insert text)
      (insert "\n"))
    (forward-line 1))))

但是,不幸的是,上述代码里还有一个全局变量 html-buffer,它凭空就出现了,就像神迹一样。

真的有神迹吗?从函数的角度来看,这个神迹完全可以转化为一个参数,于是,就有了一个可将当前缓冲区内容逐行变换到另一个缓冲区的函数了,即

(defun every-line-in-current-buffer-to-other-buffer (target)
  (let (text)
    (while (< (point) (point-max))
      (setq text (translate (current-line)))
      (with-current-buffer target
        (insert (concat text "\n"))
      (forward-line 1))))

缓冲区变换

上一节末尾定义的那个函数,它的名字太长了。任何很长的名字,都可以通过修辞将其变得简短。修辞的基础是在宏观的角度上理解待修辞的对象。站在宏观的角度来看这个函数,无论它是怎样运作的,它的工作无非是将一个缓冲区里的东西变换到另一个缓冲区,那么可将这个过程修辞为缓冲区变换,用英文来写,可表示为 translate-buffer,无论它是将当前缓冲区内容变换到另一个缓冲区,还是将任意一个给定的缓冲区内容变换到另一个缓冲区,这样的过程皆可定义为

(defun translate-buffer (source target)
  (with-current-buffer source
    (let (text)
      (while (< (point) (point-max))
        (setq text (translate (current-line)))
        (with-current-buffer target
          (insert (concat text "\n"))
        (forward-line 1)))))

基于 translate-buffer,将缓冲区 foo.md 中的内容变换另一个缓冲区的完整示例可写为:

(find-file "foo.md")
(setq html-buffer (generate-new-buffer "html"))
(translate-buffer ((current-buffer) html-buffer))

基于 let 表达式,可以消除掉全局变量 html-buffer 并且可将程序进一步简化,例如

(let ((html-buffer (generate-new-buffer "html")))
  (translate-buffer (find-file "foo.md") html-buffer))

没错,(find-file "foo.md") 的求值结果是缓冲区,因此它可以作为 translate-buffer 的参数值。

将变换结果保存为文件

倘若在完成缓冲区变换后,想查看缓冲区 html-buffer 的内容,可以再使用一次 with-current-buffer 表达式,即

(let ((html-buffer (generate-new-buffer "html")))
  (translate-buffer (find-file "foo.md") html-buffer)
  (with-current-buffer html-buffer
    (princ\' (buffer-string))))

也可将 html-buffer 的内容保存为文件 foo.html,还记得第二章提到的 write-file 函数吗?但是,不推荐使用它,因为它是面向 Emacs 图形界面的,工作比较多,导致运行起来有些慢吞吞的。比它更快且更为底层的函数是 write-region,它可以通过第一个参数和第二个参数,将当前缓冲区的一个局部区域写入指定文件。倘若 write-region 的第一个参数为 nil,那么无论第二个参数值是什么,它会将当前缓冲区的全部内容写入指定文件。

以下代码实现了缓冲区变换和文件保存过程:

(let ((html-buffer (generate-new-buffer "html")))
  (translate-buffer (find-file "foo.md") html-buffer)
  (with-current-buffer html-buffer
    (write-region nil nil "foo.html")))

结语

缓冲区也许是 Elisp 语言里也许是最为重要的数据类型了。虽然 Elisp 没有 Scheme 语言的 call/cc,但是它有 with-current-buffer。我甚至隐约觉得,用 Elisp 语言编程,基于缓冲区类型,可以开辟一个其他编程语言所没有的范式,面向缓冲区编程。

在本章示例里,要编译的 Markdown 文件以及作为编译结果的 HTML 文件,它们都是硬编码到程序里的。下一章,我要让程序能够通过命令行参数传递文件的名字。

下一章:命令行界面

附录

可将 foo.md 变换为 foo.html 的完整代码如下:

(defun section-n (level name)
  (let ((n (length level)))
    (format "<h%d>%s</h%d>" n name n)))

(defun empty-line (text) "")

(defun paragraph (text)
  (format "<p>%s</p>" text))

(defun translate (text)
  (cond
   ((string-match "^\\(#+\\)[[:blank:]]+\\(.+\\)$" text)
    (section-n (match-string 1 text) (match-string 2 text)))
   ((string-match "^$" text)
    (empty-line text))
   (t (paragraph text))))

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

(defun translate-buffer (source target)
  (with-current-buffer source
    (let (text)
      (while (< (point) (point-max))
        (setq text (translate (current-line)))
        (with-current-buffer target
          (insert (concat text "\n"))
        (forward-line 1)))))

(let ((html-buffer (generate-new-buffer "html")))
  (translate-buffer (find-file input) html-buffer)
  (with-current-buffer html-buffer
    (write-region nil nil output)))
阅读 1.5k

5.9k 声望
0 粉丝
0 条评论
5.9k 声望
1.9k 粉丝
宣传栏