TreeSit API 详解
Emacs
2023-09-02 8297字

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-starttreesit-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-siblingtreesit-node-prev-sibling 这两个 API, 可以获取节点的兄弟节点, 比如 for 循环括号或者函数参数括号内的兄弟节点。

通过兄弟节点 API 我们可以开发出比较有意思的功能, 比如把所有兄弟节点找出来以后快速分成多行, 或者执行 kill 功能时一次只删除一个兄弟节点, 而不是删除光标后面所有的字符串。

跳转到函数开头和结尾

Emacs 内置了 treesit-beginning-of-defuntreesit-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-typeparse-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 是否可以用?

(and (featurep 'treesit) (treesit-available-p) (treesit-parser-list))

如果我们检测当前 Buffer 是否有可用的 Tree-Sitter 解析器时, 可以使用上面三个表达式:

  1. 首先检测用户是否开启了 treesit
  2. 再通过 treesit-available-ptreesit-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 上实现的优势是, 性能比较好, 复杂表达式的准确性比较高。

从我实际编写语法编辑插件的经验看, 劣势主要有下面几个:

  1. 字符串和注释区域检测: 上面提到过, 只依赖 treesit-node-type 函数, 一些边界情况无法处理, 依然需要结合 parse-partial-sexp 这个传统的 sexp API
  2. 复杂字符串边界的处理: 比如 Python 的 ''' 三引号语法, 光标在三个引号不同的地方, 返回的类型都不一样, 而实际编程中, 当三个引号依次键入时, 语法分析就会比较混乱
  3. 解析器质量参差不齐: 有些语言的解析器质量还不是那么好, 写一写会挂掉, 需要编辑语法插件做很多额外的工作

当然, 瑕不掩瑜, Tree-Sitter 总体来说还是很好用的, 以前很多用正则表达式糊出来的插件, 现在都可以用 Tree-Sitter 的 API 干净利落的重新实现。

最后

欢迎大家告诉我 treesit 的新玩法, 也欢迎大家给 fingertip 反馈问题和发送补丁。 ;)