rustfmt 设计解析:从 Design.md 到源码实现 —— 理解 Rust 官方格式化工具的核心架构与设计哲学

发布时间:2026/9/17 20:16:42
rustfmt 设计解析:从 Design.md 到源码实现 —— 理解 Rust 官方格式化工具的核心架构与设计哲学 rustfmt 设计解析从 Design.md 到源码实现 —— 理解 Rust 官方格式化工具的核心架构与设计哲学【免费下载链接】rustfmtFormat Rust code项目地址: https://gitcode.com/GitHub_Trending/ru/rustfmt本文以仓库根目录下的 Design.md 为骨架深入解析 rustfmt一款按风格指南格式化 Rust 代码的工具的设计文档并结合当前仓库的源码实现src/rewrite.rs、src/shape.rs、src/visitor.rs、src/missed_spans.rs、src/config/、src/items.rs等进行交叉印证。读者将掌握 rustfmt 的设计边界、语义保持原则、基于 AST 的启发式格式化架构、宽度预算width budget分配机制以及这些设计思想如何落实为可运行的代码。一、使用场景与设计边界rustfmt 为什么这样设计设计文档开篇即点明一个核心观点格式化工具的不同使用方式会反过来影响工具本身的架构设计。rustfmt 作者最初由 Nicholas Cameron 撰写该文档明确列出了关心的使用场景check-in 前对整个仓库运行作为代码提交前的规范性检查特别提到要替代 Rust 发行版中的make tidy流程对引入到自己项目中的第三方代码运行格式化来自其他项目的代码在项目中大规模批量修改代码风格作为风格迁移的工具。与之相对文档明确列出了刻意不去解决的使用场景nice, if possible 但不在当前目标内在 IDE 中边输入边格式化as you type对任意代码片段运行对 Rust 风格但无法解析的代码运行作为编译器内部的 pretty printer 使用重构refactoring格式化完全未格式化的源码。这一节的价值在于划定了设计的能力边界rustfmt 的一切架构取舍——选择 AST 而非 token 流、接受少量无法格式化的情况、采用启发式规则等——都可以从这组使用场景中找到动机。例如不做 IDE 即时格式化意味着可以接受一次完整的解析成本不做重构意味着语义保持原则可以严格化。二、范围与愿景语义保持semantics preserving而非语法保持Design.md 最核心的设计主张之一是我不同意格式化工具只应改动空白字符的观点。我们应该做到语义保持但不必语法保持即我们可以改变程序的 AST。这意味着 rustfmt 可以做的改动包括把 glob 导入展开为逐个或单个导入、重新排序导入、把约束bounds移动到 where 子句、把多个 impl 合并为一个 impl 等。当前仓库中 src/config/mod.rs 的create_config!宏正好记录了这批会改变 AST 但保持语义的选项例如reorder_imports按字母序重排 import 与 extern crate 语句reorder_modules按字母序重排 module 声明merge_derives把多个#[derive(...)]合并为一个use_try_shorthand把try!宏替换为?简写use_field_init_shorthand尽可能使用字段初始化简写condense_wildcard_suffixes把元组模式中的连续_通配符压缩为单个..。同时命名、任何可能改变语义的改动都在禁止之列。文档用一个更形式化的表述约束了这一原则可以想象编译器的高层中间表示HIRrustfmt 应当只做不改变 HIR 的改动即使它们改变了 AST。文档的长期愿景是把编译器中所有的风格 lint 迁移到 rustfmt 中rustfmt 除了给出警告还能直接修复问题或输出重构脚本例如重命名变量以符合风格指南——重构脚本是更深层改动的输出通道而不是格式化主流程的一部分。三、可配置性与多运行模式Design.md 认为格式化应当在一定程度上可配置从配置文件读取选项并按配置进行格式化至少提供一份与 Rust 风格指南一致的默认配置文件提供多种运行模式直接替换每个文件向用户展示将要做的改动列表只展示违规项而不给出修正文档特别指出同一组风格指南可能有多种满足方式应当区分违规与偏离我们自己的模型。这一点在当前仓库中体现为丰富的模式支持。README.md 中列出了--emit的取值files覆盖写入文件、stdout输出到标准输出、coverage显示输入文件被处理的比例nightly 专属、checkstyle以 checkstyle 格式输出nightly 专属、json以 JSON 格式输出 diffnightly 专属而--check模式则让 rustfmt 在若不改动则退出 0、若需要改动则退出 1用于 CI 强制风格一致性。配置层面src/config/mod.rs 中create_config!宏定义了全部选项并按主题分组基础项max_width、hard_tabs、tab_spaces、newline_style、indent_style、宽度启发式use_small_heuristics、fn_call_width、struct_lit_width、array_width、chain_width等、注释与宏与字符串wrap_comments、comment_width、format_macro_bodies、skip_macro_invocations等、单行表达式与条目、导入、排序、标点空格、控制选项disable_all_formatting、skip_children、file_lines、ignore等共约 90 个选项。每个选项都有稳定的类型封装如BraceStyle、IndentStyle、NewlineStyle、Density定义于 src/config/options.rs。四、实现哲学五条指导原则Design.md 用五个小节阐述了实现层面的哲学每条都能在当前源码中找到对应物。4.1 基于 AST 而非 token 流格式化工具既可以选择 AST 方案也可以选择 token 流在 Rust 中实际是 token tree 流方案。rustfmt 选择了AST 方案理由有二能做更复杂的操作而不只是改空白做这些操作时有更多上下文信息。token 方案的优势在于可以处理无法解析的代码但作者认为能够做复杂的变换更重要未来还希望可选地利用类型信息来指导格式化。宏是无法解析代码的一个典型例子——token 方案在这里确实更容易但作者相信 AST 方案完全可以解决极端情况下可以在宏场景退化为仅操作 token。文档还给出了一个有趣的趋同论证由于 span 信息不完美AST 方案有时被迫去检查 token 或自行重新词法分析而 token 方案需要自己实现一个更简单的解析器。随着工具越来越复杂两端会越走越近——最终你会得到同一个工具。但从 AST 出发能更快得到一个可用、有用的工具。当前仓库正是基于rustc_astrustc 的 AST crate工作例如 src/visitor.rs 第 5 行use rustc_ast::{ast, token::Delimiter, visit};直接复用了 rustc 的 AST 与 visit 模块。Design.md 写作时引用的是 [syntex_syntax]rustc libsyntax 的导出如今 rustfmt 已迁移到官方rustc_ast但基于 AST visit 遍历的核心决策没有改变。4.2 启发式heuristic而非算法式algorithmic许多格式化工具使用非常通用的算法甚至代数方法来处理 pretty printing代码非常优雅但作者认为结果不是最好的。rustfmt 偏好更临时/特化ad hoc的做法每个表达式/条目都用自定义规则格式化通过良好的抽象与代码共享来控制代码量。代价是代码库更大但结果更好。由此引出一个坦率的结论会有一些情况无法格式化必须放弃。作者认为这可以接受——与其要一个 50% 出色、50% 丑陋但永不失败的工个工具不如要一个 99% 出色、1% 失败的工具失败的情况足够罕见手工修复并不痛苦。这一点在代码中的直接体现是rewrite系列函数的可失败返回设计。src/rewrite.rs 定义了核心 traitpub(crate) trait Rewrite { fn rewrite(self, context: RewriteContext_, shape: Shape) - OptionString; }返回OptionStringSome表示在该Shape宽度预算内成功格式化None表示失败调用方需要回退fallback例如给被调用方更多空间。源码中还把失败细化为RewriteError枚举SkipFormatting因 skip 属性或超出文件行范围而跳过、ExceedsMaxWidth超出配置宽度、MacroFailure宏格式化失败、Unknown。4.3 增量开发尽快有用、始终有用作者明确不希望等待某个特性甚至整个工具完美之后才变得有用。实现这一目标的主要手段对暂时无法重新格式化的代码原样输出源码新特性在成熟前可以被关闭依靠下一节的不造成伤害原则兜底。4.4 首要原则不造成伤害First, do no harm在做更多事与把已有的事做好之间作者选择宁可保守rustfmt 绝不应该把好的代码改得更难看。如果无法让它更好就保持原样。这可能意味着少做一些激进的改动或借助可配置性。这条原则与以源码为指引下一节共同构成了 rustfmt 稳定性的哲学基础也与 README.md 中关于稳定性的承诺相呼应post-1.0 之后大多数代码的格式化结果不应随 rustfmt 改进而改变。4.5 以源码作为指引Use the source code as guidance当有多种满足规范的格式化方式时把源码当作重格式化的提示如果代码已经以一种满足规范的方式格式化过就不要改动它文档承认由于统一性的需要这有时不可能或不值得但这是一个有用的目标。五、架构细节从设计文档到源码实现Design.md 的Architecture details一节描述了实际架构这一节我们逐条对照当前源码验证。5.1 从 syntex_syntax 到 rustc_ast 的 AST 遍历文档原文我们使用 [syntex_syntax]rustc libsyntax 的一个导出的 AST使用其 visit 模块遍历 AST寻找重格式化的起点。最终我们应该格式化一切不再需要 visit 模块。我们记录代码中最后一个已格式化位置在重格式化下一段代码时确保把中间所有代码的 span 原样输出由 missed_spans.rs 处理。当前实现中visit 模块的职责体现在 src/visitor.rs 的FmtVisitor结构体它持有buffer输出缓冲区、last_pos最后一个已格式化位置类型为BytePos和block_indent期望的块缩进通过visit_*方法逐节点处理。SnippetProvider同文件负责根据 span 从原始文件中切出源码片段。5.2 missed_spans填补格式化间隙文档提到的 src/missed_spans.rs 正是把两个格式化点之间的代码原样输出的实现。核心逻辑在format_missing_inner它取出从last_pos到end的 snippet对纯空白片段按blank_lines_upper_bound/blank_lines_lower_bound配置压缩垂直空白push_vertical_spaces对注释片段走rewrite_comment对无法格式化的代码走process_missing_code原样保留。这与文档中keep track of the last formatted position的描述完全一致——self.last_pos在每个片段处理后更新。5.3 配置系统rustfmt.toml 与 Config 对象文档原文如果存在rustfmt.toml我们从其中读取格式化配置。选项及其默认值定义在config.rs中。一个Config对象被传递给整个格式化代码每个格式化例程都从中查询自己的配置。当前仓库中配置定义已拆分为 src/config/mod.rscreate_config!宏集中声明选项、默认值与稳定性和 src/config/options.rs枚举与数值类型的定义如NewlineStyle、BraceStyle、ControlBraceStyle、IndentStyle、Density、Heuristics。Config对象通过RewriteContext传给所有格式化例程——src/rewrite.rs 中RewriteContext的config: a Config字段正是Config 对象被传递到各处的落点。此外RewriteContext::budget()实现了文档所述宽度预算的核心运算pub(crate) fn budget(self, used_width: usize) - usize { self.config.max_width().saturating_sub(used_width) }5.4 Shape缩进与宽度预算的载体文档描述了rewrite_*方法的工作方式它们接收要重格式化的 AST 节点、当前缩进和当前宽度预算返回格式化后的String或OptionString失败返回None调用方回退、给被调用方更多空间。这里的缩进 宽度预算在代码中就是Shape类型定义于 src/shape.rspub(crate) struct Shape { pub(crate) width: usize, // 最后一行允许的最大字符数不含缩进 pub(crate) indent: Indent, // 当前代码缩进 pub(crate) offset: usize, // 缩进 当前语句首行已输出的文本 }Shape提供了一组在树下行/回溯时调整预算的 APIShape::indented(indent, config)按max_width - indent.width()初始化宽度visual_indent(delta)/block_indent(delta)分别产生视觉缩进对齐到首行与块缩进sub_width(delta, span)/saturating_sub_width扣减宽度预算预算不足时返回ExceedsMaxWidthErrorcomment(config)取width与comment_width - indent.width()的较小值用于注释重排infinite_width()返回一个近似无限的宽度INFINITE_SHAPE_WIDTH 8096用于不要在此处折行的场景。Indent则由block_indent块缩进宽度须是tab_spaces的整数倍与alignment对齐列组成其渲染逻辑区分空格与制表符hard_tabssrc/shape.rs 中indent_to_string_hard_tabs等单元测试验证了2 tabs 4 spaces这类输出。5.5 文档示例的完整推演fn foo(a: A, b: B)Design.md 用一段经典代码演示了宽度预算在树上自上而下分配、在叶子上生成字符串、再自下而上合并的过程fn foo(a: A, b: B) { ... }推演过程假设max_width 100从缩进 4开始整个函数的 rewrite 函数知道必须在参数前写fn foo(、在参数后写) {fn foo(在缩进 4 之后又占 7 列参数从第 11 列开始) {还要 3 列因此它请求参数列表以缩进 11、宽度 86重写100 − 11 − 3 86本例中参数放得下返回参数串即可拼出整个函数头如果参数放不下则回退到悬挂缩进hanging indent用缩进 8、宽度 89再试100 − 8 − 3 89。这段设计文档中的推演在当前源码中对应 src/items.rs 的rewrite_fn_base它先计算one_line_budget context.budget(used_width overhead)overhead为 4 或 2对应() {与()构造Shape { width: one_line_budget, indent, offset: used_width }随后通过compute_budgets_for_params分别算出单行预算与多行预算以及参数缩进param_indent再调用rewrite_params。如果 Block 缩进风格下参数串包含换行或超出单行预算则put_params_in_block为真参数被放入块缩进的多行布局否则参数紧跟在(后并检查空参数 右括号超宽等边界情况决定是否把)放到下一行。这正印证了文档所说如果方法失败返回None调用方法必须以某种方式回退给被调用者更多空间——只不过在现代代码中失败被建模为ResultString, RewriteError回退则由sub_width、visual_indent、block_indent、shrink_left等 Shape 变换与compute_budgets_for_params的预算重算共同完成。六、总结设计思想如何塑造今天的 rustfmt回到 Design.md 的原始论断我们可以把 rustfmt 的架构设计浓缩为一条清晰的因果链使用场景整仓/引入代码/批量风格迁移决定了解析整文件是合理成本进而决定了——基于 AST 的方案换来复杂的、有上下文的变换能力语义保持而非语法保持的愿景使改 AST 不动 HIR成为合法的格式化操作对应 src/config/mod.rs 中reorder_imports、merge_derives等选项启发式 可失败的 rewrite 机制src/rewrite.rs 的Rewritetrait 返回OptionString把99% 出色、1% 手工修复的取舍落到实处增量开发与不造成伤害加上以源码为指引构成了稳定性承诺的哲学基础宽度预算在Shapesrc/shape.rs上自上而下分配、失败时回退成为所有rewrite_*例程的统一工作协议fn foo(a: A, b: B)示例的缩进 11/宽度 86 → 缩进 8/宽度 89回退路径在 src/items.rs 的rewrite_fn_base中依然清晰可辨。无论是想为 rustfmt 贡献代码参见 Contributing.md 与 Processes.md还是想深入理解一个成熟的格式化工具应该怎么设计从 Design.md 到src/目录的这份对应关系都是一条值得反复研读的路径。【免费下载链接】rustfmtFormat Rust code项目地址: https://gitcode.com/GitHub_Trending/ru/rustfmt创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考