专栏名称: 奇舞精选
《奇舞精选》是由奇舞团维护的前端技术公众号。除周五外,每天向大家推荐一篇前端相关技术文章,每周五向大家推送汇总周刊内容。
目录
相关文章推荐
晚点Auto  ·  晚点对话何小鹏:我的造车血泪史 ·  21 小时前  
晚点Auto  ·  晚点对话何小鹏:我的造车血泪史 ·  21 小时前  
百车全说  ·  集合!刀客们~快来三刀的朋友圈玩 ·  昨天  
玩车教授  ·  教授说车 | 沉浸式体验方程豹 豹8! ·  4 天前  
51好读  ›  专栏  ›  奇舞精选

利用 Tree-sitter 进行语法树分析

奇舞精选  · 公众号  ·  · 2024-08-26 19:41

正文


在现代软件开发过程中,随着项目规模的不断增大,开发者面对的挑战也越来越多,我们需要借助开发工具帮助我们更好地理解整个项目,比如:

代码编辑器的代码高亮功能帮助开发者迅速区分语法元素,如变量、函数、关键字等,鼠标 hover 上去还可以快速查看变量属性和函数定义。

当我们在 Github 中查看源码时,Github 提供了 Navigating code 的能力,帮助我们快速在函数或变量的定义和引用之间跳转,甚至可以跨 Repo 进行。

当我们开发前端项目时,常常会使用 Prettier 等工具自动格式化代码,以确保团队项目代码风格的一致性。

上述的三种场景在底层实现上都依赖了语法树技术,其不光在代码编译技术中至关重要,同时在代码分析、代码处理等应用场景下也起到了核心作用。

如果你是一名前端开发,你应该知道我们可以用 @babel/parser 库来解析生成JavaScript 的 抽象语法树(Abstract Syntax Tree,AST),用于 JavaScript 的语言转译,将现代 JavaScript 转换成兼容老版本浏览器的代码。

我们今天要聊的 Tree-sitter 是一个通用的 具体语法树(Concrete Syntax Tree,CST,也可以叫 Parse Tree,即 PT)解析生成工具,最初被用于 Atom 文本编辑器,后续被用于越来越多的地方,比如上文提到的 GitHub Navigating Code 功能,它具有以下特点:

  • 通用 :支持绝大部分编程语言
  • 快速 :具备增量构建能力
  • 健壮 :即使存在语法错误,也可以输出有效的结果
  • 无依赖 :使用纯 C 编写,并提供 wasm 包可以在浏览器环境高效运行

AST vs CST

很多 Paser 在工作时,会首先生成 CST,包含所有的代码细节,再经过一些操作,去除冗余的语法信息,只保留语义相关的部分,将 CST 转化为 AST。所以一般来说 AST 相比 CST 的抽象程度更高,CST 生成更快。

不过 CST 优点也在于它存储了更加完整的语法细节,这使得它在 代码补全、代码格式化、语法高亮 等领域相比 AST 更为合适。

上手 Tree-sitter

刚刚为大家简单介绍了下背景,相信大家对 Tree-sitter 已经有了一些大致的了解,接下来我们正式聊一聊 Tree-sitter 的使用。

虽说 Tree-sitter 解析功能都是通过 C API 暴露的,但官方提供了一些主流语言的 Binding Library,所以我们可以使用 JavaScript、Python 等语言快速使用 Tree-sitter。

后续为了方便,我会使用 Node.js 版的 Tree-sitter —— node-tree-sitter 来为大家做进一步讲解。(Tree-sitter 有对应的 wasm 版本,所以在浏览器环境也可以使用,但是演示起来相对比较复杂,大家感兴趣可以去官网了解下)

基础概念

首先,进行包的安装:

npm install tree-sitter
npm install tree-sitter-javascript

接下来我们尝试解析一段简单的 JavaScript 代码片段:

const Parser = require('tree-sitter'); // Parser
const JavaScript = require('tree-sitter-javascript'); // Language

const parser = new Parser();
parser.setLanguage(JavaScript); // 为 Parser 设置 Language

const sourceCode = 'let x = 1; console.log(x);';
const tree = parser.parse(sourceCode); // Tree

const rootNode = tree.rootNode; // Node

在这段代码涵盖了 Tree-sitter 中的四个最重要的概念:

  • Parser : 是一个具有状态的对象,可以被赋予一种 Language 来对特定编程语言进行解析生成 Tree
  • Language :一个不透明的对象,内部定义了如何对特定编程语言进行解析
  • Tree :表示源码的语法树,容纳了 Node 实例,可以对 Tree 进行编辑并生成新的 Tree
  • Node :表示语法树的单个节点,存储着语法节点在源码中的开始结束位置,以及父子兄弟节点等关系信息。

解析生成语法树

我们继续,看看 Tree-sitter 生成的语法树结构是怎样的:

console.log(rootNode.toString());

// Input:
// 'let x = 1; console.log(x);'

// Output:
// (program 
//   (lexical_declaration 
//     (variable_declarator name: (identifier) value: (number))) 
//   (expression_statement
//     (call_expression
//       function: (member_expression object: (identifier) property: (property_identifier))
//       arguments: (arguments (identifier)))))

