《elisp 每日一练》——tree-sitter

《elisp 每日一练》——tree-sitter

Masimaro
2026-08-20 / 0 评论 / 0 阅读 / 正在检测是否收录...

前面一篇文章中,我们提到 Syntax-Table。它相当于一张巨大的表,通过字符串索引到它的属性信息。但是它仅仅停留在词法层面,例如下面的一段c代码

int main(){return 0;}

它仅仅能识别它们是 单词 单词 () {单词 数字 分号} 也就是给每个语法单元大了一个 Token,进行了简单的词法分析。但是它没有办法将这段代码解析成有意义的语法单元。导致我们没办法利用它进行高级的语法单元的解构。要实现类似IDE那种跳转或者语法高亮,需要借助第三方库,或者自己根据词法单元写一套语法分析器。

但是从Emacs 29开始,引入了 tree-sitter,针对所有编程语言提供了统一的接口,让我们能基于语法形成的AST,做一些高级的功能

背景知识

Emacs中使用 tree-sitter 围绕着 解析器语法节点查询 展开的。这么说有些抽象,我们换一种说法就是调用函数解析当前buffer中的内容形成抽象语法树,遍历树节点获得每个节点中的属性,例如它是一个function或者是一个变量等等。最后对于一些提取数据的功能我们不需要遍历整个树,而是可以采用类似SQL的查询语句来找到想要的内容。

解析器

在执行语法分析前,我们需要检查环境以及定义使用的解析器。这方面的函数有:

  • treesit-available-p : 检查当前Emacs是否支持 tree-sitter
  • treesit-language-available-p : 检查特定语言的语法定义是否已安装
  • treesit-parser-create : 为当前缓冲区创建一个新的解析器
  • treesit-parser-list : 获取当前缓冲区中的解析器列表
  • treesit-parser-buffer : 获取解析器关联的缓冲区
  • treesit-parser-language : 获取解析器所使用的语言

