rustc 词法分析与解析(Lexing & Parsing):编译器前端的入口与源码级实现剖析
【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust
本文基于 Rust 官方开发者指南中“词法分析与解析”(Lexing and parsing)章节,结合当前仓库(rust 编译器源码树)中的rustc_lexer、rustc_parse、rustc_ast等真实源码,系统讲解 rustc 如何处理一段 UTF-8 源码:从把字符串切分为 token 流,到把 token 流组织成抽象语法树(AST),再到解析会话(ParseSess)、手写词法分析器、解析器子模块划分与限制性位掩码(Restrictions)等实现细节。读完本文,你将理解 rustc 前端第一阶段的完整数据流、各 crate 的职责边界,以及定位解析相关 bug 时应该去哪里读代码。
编译器要做的第一件事:Lexing 与 Parsing
编译器拿到的第一个东西是一段程序源码(UTF-8 编码的 Unicode 文本)。在把文本变成编译器内部能高效处理的数据格式之前,必须经过两个阶段:
- 词法分析(Lexing):把字符串切分成 token 的流。例如
foo.bar + buz会被切分为 5 个 token:foo、.、bar、+、buz。这一层由独立的 rustc_lexer crate 实现。 - 解析(Parsing):把 token 流组织成结构化形式,即抽象语法树(Abstract Syntax Tree,AST),这是后续所有编译阶段工作的基础。AST 的定义位于 rustc_ast,其中同时包含 token 与 token 流的定义、修改 AST 的数据结构/特征(trait),以及词法分析、宏展开等其他 AST 相关模块共享的类型。
从源码结构看,这个“两阶段”的分工在 crate 级别被刻意切干净了:
| 阶段 | 所在 crate | 核心职责 |
|---|---|---|
| 底层切词 | rustc_lexer | 纯词法,直接操作&str,不感知 span 与报错 |
| 集成词法 | rustc_parse::lexer | 给底层 token 补上Span、对标识符做 interning |
| 解析 | rustc_parse::parser | 从 token 流构建 AST |
| AST 数据结构 | rustc_ast | 定义ast::Crate、Token、TokenStream、NodeId、visit 等 |
AST:内存中镜像整个 Rust 程序
AST 与 Span
AST 在内存中镜像一个 Rust 程序的结构,并用Span把每个 AST 节点关联回它的原始源码文本。Span是 rustc 中一切诊断(错误、警告、提示)能精确指向源码行列的根基。
NodeId:crate 内唯一、但增量不友好
AST 中的每个节点都有自己的NodeId——不仅包括 struct 这类顶层 item,也包括单独的语句和表达式。NodeId是 crate 内部唯一标识一个 AST 节点的编号。
NodeId有一个关键的工程性质:它是crate 内绝对编号。这意味着在 AST 中插入或删除任何一个节点,都会导致其后所有NodeId全部变动。而增量编译(incremental compilation)恰恰希望尽可能少的东西发生变化,所以从源码结构看,NodeId对增量编译几乎无用——这也是为什么 rustc 后续的 HIR、MIR 等表示会引入更稳定的 ID 体系。
NodeId主要服务于直接操作 AST 的那些前端阶段:宏展开和名称解析(resolve)。相关定义见 node_id.rs,visit.rs与mut_visit.rs则提供了遍历(visit)和变换(mut visit)AST 的通用接口,expand/目录存放宏展开过程中使用的 AST 扩展结构(如 typetree)。
解析器入口:rustc_parse 的调用链
解析器定义在 rustc_parse 中,该 crate 除了解析器本体,还包含面向 lexer 的高层接口,以及一些在宏展开之后运行的校验例程。真正的解析实现位于rustc_parse::parser模块。
从源文件到 Crate 的完整入口
rustc_parse顶层(lib.rs)暴露的主入口是各种parse_*函数与工厂函数,典型调用链如下:
- 从文件名创建解析器:
new_parser_from_file——通过ParseSess持有的SourceMap::load_file读入源码。注意其中的错误处理细节:文件不存在时会在父目录里用编辑距离(find_best_match_for_name)找一个最相似的文件名并提示 “you might have meant to open …”;文件不是合法 UTF-8 时,utf8_error会定位到具体非法字节并生成带 span 的诊断。 - 从源码字符串创建解析器:
new_parser_from_source_str——先new_source_file注册一个SourceFile,再走同一条路径。 - token 流生成:
new_parser_from_source_file内部调用source_file_to_stream,后者最终调用lexer::lex_token_trees把整个文件词法化为一个TokenStream,然后Parser::new(psess, stream, None)构建解析器;若第一个 token 就是Eof(空文件),会为其补一个指向文件末尾的 span。 - 执行解析得到
Crate:拿到Parser后,调用各parse_*方法逐项解析 item,最终得到根 AST 节点rustc_ast::ast::Crate。
此外还有两个值得注意的辅助入口:
source_str_to_stream:直接对一段源码字符串做词法化,得到 token 树序列,常用于宏参数、测试等场景。它目前只剥离 shebang,不剥离 frontmatter(代码中的 FIXME 注释说明了对 edition 兼容性的考虑)。parse_in:把任意TokenStream交给一个子解析器闭包f处理,并要求处理完后 token 必须恰好耗尽(否则报unexpected)。这是“对一段独立 token 流做子解析”的标准姿势。
错误处理上,所有入口都返回Result<_, Vec<Diag>>的“缓冲诊断”风格:失败时错误不会立即打印,而必须通过unwrap_or_emit_fatal(lib.rs 中定义)统一发射,否则诊断被 drop 时会 panic。这让调用方有机会在收集到多个错误后统一决策。
生命周期绑定 ParseSess:以最小拷贝为目标
文档特别指出一个设计决策:为了最小化拷贝,Lexer和Parser都带一个绑定到父ParseSess的生命周期。也就是说你无法把Parser单独存起来跨会话使用——它只在一次解析期间有效。
从 rustc_session/src/parse.rs 的源码可以看到ParseSess的实际构成:诊断上下文DiagCtxt、edition、Arc<SourceMap>、缓冲的 early lint(buffered_lints)、特性门控收集器GatedSpans(记录哪些 feature 在哪些 span 被使用,供后续check_crate检查)、SymbolGallery(记录符号首次出现位置)、AttrIdGenerator等。解析期间需要的一切信息都集中在这里,Parser借住(borrow)而非拥有这些状态,这正是生命周期绑定的收益。
词法分析:手写词法器 rustc_lexer 与集成层 Lexer
词法分析的代码分布在两个 crate 中,这一分层是理解 rustc 前端复用性的关键。
rustc_lexer:纯函数式的手写词法器
rustc_lexer负责把一段&str切分为构成 token 的片段。虽然业界很流行用生成的有限状态机实现 lexer,但 rustc 的词法器是完全手写的。其 crate 文档明确了设计目标:
- 可复用:把“纯词法”与 rustc 特有关注点(span、报错、interning)分离。因此
rustc_lexer直接操作&str,产出的是简单 token——“类型标签 + 一小段原始文本”,它不报告错误,而是把错误作为标志位(flag)存到 token 上; - 产出的 token 尚不足以直接用于解析 Rust 语法,真正供解析器使用的是
rustc_parse::lexer转换出的“wide token”。
核心数据结构:
/// Parsed token. /// It doesn't contain information about data that has been parsed, /// only the type of the token and its size. #[derive(Debug)] pub struct Token { pub kind: TokenKind, pub len: u32, }Token只携带TokenKind和长度len(lib.rs),真正的文本始终由调用方按(起点, len)从原字符串中截取——这是又一个“零拷贝”设计。TokenKind枚举覆盖了 Rust 词法的方方面面:行/块注释(块注释可嵌套,如/* /* */不会终结)、空白、Ident、含 emoji 的InvalidIdent、raw identifier(r#ident)、未知字面量前缀(UnknownPrefix)、Literal { kind, suffix_start }、lifetime、各种运算符等。
从源码结构看,还有一个值得注意的“性能护栏”:集成层 rustc_parse/src/lexer/mod.rs 中用static_assert_size!(rustc_lexer::Token, 12)断言底层Token在 64 位平台上不超过 12 字节——因为它在词法化循环中被高频创建,任何意外的膨胀都会直接影响编译速度(注释解释了为何断言放在本 crate 而非rustc_lexer:后者不能依赖rustc_data_structures)。
此外 rustc_parse/src/lib.rs 中有一组编译期断言,强制rustc_lexer、rustc_span、unicode-normalization、unicode-width使用同一 Unicode 版本——这保证标识符合法性判断与字符宽度/规范化计算口径一致。
Lexer:集成 span、interning 与错误报告
rustc_parse::lexer::Lexer把rustc_lexer与 rustc 特有数据结构粘合起来,具体来说:给底层 token 补上Span信息,并对标识符做 interning(转为Symbol)。
词法化的总入口是lex_token_trees,其参数StripTokens控制词法前预处理行为,共有三种取值(mod.rs):
ShebangAndFrontmatter:剥离 shebang 与 frontmatter;Shebang:只剥离 shebang,长得像 frontmatter 的序列按普通 Rust 词素处理;Nothing:什么都不剥离。
内部流程是:先按策略用rustc_lexer::strip_shebang截掉首行 shebang 并修正start_pos,再创建Cursor::new(src, frontmatter_allowed)和Lexer { psess, start_pos, src, cursor, ... }逐 token 推进。词法错误(如未闭合的字符串、失配的分隔符)在diagnostics.rs中统一组织,其中UnmatchedDelim结构会记录找到的分隔符、未闭合位置及候选位置,用于生成精确的“未闭合括号”类诊断。
解析器实现结构:rustc_parse::parser 内部
parser/mod.rs是整个解析器的“枢纽”,按语法范畴拆分为多个子模块,从源码结构看其组织方式一目了然:
| 子模块 | 解析对象 |
|---|---|
expr.rs | 表达式 |
item.rs | item(函数、struct、impl 等),如parse_item、parse_mod |
stmt.rs | 语句 |
ty.rs/pat.rs/generics.rs/path.rs | 类型、模式、泛型参数、路径 |
function.rs | 函数签名/头 |
nonterminal.rs | 宏非终结符参数($e:expr、$p:pat等),入口parse_nonterminal |
asm.rs/cfg_select.rs | 内联汇编、cfg_select! |
Parser维护当前 token、lookahead 能力(TokenCursor/TokenStream),并维护一个名为Restrictions的位掩码(mod.rs),用来表达“当前正在解析什么”这一上下文状态,从而改变解析行为。例如:
STMT_EXPR:限制语句位置的表达式解析。源码注释给出了经典例子:开启该限制时,if true {} & 1被解析为两个语句(if语句与对1取引用的语句);关闭时则解析为按位与表达式,if在左、1在右;NO_STRUCT_LITERAL:在需要前瞻或存在歧义的语法位置禁止结构体字面量(如if Foo {} {}无法确定Foo{}是结构体字面量还是条件表达式后跟两个块)。
其他位还包括CONST_EXPR(无花括号 const 泛型参数的更好报错)、ALLOW_LET(let 链中的let表达式)、IN_IF_GUARD(match guard 缺=>的检测)等。这种“用状态位驱动解析行为”的方式,让同一个parse_expr能在不同语法位置复用。
解析中遇到宏:先存起来
解析过程中会不断遇到宏定义或宏调用,解析器不会当场展开它们,而是把这些 token 存起来,交给宏展开阶段处理(见指南中 Macro Expansion 一章)。而宏展开本身可能产生新的 token 流,展开输出又需要再次解析,解析出的结果里可能又暴露出新的宏——如此循环,直到没有宏可展开。这正是解析(rustc_parse)与宏展开(rustc_expand及rustc_ast::expand)之间互相递归调用的原因:rustc_parse顶层的fake_token_stream_for_item、fake_token_stream_for_crate等函数(lib.rs)就是为“把 AST 节点重新打印成文本再词法化”这一展开回路服务的。
验证与测试:如何在仓库中验证这些行为
- 解析器单元测试位于 parser/tests.rs,同目录还有针对 token 流行为的测试模块;
rustc_lexer的单元测试在 rustc_lexer/src/tests.rs,覆盖各TokenKind的切分边界;- UI 测试层面,词法/解析错误的大规模回归测试集中在 tests/ui(大量
*.rs与期望输出*.stderr成对出现),可用于验证诊断信息; - AST 生成后的结构性校验见指南中的 AST 校验章节。
小结:一张图看清前端第一阶段的职责边界
UTF-8 源码 (&str) │ ▼ rustc_lexer::Cursor / Token{kind,len} ← 手写、纯词法、零拷贝、错误以标志位存于 token │ ▼ rustc_parse::lexer::Lexer ← 补 Span、标识符 interning、生成诊断 │ (StripTokens: 控制 shebang/frontmatter 剥离) ▼ TokenStream (rustc_ast::tokenstream) │ ▼ rustc_parse::parser::Parser (生命周期绑定 ParseSess) │ Restrictions 位掩码驱动上下文相关解析;宏暂存待展开 ▼ ast::Crate (AST, 每节点带 NodeId 与 Span) │ ▼ 宏展开 / 名称解析等后续前端阶段对 rustc 前端的修改者而言,这条链路上“改哪里”有清晰对应:想调整某个词素的切分规则,改rustc_lexer;想给 token 加 span 语义或词法错误信息,改rustc_parse::lexer;想改语法结构、解析歧义或解析错误恢复,改rustc_parse::parser对应子模块;想改 AST 形状或 visit 接口,改rustc_ast。
【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考