上一篇把源码拆成了 Token 序列。一行 let mut x: i64 = 0; 经过词法分析后变成 LetMutIdent("x")ColonIdent("i64")AssignInt(0)Semi 这样一串扁平记号。但 Token 序列不包含结构信息——编译器还不知道这是一条变量声明、一条赋值还是一条返回语句。这一篇的任务是把 Token 流解析成结构化的语句节点:let 声明、赋值、return 和块。

语句的文法

先用一组产生式把 Sprout 当前支持的语句写下来。这里只处理语句层面,表达式的优先级和结合性留给第 04 篇。

1
2
3
4
5
6
7
8
9
10
11
12
13
stmt       → let_stmt
| assign_stmt
| return_stmt
| expr_stmt

let_stmt → "let" ["mut"] IDENT ":" type "=" expr ";"
assign_stmt→ IDENT "=" expr ";"
return_stmt→ "return" expr ";"
expr_stmt → expr ";"

block → "{" stmt* "}"

type → "i64" | "bool"

几个要点。第一,每种语句都以分号结尾,这是语法层面的硬性要求,不是可选项。第二,let_stmtassign_stmt 都以标识符出现,区分它们靠的是有没有前导关键字 let。第三,block 本身不以分号结尾——右花括号已经是明确的终止标记。第四,类型注解暂时只有 i64bool 两种,后续篇幅会逐步扩展。

这套文法属于 LL(1) 的子集:看当前 Token 就能决定走哪条规则。let 开头走 let_stmtreturn 开头走 return_stmt,标识符开头且下一个 Token 是 =assign_stmt,其他情况走 expr_stmt。不需要回溯。

递归下降:每条规则变成一个函数

递归下降解析(recursive descent parsing)的核心思想很直接:文法里每条产生式对应解析器里的一个函数。函数内部按产生式右侧的顺序依次消费 Token。遇到终结符就检查当前 Token 是否匹配并推进;遇到非终结符就调用对应的函数。

解析器需要维护的状态很少:一个 Token 列表和一个指向当前位置的游标。核心操作有三个:

  • peek():查看当前 Token 但不消费。
  • advance():消费当前 Token 并把游标前移。
  • expect(kind):检查当前 Token 是否为指定类型,如果是则消费,否则报错。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Parser {
tokens: Vec<Token>,
pos: usize,
}

impl Parser {
fn peek(&self) -> &TokenKind {
&self.tokens[self.pos].kind
}

fn advance(&mut self) -> Token {
let tok = self.tokens[self.pos].clone();
self.pos += 1;
tok
}

fn expect(&mut self, expected: TokenKind) -> Result<Token, ParseError> {
if *self.peek() == expected {
Ok(self.advance())
} else {
Err(ParseError {
expected,
found: self.peek().clone(),
span: self.tokens[self.pos].span,
})
}
}
}

expect 是整个解析器中出现频率最高的操作。后面每一步"消费冒号"“消费等号”"消费分号"都通过它完成。当实际 Token 和预期不符,expect 直接携带源码位置生成错误——这比"解析失败"四个字有用得多。

解析 let 语句

let_stmt 的产生式是 "let" ["mut"] IDENT ":" type "=" expr ";"。翻译成代码,就是按顺序消费每个成分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
fn parse_let_stmt(&mut self) -> Result<Statement, ParseError> {
self.expect(TokenKind::Let)?;

let mutable = if *self.peek() == TokenKind::Mut {
self.advance();
true
} else {
false
};

let name_tok = self.expect(TokenKind::Ident)?;
let name = name_tok.text.clone();

self.expect(TokenKind::Colon)?;

let ty = self.parse_type()?;

self.expect(TokenKind::Assign)?;

let init = self.parse_expr()?;

self.expect(TokenKind::Semi)?;

Ok(Statement::Let(LetStmt {
mutable,
name,
ty,
init,
span: name_tok.span,
}))
}

注意 mut 是可选的。解析器通过 peek 检查下一个 Token 是不是 Mut,如果是就消费掉并记录为可变;如果不是就跳过,不报错。这是方括号 ["mut"] 在代码中的直接对应。

