Tree-sitter 查询介绍及应用

4,454 阅读6分钟

Tree-sitter查询允许你在语法树中搜索模式,就像regex那样,在文本中搜索。将其与一些Rust胶水结合起来,就可以编写简单的、自定义的linters。

Tree-sitter 语法树

下面是关于由tre-sitter生成的语法树的快速速成课程。由tre-sitter生成的语法树由S表达式表示。下面的Rust代码生成的S表达式为

fn main() {
    let x = 2;
}

将会是

(source_file
 (function_item
  name: (identifier)
  parameters: (parameters)
  body:
  (block
   (let_declaration
    pattern: (identifier)
    value: (integer_literal)))))

由tree-sitter生成的语法树还有其他一些很酷的特性:它们是无损语法树(或称具体语法树),这样的树可以完整地再生出原始源代码。请考虑对我们的例子进行以下补充。

 fn main() {
+    // a comment goes here
     let x = 2;
 }

tree-sitter语法树保留了注释,而典型的抽象语法树则不会。

 (source_file
  (function_item
   name: (identifier)
   parameters: (parameters)
   body:
   (block
+   (line_comment)
    (let_declaration
     pattern: (identifier)
     value: (integer_literal)))))

Tree-sitter查询

Tree-sitter提供了一个DSL来匹配CST。这些查询类似于我们的S-表达式语法树,这里是一个查询,用于匹配Rust CST中的所有行注释。

(line_comment)

; matches the following rust code
; // a comment goes here

很好,是吗?但不要相信我的话,在tree-sitter操场上试试吧。像这样键入一个查询

; the web playground requires you to specify a "capture"
; you will notice the capture and the nodes it captured
; turn blue
(line_comment) @capture

这是另一个匹配let ,将一个整数与一个标识符绑定的表达式。

(let_declaration
 pattern: (identifier)
 value: (integer_literal))

; matches:
; let foo = 2;

我们可以将节点捕获为变量

(let_declaration
 pattern: (identifier) @my-capture
 value: (integer_literal))

; matches:
; let foo = 2;

; captures:
; foo

并将某些谓词应用到捕获中