我们在代码中输出了语法树的字符串表示,是  S-expression 风格的。我们可以看到我们我们的整个程序的根节点为 program 节点,其中包含的两个语句在语法树被表示为 lexical_declaration (词法声明) 和 expression_statement (表达式语句)两个节点。其中更细的部分我们也可以看得很清楚。

什么是 S-expression?

S-expression(符号表达式)是一种简单的表示形式,最初用于 Lisp 编程语言,以表达程序和数据。它由括号包围的 List 组成,List 里的元素可以是 Atom(如数字或符号)或其他 List。这种语法具备简洁性和一致性,同时易于解释执行。S-expression 非常适合表示嵌套结构,使其在函数式编程和数据处理中广受欢迎。

例如,表达式 (+ 1 (* 2 3)) 描述了 1 + (2 * 3)

具名节点 和 匿名节点

细心的小伙伴可能已经发现了,我们上一节生成的语法树看上去和 AST 没有什么不同,没有体现出所谓的 CST。其实这是因为在上一节生成的语法树字符串表示中没有包含 Tree-sitter 中的「匿名节点」。

Tree-sitter 生成的是 CST,CST 会包含源码中所有的独立 Token,包括逗号、括号、分号之类的。这样详细的语法树对代码高亮等场景非常重要。但对于代码分析等场景更适合使用 AST,AST 删除了哪些不太重要的细节。Tree-sitter 将诸如关键字、逗号、分号等语法节点标记为「匿名节点」,在语法中被明确定义的部分标记为「具名节点」。

我们上代码:

console.log(rootNode.children[0]); // LexicalDeclarationNode
console.log(rootNode.children[0].children); // [SyntaxNode, VariableDeclaratorNode, SyntaxNode]
console.log(rootNode.children[0].children[2].text); // ';'
console.log(rootNode.children[0].children[2].isNamed); // false

可以观察到 Tree-sitter 确实生成的是 CST,其中包含了我们在字符串表示中没有看到的 SyntaxNode 。找到我们的分号节点,并通过 isNamed 方法,可以看到其为「匿名节点」。

如此一来,通过是否排除「匿名节点」,Tree-sitter 就可以同时支持 CST 和 AST。

节点字段

为了使语法节点更容易分析,Tree-sitter 为特定的节点赋予了唯一的字段。还以我们之间例子中的 VariableDeclaratorNode 为例。

const variableDeclaratorNode = rootNode.children[0].children[1]; // VariableDeclaratorNode
console.log(variableDeclaratorNode.text); // 'x = 1'
console.log(variableDeclaratorNode.fields); // ['nameNode', 'valueNode']
console.log(variableDeclaratorNode.nameNode.text); // 'x'
console.log(variableDeclaratorNode.valueNode.text); // '1'

可以看到,Tree-sitter 将特定的节点字段存储在了 fields 中,我们可以以此找到在 VariableDeclaratorNode 特定的字段,这些字段和我们之前的字符串表示之间是能够找到对应关系的。

如果存在错误 …

Tree-sitter 不仅对语法正确的代码片段生效,也能对存在语法错误的代码片段生效,提供精准信息,这是很多 AST Parser 无法做到的,请看如下代码:

const wrongCode = `var 42num = 42`
console.log(parser.parse(wrongCode).rootNode.toString());

// (program
//   (variable_declaration 
//     (ERROR (number))
//     (variable_declarator name: (identifier) value: (number))))

在这个 case 中,Tree-sitter 找到了代码中的错误。

利用 Query 进行模式匹配

Tree-sitter 不光能构建语法树,也能方便的通过一种声明式的语言对语法树进行模式匹配,匹配语法树中的特定节点。这让我们在对代码片段进行分析的操作变得简单高效。

