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::var 或std::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-sitter 和tree-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::var 和env::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在降低编写语言工具的门槛方面做得很好。