关于lisp:文本解析

10次阅读

共计 7496 个字符,预计需要花费 19 分钟才能阅读完成。

上一章:缓冲区和文件

本章介绍 Elisp 的变量、列表、符号、函数的递归以及一些更便捷的插入点挪动函数。这些常识将围绕一个理论问题的解决过程逐渐开展。

问题

假如有一份文档 foo.md,内容如下:

# Hello world!

上面是 C 语言的 Hello world 程序源文件 hello.c 的内容:```
#include <stdio.h>

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

... ... ...

其中有一部分内容被蕴含在以 \`\`\` 为结尾的两个文本行之间,如何应用 Elisp 编写一个程序,从 foo.md 中辨认它们?

解析器

foo.md 文件中每一行文本无非为以下三种状况之一。这三种状况是

  1. \`\`\` 结尾的文本行;
  2. 位于两个 \`\`\` 结尾的文本行之间的文本行;
  3. 非上述两种状况的文本行。

假如我要编写的程序是 simple-md-parser.el,只有它可能断定每一行文本的状况,并将断定后果记录下来,那么问题便得以解决。这个程序尽管简略,但着实称得上是解析器(Parser)。

变量和列表

simple-md-parser.el 对 foo.md 文件每一行文本的断定后果可存储于 Elisp 的列表类型的变量里。

在 Elisp 语言里,变量是绑定到某种类型的数据对象的符号,因此定义一个变量,就是将一个符号与一个数据对象绑定起来。例如,

(setq x "Hello world!")

将一个符号 x 与一个字符串类型的数据绑定起来,于是便定义了变量 x

列表变量,就是一个符号绑定到了列表类型的实例,后者可由 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 (source target)
  (setq n (length target))
  (if (< (length source) n)
      nil
    (string= (substring source 0 n) target)))

(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)

利用 反转 函数,便能够对上一节实现的 simple-md-parser.el 进一步欠缺了,这应该是本章的习题。

结语

本章所实现的 simple-md-parser.el 程序,仅仅是 Elisp 语言的初学者代码,有些繁琐,甚至也不够平安。在前面三章里,我对这些代码进行了肯定水平的简化和欠缺,并在这些工作里学习更多的 Elisp 语法和函数。

正文完
 0