类型注解紧跟在冒号后面。parse_type 函数目前只接受两种类型名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn parse_type(&mut self) -> Result<Type, ParseError> {
match self.peek() {
TokenKind::Ident if self.tokens[self.pos].text == "i64" => {
self.advance();
Ok(Type::I64)
}
TokenKind::Ident if self.tokens[self.pos].text == "bool" => {
self.advance();
Ok(Type::Bool)
}
_ => Err(ParseError {
expected: TokenKind::Ident,
found: self.peek().clone(),
span: self.tokens[self.pos].span,
}),
}
}

这里 i64bool 暂时当作特殊的标识符来匹配。等类型系统变复杂后会引入专门的类型解析逻辑,但起步阶段没必要过早设计。

解析赋值语句

赋值语句的结构比 let 简单:没有关键字前缀,没有类型注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn parse_assign_stmt(&mut self) -> Result<Statement, ParseError> {
let name_tok = self.expect(TokenKind::Ident)?;
let name = name_tok.text.clone();

self.expect(TokenKind::Assign)?;

let value = self.parse_expr()?;

self.expect(TokenKind::Semi)?;

Ok(Statement::Assign(AssignStmt {
name,
value,
span: name_tok.span,
}))
}

赋值和 let 的区分在调用方完成。parse_stmt 先看当前 Token:如果是 Let 就进入 parse_let_stmt;如果是 Ident 且下一个 Token 是 Eq,就进入 parse_assign_stmt。这个两步前瞻的代价很小,换来的是解析逻辑的清晰分离。

1
2
3
4
5
6
7
8
9
10
11
12
13
fn parse_stmt(&mut self) -> Result<Statement, ParseError> {
match self.peek() {
TokenKind::Let => self.parse_let_stmt(),
TokenKind::Return => self.parse_return_stmt(),
TokenKind::Ident
if self.pos + 1 < self.tokens.len()
&& self.tokens[self.pos + 1].kind == TokenKind::Assign =>
{
self.parse_assign_stmt()
}
_ => self.parse_expr_stmt(),
}
}

这里有一处值得注意的设计选择:赋值的左侧目前只允许简单标识符。a[0] = 1s.field = 2 这类复合左值留到引入数组和结构体时再处理。起步阶段限制越紧,错误信息越容易写对。

解析 return 语句

return 是三种语句中最规整的——固定前缀、一个表达式、一个分号:

1
2
3
4
5
6
7
8
9
10
11
12
fn parse_return_stmt(&mut self) -> Result<Statement, ParseError> {
let ret_tok = self.expect(TokenKind::Return)?;

let value = self.parse_expr()?;

self.expect(TokenKind::Semi)?;

Ok(Statement::Return(ReturnStmt {
value,
span: ret_tok.span,
}))
}

当前要求 return 后面必须跟表达式。对于不返回值的函数(返回类型为 unit),后续可以允许单独的 return;,但在只有 i64bool 的阶段没有必要。

块:语句的容器

块(block)是用花括号包裹的语句序列。函数体是一个块,后续的 if 分支和 while 循环体也是块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn parse_block(&mut self) -> Result<Block, ParseError> {
let open = self.expect(TokenKind::LBrace)?;

let mut stmts = Vec::new();
while *self.peek() != TokenKind::RBrace {
stmts.push(self.parse_stmt()?);
}

self.expect(TokenKind::RBrace)?;

Ok(Block {
stmts,
span: open.span,
})
}

循环条件是"当前 Token 不是右花括号就继续解析语句"。这意味着空块 {} 是合法的——循环一次都不执行,直接消费 }

块内的每条语句通过 parse_stmt 解析,而 parse_stmt 内部可能再次调用 parse_block(比如后续加入 ifwhile 时)。这就是"递归"下降的由来:解析函数互相调用形成递归结构,天然映射了语法树的嵌套层次。

