treesit 是 Emacs 内部对 tree-sitter 的模块实现。
Tree-sitter 是一个解析器生成工具和增量解析库。 它可以为源文件构建一个具体的语法树, 并在源文件被编辑时高效地更新语法树。 这种技术相对于 Emacs 以前基于正则表达式来实现的语法高亮功能, 性能上要快很多, 而且在复杂表达式场景下的语法高亮准确度要高很多。
今年早些时候我基于 treesit 开发了一个针对所有语言的括号补全和语法编辑插件 fingertip, 支持 Emacs 29 及以后的版本。
今天主要针对 treesit 的 API 做一些技术分享, 希望能够帮助大家理解 treesit 的功能。
获取光标处的节点
(treesit-node-at (point))
其实基于 treesit 开发语法编辑插件的核心就是移动光标, 根据当前光标处或者光标周围的节点信息进行对应的代码处理。
所以, 首先我们可以根据 treesit-node-at
以及 (point)
函数来获取当前光标的节点。
节点的文本
(treesit-node-text (treesit-node-at (point)) t)
获取光标处的节点信息后, 我们就可以结合 treesit-node-text
来获取节点的字符串内容, 我一般会在最后加一个 t
参数, 用于移除 treesit-node-text
返回的字符样式信息只返回纯文本, 这样方便后续的逻辑处理。
节点的类型
(treesit-node-type (treesit-node-at (point)) t)
除了获取节点文本信息外, 我认为语法编辑插件用的最多的 API 就是 treesit-node-type
, 通过这个 API, 我们可以获得节点的类型信息, 通过检查节点类型信息, 我们就可以知道当前光标是位于代码、 字符串、 注释、 括号、 还是 HTML 标记等特定的语法区域, 相对于以前的正则表达式实现, 代码要简洁清晰很多。
节点的开始和结束范围
(treesit-node-start (treesit-node-at (point)))
(treesit-node-end (treesit-node-at (point)))
接着, 我们可以通过 treesit-node-start
和 treesit-node-end
这两个 API 来获取节点的开始和结束位置, 方便我们进一步处理, 比如结合 kill-region
来删除光标处的节点。
(kill-region
(treesit-node-start (treesit-node-at (point)))
(treesit-node-end (treesit-node-at (point))))
节点的父节点
(treesit-node-parent (treesit-node-at (point)))
我们分析代码语法树时需要向外来遍历整个语法树, 这时候 treesit-node-parent
就很有用, 它会返回当前节点的父节点, 你也可以循环调用这个 API, 一直找到你想要匹配的父节点。
搜索特定的父节点
(defun fingertip-find-parent-node-match (node-types)
(treesit-parent-until
(treesit-node-at (point))
(lambda (parent)
(member (treesit-node-type parent) node-types))))
上一小节学习到, 我们可以通过 treesit-node-parent
来获取父节点, 而语法编辑时, 我们期望找到特定类型的父节点, 如果自己实现, 我们会用循环的方法来实现。 Emacs 现在已经贴心的提供了 treesit-parent-until
, 通过这个 API 我们可以快速的搜索匹配特定类型的父节点, 比如, 可以使用下面代码来判断光标是否在参数列表
中:
(defun fingertip-in-argument-list-p ()
(fingertip-find-parent-node-match '("argument_list" "arguments" "tuple" "tuple_pattern" "pair" "dictionary" "list")))
节点的兄弟节点
(treesit-node-next-sibling (treesit-node-at (point)))
(treesit-node-prev-sibling (treesit-node-at (point)))
结合 treesit-node-next-sibling
和 treesit-node-prev-sibling
这两个 API, 可以获取节点的兄弟节点, 比如 for 循环括号或者函数参数括号内的兄弟节点。
通过兄弟节点 API 我们可以开发出比较有意思的功能, 比如把所有兄弟节点找出来以后快速分成多行, 或者执行 kill 功能时一次只删除一个兄弟节点, 而不是删除光标后面所有的字符串。
跳转到函数开头和结尾
Emacs 内置了 treesit-beginning-of-defun
和 treesit-end-of-defun
这两个 API, 一个是跳转到函数开头位置, 一个是跳转到函数结尾的位置。
获取根节点
(treesit-buffer-root-node)
我们还可以通过 treesit-buffer-root-node
来获取当前 Buffer 的根节点, 根节点获取后一般主要用于遍历 Buffer 中所有的节点信息, 比如查找 Buffer 中所有的函数名称, 下面几个小节会详细讲解怎么获取所有函数名称。
获取语言信息
(treesit-node-language (treesit-buffer-root-node))
treesit-node-language
结合上面讲的 treesit-buffer-root-node
可以获取当前 Buffer 的语言信息。
查找符合语法条件的节点
(defun find-orphan-get-match-nodes (query)
(ignore-errors
(mapcar #'(lambda (range)
(treesit-node-at (car range)))
(treesit-query-range
(treesit-node-language (treesit-buffer-root-node))
query))))
(append (find-orphan-get-match-nodes '((function_definition name: (symbol) @name)))
(find-orphan-get-match-nodes '((function_definition name: (identifier) @x)))
(find-orphan-get-match-nodes '((method_declaration name: (identifier) @x)))
(find-orphan-get-match-nodes '((function_declaration name: (identifier) @x))))
上面的这段代码主要是利用 treesit-query-range
这个 API, 根据用户传入的 query
规则来过滤当前 Buffer 所有的节点, 比如上面代码第二段, 主要的作用是找出代码中所有函数的节点, 再结合 treesit-node-text
获取所有函数的名称。
那么获取函数所有名称有什么作用呢? 可以实现类似 IDE 的侧边栏, 或者像我这样偏门的用法, 用 ripgrep 来搜索 Elisp 插件的函数名, 看看是否有些函数根本就没有使用, 相对于以前手工一个一个搜索函数名的操作, 效率提升上百倍。
而节点规则可以通过命令 treesit-inspect-node-at-point
来获取到。
获取符合条件的子节点
(setq argument-lis-node (fingertip-find-parent-node-match '("argument_list" "arguments" "tuple" "tuple_pattern" "pair" "dictionary" "list")))
(treesit-filter-child
argument-lis-node
(lambda (c)
(not (member (treesit-node-type c) '("(" ")")))))
上面代码中, 第一段的意思是获得到光标外围的参数列表节点 argument-lis-node
, 然后再结合 treesit-filter-child
把参数列表节点两边的括号节点去除, 这样就可以获得参数列表中所有参数节点的准确信息。
这样我们可以对所有参数节点进行标记和重构。
指定范围最靠近的节点
(treesit-node-on (line-beginning-position) (line-end-position))
treesit-node-on
可以返回最靠近指定范围的节点, 上面的代码的意思是搜索当前行最靠近的节点。
比如我会用这段代码搜索 Python 中 import 语句的精确范围在当前行的什么地方。
检测字符串和注释区域
Tree-Sitter 虽然技术更先进, 但是不是所有地方都完美的, 比如检测光标是否在字符串和注释区域时, 如果完全依赖 treesit-node-type
会导致很多边界情况无法检测到。
目前阶段, 我建议结合 treesit-node-type
和 parse-partial-sexp
这两个 API 一起进行判断, 才能准确的判断光标处是否在字符串和注释区域。
下面是我在 fingertip 写的字符串和注释区域检测的函数:
(defun fingertip-current-parse-state ()
(let ((point (point)))
(beginning-of-defun)
(when (equal point (point))
(beginning-of-line))
(parse-partial-sexp (min (point) point)
(max (point) point))))
(defun fingertip-in-string-p ()
(save-excursion
(or
;; If node type is 'string, point must at right of string open quote.
(ignore-errors
(let ((current-node (treesit-node-at (point))))
(and (fingertip-is-string-node-p current-node)
(> (point) (treesit-node-start current-node))
)))
(nth 3 (fingertip-current-parse-state))
(fingertip-before-string-close-quote-p))))
(defun fingertip-in-comment-p ()
(save-excursion
(or
;; Elisp parser has bug, node type is comment even current line is empty line.
(and (not (string-empty-p (string-trim (buffer-substring (line-beginning-position) (line-end-position)))))
(string= (fingertip-node-type-at-point) "comment"))
(nth 4 (fingertip-current-parse-state)))))
搜索匹配的节点
如果要搜索匹配类型的节点, treesit-search-forward-goto
就非常好用。
(treesit-search-forward-goto
(treesit-node-at (point))
(lambda (node)
(string= (treesit-node-type node) "string_end")))
上面的代码的意思是, 向右搜索匹配 string_end
的节点, 并跳到 string_end
节点结束的位置。
(treesit-search-forward-goto
(treesit-node-at (point))
(lambda (node)
(string= (treesit-node-type node) "string_start"))
t
t)
上面的代码的意思是, 向左搜索匹配 string_start
的节点, 并跳到 string_end
节点开始的位置。
treesit 是否可以用?
(and (featurep 'treesit) (treesit-available-p) (treesit-parser-list))
如果我们检测当前 Buffer 是否有可用的 Tree-Sitter 解析器时, 可以使用上面三个表达式:
- 首先检测用户是否开启了 treesit
- 再通过
treesit-available-p
和treesit-parser-list
来判断当前 Buffer 有无对应的语言解析器
安装 Tree-Sitter 解析器
(setq treesit-language-source-alist
'((bash . ("https://github.com/tree-sitter/tree-sitter-bash"))
(c . ("https://github.com/tree-sitter/tree-sitter-c"))
(cpp . ("https://github.com/tree-sitter/tree-sitter-cpp"))
(css . ("https://github.com/tree-sitter/tree-sitter-css"))
(cmake . ("https://github.com/uyha/tree-sitter-cmake"))
(csharp . ("https://github.com/tree-sitter/tree-sitter-c-sharp.git"))
(dockerfile . ("https://github.com/camdencheek/tree-sitter-dockerfile"))
(elisp . ("https://github.com/Wilfred/tree-sitter-elisp"))
(go . ("https://github.com/tree-sitter/tree-sitter-go"))
(gomod . ("https://github.com/camdencheek/tree-sitter-go-mod.git"))
(html . ("https://github.com/tree-sitter/tree-sitter-html"))
(java . ("https://github.com/tree-sitter/tree-sitter-java.git"))
(javascript . ("https://github.com/tree-sitter/tree-sitter-javascript"))
(json . ("https://github.com/tree-sitter/tree-sitter-json"))
(lua . ("https://github.com/Azganoth/tree-sitter-lua"))
(make . ("https://github.com/alemuller/tree-sitter-make"))
(markdown . ("https://github.com/MDeiml/tree-sitter-markdown" nil "tree-sitter-markdown/src"))
(ocaml . ("https://github.com/tree-sitter/tree-sitter-ocaml" nil "ocaml/src"))
(org . ("https://github.com/milisims/tree-sitter-org"))
(python . ("https://github.com/tree-sitter/tree-sitter-python"))
(php . ("https://github.com/tree-sitter/tree-sitter-php"))
(typescript . ("https://github.com/tree-sitter/tree-sitter-typescript" nil "typescript/src"))
(tsx . ("https://github.com/tree-sitter/tree-sitter-typescript" nil "tsx/src"))
(ruby . ("https://github.com/tree-sitter/tree-sitter-ruby"))
(rust . ("https://github.com/tree-sitter/tree-sitter-rust"))
(sql . ("https://github.com/m-novikov/tree-sitter-sql"))
(vue . ("https://github.com/merico-dev/tree-sitter-vue"))
(yaml . ("https://github.com/ikatyang/tree-sitter-yaml"))
(toml . ("https://github.com/tree-sitter/tree-sitter-toml"))
(zig . ("https://github.com/GrayJack/tree-sitter-zig"))))
我们通过设置 treesit-language-source-alist
选项来定义每种语言解析器的仓库地址, 当我们发现 treesit 提示解析器没找到的错误时, 可以通过命令 treesit-install-language-grammar
来安装对应的语言解析器。
开启 Tree-Sitter
Emacs 内置了不少解析器, 很多语言默认就打开了 Tree-Sitter 的支持, 但是一些新加的语言 Emacs 还没有配置, 这时候需要使用 treesit-parser-create
来手动开启 Tree-Sitter, 下面是我对一些比较新的语言的配置:
(add-hook 'markdown-mode-hook #'(lambda () (treesit-parser-create 'markdown)))
(add-hook 'zig-mode-hook #'(lambda () (treesit-parser-create 'zig)))
(add-hook 'emacs-lisp-mode-hook #'(lambda () (treesit-parser-create 'elisp)))
(add-hook 'ielm-mode-hook #'(lambda () (treesit-parser-create 'elisp)))
(add-hook 'json-mode-hook #'(lambda () (treesit-parser-create 'json)))
(add-hook 'go-mode-hook #'(lambda () (treesit-parser-create 'go)))
(add-hook 'java-mode-hook #'(lambda () (treesit-parser-create 'java)))
(add-hook 'java-ts-mode-hook #'(lambda () (treesit-parser-create 'java)))
(add-hook 'php-mode-hook #'(lambda () (treesit-parser-create 'php)))
(add-hook 'php-ts-mode-hook #'(lambda () (treesit-parser-create 'php)))
(add-hook 'web-mode-hook #'(lambda ()
(let ((file-name (buffer-file-name)))
(when file-name
(treesit-parser-create
(pcase (file-name-extension file-name)
("vue" 'vue)
("html" 'html)
("php" 'php))))
)))
Tree-Sitter 的优劣势
Tree-Sitter 目前在 Emacs 上实现的优势是, 性能比较好, 复杂表达式的准确性比较高。
从我实际编写语法编辑插件的经验看, 劣势主要有下面几个:
- 字符串和注释区域检测: 上面提到过, 只依赖
treesit-node-type
函数, 一些边界情况无法处理, 依然需要结合parse-partial-sexp
这个传统的 sexp API - 复杂字符串边界的处理: 比如 Python 的
'''
三引号语法, 光标在三个引号不同的地方, 返回的类型都不一样, 而实际编程中, 当三个引号依次键入时, 语法分析就会比较混乱 - 解析器质量参差不齐: 有些语言的解析器质量还不是那么好, 写一写会挂掉, 需要编辑语法插件做很多额外的工作
当然, 瑕不掩瑜, Tree-Sitter 总体来说还是很好用的, 以前很多用正则表达式糊出来的插件, 现在都可以用 Tree-Sitter 的 API 干净利落的重新实现。
最后
欢迎大家告诉我 treesit 的新玩法, 也欢迎大家给 fingertip 反馈问题和发送补丁。 ;)