((let_declaration
  pattern: (identifier) @my-capture
  value: (integer_literal))
 (#eq? @my-capture "foo"))

; matches:
; let foo = 2;

; and not:
; let bar = 2;

#match? 谓词检查一个捕获是否与一个重码相匹配

((let_declaration
  pattern: (identifier) @my-capture
  value: (integer_literal))
 (#match? @my-capture "foo|bar"))

; matches both `foo` and `bar`:
; let foo = 2;
; let bar = 2;

表现出漠不关心的态度,就像一个坚忍不拔的程序员那样,用通配符模式

(let_declaration
 pattern: (identifier)
 value: (_))

; matches:
; let foo = "foo";
; let foo = 42;
; let foo = bar;

文档对tree-sitter查询DSL做了更多的说明,但我们现在知道的足够多了,可以编写我们的第一个lint。

给你写一个树状查询的提示

std::env 函数中的字符串是容易出错的

std::env::remove_var("RUST_BACKTACE");
//                             ^^^^ "TACE" instead of "TRACE"

我更喜欢这样

// somewhere in a module that is well spellchecked
static BACKTRACE: &str = "RUST_BACKTRACE";

// rest of the codebase
std::env::remove_var(BACKTRACE);

让我们写一个lint来找到使用字符串的std::env 函数。暂时不考虑这个提示的有效性,先试着写一个树状查询。作为参考,这样的一个函数调用。

remove_var("RUST_BACKTRACE")

产生以下的S表达式

(call_expression
  function: (identifier)
  arguments: (arguments (string_literal)))

我们肯定在寻找一个call_expression

(call_expression) @raise

; matches:
; std::env::remove_var(t);
; other_fn(x, y, z, w);

其函数名至少要与std::env::varstd::env::remove_var 相匹配(我知道,我知道,这不是最理想的重合词)。

((call_expression
  function: (_) @fn-name) @raise
 (#match? @fn-name "std::env::(var|remove_var)"))

; matches:
; std::env::var(t);
; std::env::remove_var(t;

让我们把std:: 的前缀变成可选的

((call_expression
  function: (_) @fn-name) @raise
 (#match? @fn-name "(std::|)env::(var|remove_var)"))

; matches:
; std::env::var(t)
; env::var(t)

并确保arguments 是一个字符串

((call_expression
  function: (_) @fn-name
  arguments: (arguments (string_literal)))
 (#match? @fn-name "(std::|)env::(var|remove_var)"))

; matches:
; std::env::var("PWD");
; env::var("HOME");

运行linter

我们总是可以把我们的查询插入到网络游乐场,但让我们更进一步。

cargo new --bin toy-lint

tree-sittertree-sitter-rust 添加到你的依赖项中。

# within Cargo.toml
[dependencies]
tree-sitter = "0.20"

[dependencies.tree-sitter-rust]
git = "https://github.com/tree-sitter/tree-sitter-rust"

让我们装入一些Rust代码来工作。作为对哥德尔(Godel?)的颂扬,为什么不加载我们的linter本身。

fn main() {
    let src = include_str!("main.rs");
}

大多数树状的API需要引用一个 Language结构,如果你还没有猜到,我们将与Rust一起工作。

use tree_sitter::Language;

let rust_lang: Language = tree_sitter_rust::language();

够了,让我们来解析一些Rust

use tree_sitter::Parser;

let mut parser = Parser::new();
parser.set_language(rust_lang).unwrap();

let parse_tree = parser.parse(&src, None).unwrap();

第二个参数 Parser::parse的第二个参数可能会让你感兴趣。Tree-sitter有一个功能,如果现有的解析树包含编辑内容,可以快速重新解析。如果你确实碰巧想要重新解析一个源文件,你可以传入旧的树。

// if you wish to reparse instead of parse
old_tree.edit(/* redacted */);

// generate shiny new reparsed tree
let new_tree = parser.parse(&src, Some(old_tree)).unwrap()

无论如何(哈!),现在我们有了一个解析树,我们可以检查它

println!("{}", parse_tree.root_node().to_sexp());

或者更好的是,在它上面运行一个查询:

use tree_sitter::Query;

let query = Query::new(
    rust_lang,
    r#"
    ((call_expression
      function: (_) @fn-name
      arguments: (arguments (string_literal))) @raise
     (#match? @fn-name "(std::|)env::(var|remove_var)"))
    "#
)
.unwrap();

A QueryCursor是tree-sitter维护状态的方式,因为我们在解析树上运行查询产生的匹配或捕获中进行迭代。观察一下。

use tree_sitter::QueryCursor;

let mut query_cursor = QueryCursor::new();
let all_matches = query_cursor.matches(
    &query,
    parse_tree.root_node(),
    src.as_bytes(),
);

我们首先把我们的查询传递给游标,然后是 "根节点",也就是 "从头开始 "的另一种说法,最后是源本身。如果你已经看过了C语言的API,你会注意到最后一个参数,即源(被称为 TextProvider),是不需要的。Rust绑定似乎需要这个参数来提供谓词功能,比如#match?#eq?

让我们试着对匹配的数据做些什么:

// get the index of the capture named "raise"
let raise_idx = query.capture_index_for_name("raise").unwrap();

for each_match in all_matches {
    // iterate over all captures called "raise"
    // ignore captures such as "fn-name"
    for capture in each_match
        .captures
        .iter()
        .filter(|c| c.idx == raise_idx)
    {
        let range = capture.node.range();
        let text = &src[range.start_byte..range.end_byte];
        let line = range.start_point.row;
        let col = range.start_point.column;
        println!(
            "[Line: {}, Col: {}] Offending source code: `{}`",
            line, col, text
        );
    }
}

最后,在你的源代码中添加以下一行,让linter捕捉一些东西

env::remove_var("RUST_BACKTRACE");

然后cargo run

λ cargo run
   Compiling toy-lint v0.1.0 (/redacted/path/to/toy-lint)
    Finished dev [unoptimized + debuginfo] target(s) in 0.74s
     Running `target/debug/toy-lint`
[Line: 40, Col: 4] Offending source code: `env::remove_var("RUST_BACKTRACE")`

谢谢你,Tree-sitter!

奖金

敏锐的读者会注意到,我避开了std::env::set_var 。因为set_var 是用两个参数调用的,一个是 "key",一个是 "value",与env::varenv::remove_var 不同。因此,它需要更多的杂耍。

((call_expression
  function: (_) @fn-name
  arguments: (arguments . (string_literal)? . (string_literal) .)) @raise
 (#match? @fn-name "(std::|)env::(var|remove_var|set_var)"))

这个查询中有趣的部分是卑微的. ,即操作符。锚有助于以某些方式约束子节点。在这种情况下,它确保我们正好匹配两个同胞的string_literal,或者正好匹配一个没有同胞的string_literal 。不幸的是,这个查询也匹配了下面这个无效的Rust代码。

// remove_var accepts only 1 arg!
std::env::remove_var("RUST_BACKTRACE", "1");

注意事项

从掌握查询DSL中获得的知识也可以应用于其他有树状语法的语言。在Ruby中,这个查询可以检测到不接受额外参数的to_json 方法。

((method
  name: (identifier) @fn
  !parameters)
 (#is? @fn "to_json"))

总而言之,查询DSL在降低编写语言工具的门槛方面做得很好。