块本身不需要尾部分号。{ return 42; } 最后一个 Token 是 },不需要在后面再加 ;。这是因为花括号本身就是明确的定界符,解析器看到 } 就知道块结束了,不需要分号来做额外的终止标记。这和语句不同——语句在 Token 流中没有明确的结束标记,分号充当了这个角色。

分号为什么不可省略

有些语言允许省略分号(比如 Go 用换行代替,JavaScript 有自动分号插入)。Sprout 选择要求显式分号,原因很实际:省略分号需要复杂的启发式规则,而这些规则经常制造令人困惑的错误。JavaScript 的 ASI(Automatic Semicolon Insertion)规则就是反面教材——一个 return 后面换行,返回值变成了 undefined,因为分号被自动插入到了 return 后面。

对编译器实现来说,显式分号也简化了解析器的错误恢复。当解析器在某处遇到不期望的 Token 时,它可以跳到下一个分号处重新开始解析新的语句。分号是天然的同步点。如果分号是可选的,这个同步策略就不可靠了。

AST 节点类型

所有语句节点的 Rust 定义汇总如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub enum Statement {
Let(LetStmt),
Assign(AssignStmt),
Return(ReturnStmt),
Expr(ExprStmt),
}

pub struct LetStmt {
pub mutable: bool,
pub name: String,
pub ty: Type,
pub init: Expr,
pub span: Span,
}

pub struct AssignStmt {
pub name: String,
pub value: Expr,
pub span: Span,
}

pub struct ReturnStmt {
pub value: Expr,
pub span: Span,
}

pub struct Block {
pub stmts: Vec<Statement>,
pub span: Span,
}

每个节点都带 Span,这是贯穿整个编译器前端的设计原则。从词法分析到语法分析再到语义分析,每一层的数据结构都携带源码位置。这样无论在哪个阶段发现错误,都能指出它在源文件中的准确位置。这个成本——每个节点多八到十六字节——和它带来的诊断质量相比完全值得。

Statement 用枚举而不是 trait object,因为语句的种类在编译期就完全确定了。枚举在 Rust 中可以进行穷举模式匹配,编译器帮你检查是否遗漏了分支。每次新增一种语句类型,所有 match 语句上的漏洞会被编译器逐个揪出来——这种保障在编译器开发中极其有价值。ExprStmt 的存在是因为表达式本身也能充当语句,比如一个函数调用 print_i64(42);。它的返回值被丢弃,执行只为副作用。

AST 节点定义

前面的代码片段已经用到了 StatementLetStmt 等类型。完整的 AST 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#[derive(Debug, Clone)]
pub enum Statement {
Let(LetStmt),
Assign(AssignStmt),
Return(ReturnStmt),
Expr(ExprStmt),
}

#[derive(Debug, Clone)]
pub struct LetStmt {
pub mutable: bool,
pub name: String,
pub ty: Type,
pub init: Expr,
pub span: Span,
}

#[derive(Debug, Clone)]
pub struct AssignStmt {
pub name: String,
pub value: Expr,
pub span: Span,
}

#[derive(Debug, Clone)]
pub struct ReturnStmt {
pub value: Expr,
pub span: Span,
}

#[derive(Debug, Clone)]
pub struct ExprStmt {
pub expr: Expr,
pub span: Span,
}

#[derive(Debug, Clone)]
pub struct Block {
pub stmts: Vec<Statement>,
pub span: Span,
}

#[derive(Debug, Clone)]
pub enum Type {
I64,
Bool,
}

每个节点都携带 Span——源码中的起止位置。上一篇建立的源码映射在这里开始发挥作用:后续类型检查或代码生成报错时,可以沿着 Span 找到对应的源码行列,而不是给出一个没有上下文的"语法错误"。

Expr 类型在本篇暂时作为占位,第 04 篇会完整定义表达式解析和优先级爬升。现阶段可以先支持整数字面量和标识符两种简单表达式,足够演示语句解析的完整流程。

分号与错误报告

分号在 Sprout 中不是装饰,而是语法分隔符。每条语句以 ; 结尾,解析器通过 expect(TokenKind::Semi) 强制检查。