例如我们使用 (treesit-language-available-p 'elisp) 来判断当前elisp的解析器是否安装,对于一个裸Emacs来说,它会返回nil。

我们可以手动的通过 treesit-install-language-grammar 命令安装对应的语法解释器。或者我希望我的elisp配置帮我安装我需要的解析器这样即使我换了一台新的电脑,只要执行这些配置它也能帮我准备对应的环境。

我们可以先往列表 treesit-language-source-alist 中添加成员。它的每一项都是一个 (LANG . (URL REVISION SOURCE-DIR CC C++)) 结构,默认只需要填写LANG 和 URL,它们分别是对应的语言和解析器的github地址。后面几个参数: REVISION 表示要下载哪个版本,SOURCE-DIR 下载的源码在哪个目录、CC++ 表示编译时使用的C/C++编译器。针对elisp我们可以这样指定

(add-to-list 'treesit-language-source-alist)

之后我们可以通过下面的代码来一次安装 treesit-language-source-alist 中定义的所有的语言解析器

(mapc #'treesit-install-language-grammar (mapcar #'car treesit-language-source-alist))

如果你使用的操作系统是Windows,因为Windows没有原生的gcc,我们可以使用MinGW-w64。如果你不想折腾这些,可以直接从GitHub上找到一些预编译项目例如 casouri/tree-sitter-module ,下载已经编译好的Windows版本,将它们拷贝到 ~/.emacs.d/tree-sitter/ 目录中。因为tree-sitter 解析器的名称一般为 libtree-sitter-<LANGUAGE>.so 所以在Windows上,如果我们要启动elisp的,需要将它改名为 libtree-sitter-elisp.dll。 如果你不想改名,我们可以设置往列表 treesit-load-name-override-list 中添加一条对应的语言和dll的映射。每条记录的格式为 (LANG LIB-NAME [BASE-NAME])。其中 LANG 是语言名称,而 LIB-NAME 是对应的dll名称,BASE_NAME 是库道出的函数名称。默认来讲,每个对应的dll都有一个可以调用的导出函数,命名规则为 tree_sitter_<LANGUAGE> 但是极少数可能出现名称不匹配的规则,所以我们可以添加一条记录。

(add-to-list 'treesit-load-name-override-list
             '(elisp "elisp" "tree_sitter_elisp"))

之后我们就可以为当前的elisp缓冲区创建一个新的解析器 (treesit-parser-create 'elisp)

语法节点

创建了语法解析器之后,我们就可以利用一些API函数来获取树上节点的属性了。例如可以使用 treesit-node-at 可以获取当前光标位置在树上的节点。

有了节点,我们可以获取节点属性或者进行节点的遍历,对应的函数有:

  • treesit-buffer-root-node 获取当前树结构的根节点
  • treesit-node-type 获取节点类型,例如function_definition或者 if_statement 等等
  • treesit-node-text 获取节点对应的源码文本
  • treesit-node-starttreesit-node-end 获取节点在缓冲区的起始或者结束位置
  • treesit-node-child 获取节点的第N个子节点
  • treesit-node-parent 获取节点的父节点
  • treesit-node-prev-siblingtreesit-node-next-sibling 获取节点的前一个或者后一个兄弟节点
  • treesit-node-check 检查节点的属性
  • treesit-node-p 检查对象是否是一个treesitter 节点
  • treesit-node-eq 比较两个节点是否代表树中的同一个节点

有了这些函数,我们简单实现的实现一个高亮当前光标所在语法单元的功能:

(defun my/mark-current-node()
  (interactive)
  (let* ((parser (treesit-parser-create 'elisp)) ;;创建解析器
     (node (treesit-node-at (point))) ;; 获取当前位置所在的节点
     (start (treesit-node-start node)) ;;获取节点对应文本的起始位置
     (end (treesit-node-end node))) ;;获取结尾位置
    ;; 在起点和节点间高亮一个 region
    (goto-char start)
    (push-mark end t t)))

查询

如果我们想要快速的扫描,提取出类似于类定义、类中的属性、方法等内容,依靠遍历整个树还是显得太繁琐了,对于这种查询的需求,我们可以使用 treesitter 提供的查询功能。

它提供了基于树和节点的查询功能:

  • treesit-search-forward: 从指定节点开始,向前(或向后) 搜索第一个匹配特定条件的节点
  • treesit-search-subtree : 在节点的子树中搜索匹配条件的后代节点
  • treesit-parent-until: 从当前节点向上遍历,直到找到满足条件的祖先节点
  • treesit-induce-sparse-tree: 根据条件在语法树上构建一棵稀疏树,用于快速概览代码结构

虽然上述函数提供了一些搜索节点的方法,但是仍然不够直观。就像数据库,虽然MySQL、或者 Oracle 底层采用的树的结构来提高查找速度,但是在实际操作数据库查找数据时没有人用树的操作来查找。treesitter 在处理诸如查找这一类需求时也提供了对应的高级查找接口:

  • treesit-query-capture: 利用特定的语法,查找符合条件的接口, 可以接收list列表或者字符串
  • treesit-query-string 直接对字符串进行解析和查询
  • treesit-query-validate 验证查询语法是否正确

想要使用 tree-query-capture ,需要先了解一下查询的语法。一个查询由一个或者多个模式组成,每个模式都是一个S-表达式,其结构类似于 (type (child) (child) ...)

  • type 表示要查找的节点类型如 function_definition 或者 if_statement
  • child 嵌套的子节点模式,用于进一步限定父节点的结构

例如我们要捕获函数定义所在节点可以这么写

(tree-query-capture node ;;查询开始的节点
            '((function_definition (function_name) @func-name)))

上述代码表示,我们捕获函数定义的节点,并将它的节点名称命名为 func-name

你可能觉得上述内容比较抽象,看不懂,没关系。实际上它就是treesitter结构中的一个变种,我们了解一段代码在treesitter眼中的样子,也就知道如何编写对应查询条件。在早期的学习使用过程中,我们可以使用命令, treesit-explore-mode,它会将我们当前buffer中在treesitter中解析的内容给列出来,我们只需要点击其中的某项,它就会将对应的代码进行高亮显示。这样我们就可以很容易找到某段代码在treesitter眼中的实际模样。或者开启 treesit-inspect-mode 它会显示当前光标所在位置的节点信息,帮助我们在一堆treesitter 信息中快速找到对应的位置

例如上述高亮当前光标所在语法单元的示例在treesiter眼中,它是这样的

(function_definition ( defun name: (symbol)
  parameters: (list ( ))
  (special_form ( interactive ))
  (special_form ( let*
   (list (
    (list ( (symbol)
     (list ( (symbol)
      (quote ' (symbol))
      ))
     ))
    (comment)
    (list ( (symbol)
     (list ( (symbol)
      (list ( (symbol) ))
      ))
     ))
    (comment)
    (list ( (symbol)
     (list ( (symbol) (symbol) ))
     ))
    (comment)
    (list ( (symbol)
     (list ( (symbol) (symbol) ))
     ))
    ))

所以根据上述内容,我们如果想获取提取定义的函数名,就可以找 function_definition 类型的节点。根据节点,如果我们想获取对应的函数定义,可以这么写查询条件

(treesit-query-capture root "(function_definition name: (symbol) @func_name)")

这段代码查询之后的返回值是 (func_name. node) 结构的列表。因为我们给定的名称就是 func_name。

后面还有一些高级的匹配模式和用法,这里就不介绍了。读者可以自行查阅 treesit-query-capture root 的文档。

练习

本节我们的练习基于上述内容,我们实现一个获取当前buffer中所有函数定义的功能。

我们尝试一下多使用一下相关的函数,以便加深印象。所以本次的实现思路是:

  • 函数开始之前先调用 treesit-parser-list 遍历一下当前buffer中加载的解析器
  • 然后调用 treesit-parser-language 获取当前解析器对应的语言
  • 如果所有的语言都没有elisp,那么就创建elisp语言的解析器
  • 接着通过 treesit-buffer-root-node 获取根节点
  • 然后通过 treesit-query-captur 从根节点往下找,找到所有函数的定义
  • 我们遍历返回的结果,取每项的第二个成员,它就是对应的节点对象
  • 调用 treesit-node-text 获取节点中文本,也就是函数的定义
  • 最后打印函数定义,或者存储到一个列表中

最终实现如下:

(defun my/get-all-function-define()
  (interactive)
  ;; 先判断当前是否创建了 elisp 的解析器
  (unless (cl-some (lambda(p)
             (eq (treesit-parser-language p) 'elisp))
           (treesit-parser-list))
    (treesit-parser-create 'elisp))
  (let* ((root (treesit-buffer-root-node))
     (captures (treesit-query-capture root "(function_definition name: (symbol) @func_name)"))
     (func-names '()))
    (if (not captures)
    (message "未找到对应的内容")
      (progn
    (dolist (capture captures)
      (let* ((node (cdr capture))
         (name (treesit-node-text node)))
        (message "找到函数定义:%s" name)
        (push name func-names)))))))

总结

本篇我们介绍了treesit 在Emacs中的一些用法,包括与解析器相关的操作、遍历节点、以及在此之上的简单的查询功能。查询还有很多高级的功能,各位读者如果感兴趣可以阅读一下对应的文档。

本篇介绍的函数如下:

  • treesit-available-p : 检查当前Emacs是否支持 tree-sitter
  • treesit-language-available-p : 检查特定语言的语法定义是否已安装
  • treesit-parser-create : 为当前缓冲区创建一个新的解析器
  • treesit-parser-list : 获取当前缓冲区中的解析器列表
  • treesit-parser-buffer : 获取解析器关联的缓冲区
  • treesit-parser-language : 获取解析器所使用的语言
  • treesit-buffer-root-node 获取当前树结构的根节点
  • treesit-node-type 获取节点类型,例如function_definition或者 if_statement 等等
  • treesit-node-text 获取节点对应的源码文本
  • treesit-node-starttreesit-node-end 获取节点在缓冲区的起始或者结束位置
  • treesit-node-child 获取节点的第N个子节点
  • treesit-node-parent 获取节点的父节点
  • treesit-node-prev-siblingtreesit-node-next-sibling 获取节点的前一个或者后一个兄弟节点
  • treesit-node-check 检查节点的属性
  • treesit-node-p 检查对象是否是一个treesitter 节点
  • treesit-node-eq 比较两个节点是否代表树中的同一个节点
  • treesit-query-capture: 利用特定的语法,查找符合条件的接口, 可以接收list列表或者字符串
  • treesit-query-string 直接对字符串进行解析和查询
  • treesit-query-validate 验证查询语法是否正确
0

评论 (0)

取消