文本解析

上一章:缓冲区和文件正则表达式

本章介绍 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 语法和函数。

下一章:变量

相关文章
相关标签/搜索