下面我为大家简单介绍一下这种 query 语言的语法,篇幅有限,我只为大家讲解最基础的部分,详细内容大家可以到 Tree-sitter 的官网文档查看(https://tree-sitter.github.io/tree-sitter/using-parsers#pattern-matching-with-queries)。

在前文中,我们提到 Tree-sitter 的语法树字符串表示格式风格为 S-expression,这让我们可以更加直观的对语法树进行查询,因为这种 query 语言同样符合 S-expression 风格,我们最终的查询语句就是一组 S-expression。

这一节我们可以使用 Tree-sitter 官网的 Syntax Tree Playground (https://tree-sitter.github.io/tree-sitter/playground),进行匹配时会更加直观。

可以通过选中「Query」配置,来打开 Query 面板。

Tree-sitter 的 query 由一个或多个模式组成,每个模式都是 S-expression 风格的。

举个例子,我们使用以下模式,可以匹配所有子节点均为 number binary_expression

(binary_expression (number) (number))

也可以省略其中的一个子节点,匹配有一个子节点为 number binary_expression

(binary_expression (number))

我们可以对这些节点进行捕获,为我们的模式匹配到的节点进行命名:

(binary_expression (number) (number)) @num_binary_expression
(variable_declarator (identifier) @definition.variable)

效果可以非常直观的在 Syntax Tree Playground 中看到,不同的捕获节点使用了不同的颜色表示:

在第一行的模式匹配,我们对子节点均为 number binary_expression 节点进行了捕获,第二行,我们对 variable_declarator 下的 identifier 节点进行捕获。

另外,我们也可以使用前文提到的部分节点具有的特殊字段来进行匹配:

(variable_declarator value: (arrow_function) @definition.arrow_function)

如此,我们匹配到了进行变量声明时,被赋值的箭头函数。

我们在代码中自己试一下:

const Parser = require('tree-sitter');
const JavaScript = require('tree-sitter-javascript');

const { Query } = require('tree-sitter');

const parser = new Parser();
parser.setLanguage(JavaScript);

const sourceCode = `
let a = 1;
let b = () => {}
let c = 2 + 3 + 4
let d = '1' + 2 + true
let e = a + b + c`
;

const tree = parser.parse(sourceCode);

const patterns = `
(variable_declarator (arrow_function) @definition.arrow_function)
(variable_declarator (identifier) @definition.var)
`
;
const query = new Query(JavaScript, patterns);

const matches = query.matches(tree.rootNode);
console.log(matches);

// Output:
// let a = [
//   {
//     pattern: 1,
//     captures: [{
//       name: 'definition.var',
//       node: IdentifierNode { type: 'identifier', ... }
//     }]
//   },
//   ...
// ];

实战:构建一个简易的语法高亮系统

我们刚刚说了一堆比较枯燥的理论,但是用的上的理论才是好理论。我们这一节来尝试构建一个简易的语法高亮系统,虽说 Tree-sitter 其实提供了 tree-sitter-highlight 库来处理这种任务,也可以通过  CLI 工具 方便调用,但我觉得我们通过刚学到的知识自己实现一个也是很有价值的。

首先,我们新建项目并安装必要的依赖:

npm install tree-sitter tree-sitter-javascript

然后准备一个待语法高亮的示例代码 example.js ,这里你也可以用自己的代码试试:

// 定义一个变量
let greeting = 'Hello, World!';

/**
 * 打印问候语
 * @param {string} message - 要打印的消息
 */
function printGreeting(message) {
  console.log(message);
}

// 定义一个类
class Person {
  /**
   * 创建一个新的 Person 实例
   * @param {string} name - 名字
   * @param {number} age - 年龄
   */
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  /**
   * 打印人的信息
   */
  printInfo() {
    console.log(`Name: ${this.name}, Age: ${this.age}`);
  }
}

// 调用函数
printGreeting(greeting);

// 创建一个 Person 实例并调用方法
let person = new Person('Alice', 30);
person.printInfo();

然后我们创建 main.js 写我们的主流程:

const fs = require('fs');
const Parser = require('tree-sitter');
const JavaScript = require('tree-sitter-javascript');
const { Query } = require('tree-sitter');

const parser = new Parser();
parser.setLanguage(JavaScript);

const sourceCode = fs.readFileSync('example.js''utf8');

然后我们创建 queries.scm 来编写 query 语句,对代码中的一些关键语法结构做查询,针对匹配到的元素,我们后续就可以对其进行高亮处理了:

[
  "function"
  "const"
  "let"
  "var"
  "if"
  "else"
  "for"
  "while"
  "return"
  "class"
  "import"
  "export"
  "from"
  "new"
  "try"
  "catch"
  "finally"
  "throw"
  "switch"
  "case"
  "default"
  "break"
  "continue"
  "do"
  "typeof"
  "instanceof"
  "void"
  "delete"
  "in"
  "of"
  "with"
  "yield"
  "await"
  "async"
  "{"
  "${"
  "}"
  "("
  ")"
  "`"
] @keyword
(function_declaration name: (identifier) @function)
(call_expression function: (identifier) @function)
(method_definition name: (property_identifier) @function)
(call_expression 
  function: (member_expression
    object: (identifier) @variable
    property: (property_identifier) @function)
  arguments: (arguments))
(class_declaration name: (identifier) @class)
(new_expression constructor: (identifier) @class)
(variable_declarator name: (identifier) @variable)
(assignment_expression right: (identifier) @variable)
(call_expression arguments: (arguments (identifier) @variable))
(formal_parameters (identifier) @variable)
(member_expression property: (property_identifier) @variable)
(this) @variable
(string) @string
(template_string (string_fragment) @string)
(number) @number
(comment) @comment

然后在我们的 main.js 中读取 queries.scm 并创建 Query 对象,后续会使用其对语法树进行查询,另外我还创建了对应每一个语法结构的一个样式表,后面可以附在 HTML 中:

const query = new Query(JavaScript, fs.readFileSync('queries.scm''utf8'));

// 定义样式
const styles = {
  keyword'color: #d55fde;',
  variable'color: #ef596f;',
  function'color: #61afef;',
  class'color: #e5c07b;',
  string'color: #89ca78;',
  number'color: #d19a66;',
  comment'color: #7f848e; font-style: italic;',
};

然后,我们在 main.js 中写一下高亮逻辑:







请到「今天看啥」查看全文