TreeSit API详解

treesit 是 Emacs 内部对 tree-sitter 的模块实现。

Tree-sitter 是一个解析器生成工具和增量解析库。 它可以为源文件构建一个具体的语法树, 并在源文件被编辑时高效地更新语法树。 这种技术相对于 Emacs 以前基于正则表达式来实现的语法高亮功能, 性能上要快很多, 而且在复杂表达式场景下的语法高亮准确度要高很多。

今年早些时候我基于 treesit 开发了一个针对所有语言的括号补全和语法编辑插件 fingertip, 支持 Emacs 29 及以后的版本。

今天主要针对 treesit 的 API 做一些技术分享, 希望能够帮助大家理解 treesit 的功能。

https://manateelazycat.github.io/2023/09/02/treesit/

21 个赞

赞啊!刚巧日前我写了一篇关于个人的几个对treesitter应用的分享,当时放在知乎,但那里没人感兴趣。我的流水账是这样的:

  • docgen:我们可以得到一个函数的签名、return,从而糊一个docgen的功能。虽然没有langserver提供的准确,但比手敲强太多了!
  • ending:引号、括号、end这些,我们可以根据上下文按需补全。
  • folding:呃,目前nvim已经内置了。自己实现的话,遍历下树,然后计算每行的foldlevel。
  • fstr:之前有过一篇提过了
  • incremental selection:这个相当棒,当然nvim-treesitter也有提供。不过如果我们自己实现的话,能更如自己心愿地确定起始node是啥。比如,let a = 'a' .. 'b' ,光标在=右侧时,起始node为 'a'..'b',而在左侧是则选择整个statement。
  • text objects、operator:这个可实现的太多了;我比较喜欢的应用是“同辈方法间的跳转”
  • breadcrumbs:类.方法;在浏览超过一屏的函数内挺有用的,还可以放到winbar、statusline中,我是按需显示
  • 排序 import/require: lua没有 isort,但我们可以找到相关的require statement,然后排序
  • 快速import:既然我们可以找到第一个require/import statement,那么我们也能解决“快速import”的痛点,而不用在本buffer内跳来跳去:a) vim.ui.input 输入想要的包,然后在追加一行;b) 鉴于 a没有补全,我们可以开个floatwin+lua buffer c)何必开floatwin,split下然后定位下光标。d) 发挥想象力!

treesitter的query我至今都不知道怎么应用,但大部分我看到的插件有对它的使用。我觉得直接拿到node然后.parent/child/sibling()这样比写query更可控、高效,由于对query的无知,不知道我的这个认知对不对。

我目前主用nvim,经验、观点也主要来自于对它的使用过程,希望大家不要嫌弃我呀。

1 个赞

惭愧,我都没看完这篇详解

上面的这段代码主要是利用 treesit-query-range 这个 API, 根据用户传入的 query 规则来过滤当前 Buffer 所有的节点, 比如上面代码第二段, 主要的作用是找出代码中所有函数的节点, 再结合 treesit-node-text 获取所有函数的名称。

这么看来,对于专注于当前node的操作,其实不太需要用query来匹配。

fingertip 主要是实现括号补全、 语法级别的kill操作(比如删除括号内的内容)、 括号跳转等跟括号补全相关的操作。

光标移动, 我觉得还是要用正则来实现, 之前实现过一版 tree-sitter 的原理的, 感觉没有正则左右跳转好用, 因为我们平常写代码的时候, 大部分并不是语法完备的, 如果依赖 tree-sitter 分析, 很多时候没法找到兄弟节点和父节点, 也没法移动。

在Emacs这边, 排序 import 或者跳转 import 其实 LSP 做会更好一点。

treesitter 的 Query 操作可以认为是针对节点的全局搜索工作, 只是从文本正则搜索转换到节点搜索, 一般做侧边栏或者搜索所有函数或特点节点比较方便, 比自己 node 一层一层的找要快很多。

3 个赞

谢谢回复!(我有意没有急着回复,怕占用太多前排)

我理解了query功能应用场景及优势。

对于非正常输入,treesitter给到的语法树有点不好预测,这点我在尝试写括号补全时有体会到。

在Emacs这边, 排序 import 或者跳转 import 其实 LSP 做会更好一点。

我不确定这个“lsp做”具体是指lsp client还是lang server?如果是在lang server,那可不是个小任务,无论是自己对lang server改着玩还是后续的提pr合到上游。我之所以会考虑lsp client,是因为我有见到clangd提供查询AST的功能通过lsp。也有见过在treesitter不能正确解析c文件,转而利用这个接口来做面包屑的。

你可以分享出来知乎的链接,我们也可以学习和关注,先感谢你的工作。

哇,谢谢你的好意。不过不用了吧,我已经把主要内容加在那条回复里了。

1 个赞

括号补全插件可以参考我写的 fingertip, 核心技术就是要准确的知道当前光标是否在字符串或注释区域内, 我以前基于Emacs的 parse-partial-sexp 函数写过 awesome-pair, 其实工作的很好, 就是代码有点复杂。 后面 Emacs 的 Tree-Sitter 支持好了, 我写过好几版 Tree-Sitter 的语法补全, 发现光靠Tree-Sitter是无法100%准确检测光标所处位置, 所以在fingertip版本中, 我结合了Tree-Sitter和Emacs内置的parse-partial-sexp函数来实现。

我说的这个是在 lsp server 做, 排序 import 现在是一个标准协议, 一般是在 lsp code action 协议中实现的, 在客户端执行 Code Action, lsp server 会自动排序 import.

跳转 import 的功能我觉得 lsp server 做会更高效, 因为 lsp server 是实时对正在修改的文件做增量式 AST, 既然 lsp server 可以做 import 自动排序, 找到特定的 Import 是非常轻松的, 当然这一点难以加的是还不是 lsp 协议规范, 可以绕过去的方法是, 直接用 lsp 语法高亮的协议, 通过 lsp server 返回的前面token的位置来结合 treesitter 做, 应该会比 treesitter 逐行扫描的性能要强一点。

1 个赞

可不是吗,我才读到 lsp spec 有这样的定义

/**
	 * Base kind for an organize imports source action:
	 * `source.organizeImports`.
	 */
	export const SourceOrganizeImports: CodeActionKind =
		'source.organizeImports';

找到特定的 Import

嚯,貌似lsp还真有相关接口:textDocument/moniker。似是而非

treesitter 逐行扫描

具体实现的话,我觉得可以先拿到root然后遍历下它的直接子节点找到import就可以确定位置了。

周末有的玩了,谢谢启发啊

1 个赞

moniker接口我一直没有看懂有啥用,哈哈哈哈

有没有可能实现 injection?就是跨语言的代码高亮。

比如把 js 的字符串按照 html 的 treesit 语法进行高亮。或者把 python/java/php 里的字符串 sql 按照 sql 的 treesit 语法高亮。

理论上是有可能的, 就是针对特定的区域让其他语言解析器分析一遍, 双重或多重渲染。

但是目前状态还不行。

现在集成的 treesitter API 已经包含了这个功能,具体到每个 language mode 有没有支持就是另一回事了

elixir-ts-mode 算是一个

为啥29版本的treesit显示python的子模块跟方法颜色一样,elisp-tree-sitter就能区分

自己写 treesit-font-lock-rules

如果要搜索匹配类型的节点, 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 节点开始的位置。