少写一个分号会怎样?假设输入是:

1
2
let x: i64 = 1
x = 2;

解析器消费完表达式 1 之后调用 expect(Semi),此时当前 Token 是 Ident("x")expect 报告:

1
2
3
4
5
error: expected `;`, found `x`
--> test.spr:1:17
|
1 | let x: i64 = 1
| ^ expected `;` here

错误位置指向第一行末尾。因为 Token 自带 Span,所以"在哪里缺了分号"不需要猜。

再看一个缺少类型注解的例子:

1
let x = 1;

解析器消费完标识符 x 后调用 expect(Colon),当前 Token 是 Assign。报告:

1
2
3
4
5
error: expected `:`, found `=`
--> test.spr:1:7
|
1 | let x = 1;
| ^ expected `:` for type annotation

还有缺少等号的情况:

1
let x: i64 1;

消费完类型 i64expect(Assign) 发现当前是 Int(1)

1
2
3
4
5
error: expected `=`, found `1`
--> test.spr:1:12
|
1 | let x: i64 1;
| ^ expected `=` after type annotation

这三个例子展示了同一个机制:expect 在每个位置检查预期的 Token,不符就立刻报错并携带源码位置。代码里不需要专门写三种错误处理逻辑——文法结构本身就决定了错误消息的内容。

手动解析一段程序

把下面三条语句当作输入,手动走一遍解析器的执行过程:

1
2
3
let mut x: i64 = 0;
x = x + 1;
return x;

词法分析后的 Token 序列(省略 Span):

1
2
3
Let  Mut  Ident("x")  Colon  Ident("i64")  Assign  Int(0)  Semi
Ident("x") Assign Ident("x") Plus Int(1) Semi
Return Ident("x") Semi

第一条语句。 parse_stmt 看到 Let,进入 parse_let_stmt。消费 Letpeek 看到 Mut,消费之,记录 mutable = trueexpect(Ident) 消费 Ident("x"),记录 name = "x"expect(Colon) 消费冒号。parse_type 看到 Ident("i64"),返回 Type::I64expect(Assign) 消费等号。parse_expr 消费 Int(0),返回表达式节点。expect(Semi) 消费分号。得到 Statement::Let { mutable: true, name: "x", ty: I64, init: Lit(0) }

第二条语句。 parse_stmt 看到 Ident("x"),前瞻发现下一个是 Assign,进入 parse_assign_stmt。消费 Ident("x")expect(Assign) 消费等号。parse_expr 消费 Ident("x") Plus Int(1),返回二元运算节点。expect(Semi) 消费分号。得到 Statement::Assign { name: "x", value: BinOp(Add, Var("x"), Lit(1)) }

第三条语句。 parse_stmt 看到 Return,进入 parse_return_stmt。消费 Returnparse_expr 消费 Ident("x"),返回变量引用节点。expect(Semi) 消费分号。得到 Statement::Return { value: Var("x") }

三条语句组成一个块,对应的 AST 结构如下:

1
2
3
4
Block
├── Let { mutable: true, name: "x", ty: I64, init: Lit(0) }
├── Assign { name: "x", value: BinOp(Add, Var("x"), Lit(1)) }
└── Return { value: Var("x") }

练习

  1. 在纸上解析 let flag: bool = true; return flag;,画出完整的 AST 树。注意 true 在词法阶段应该产生什么 Token,parse_type 在这里匹配的是哪个分支。

  2. 修改 parse_stmt 使其支持 while 循环:while_stmt → "while" expr block。写出函数签名和主体逻辑,并解释为什么 while 的块不需要后续分号。

  3. 故意去掉 let x: i64 = 1; 中的冒号,写成 let x i64 = 1;。追踪 parse_let_stmt 的执行路径,确认 expect(Colon) 会在哪个 Token 上报错,错误消息应该包含什么信息。

上一篇:02 - 词法分析器:把文本切成 Token
下一篇:04 - 表达式解析与错误恢复