Biome Markdown 格式化器深度解析:有序列表标记的 9 位数字上限与重编号行为(issue-17778)
【免费下载链接】biomeA toolchain for web projects, aimed to provide functionalities to maintain them. Biome offers formatter and linter, usable via CLI and LSP.项目地址: https://gitcode.com/gh_mirrors/bi/biome
有序列表是 Markdown 中最常用的块级语法之一,但其标记规则远比表面看起来复杂。Biome 在crates/biome_markdown_formatter/tests/specs/prettier/markdown/list/parser-regression/issue-17778.md中用一个精巧的回归测试用例,专门验证了有序列表标记超过 9 位数字时的解析与格式化行为。本文以该测试规格为骨架,结合 Biome 的 Lexer、Parser 与 Formatter 源码,逐层剖析 CommonMark §5.2 的 9 位数字上限是如何在 Biome 中落地实现的,以及 Biome 与 Prettier 在这一边界场景下的行为差异。读完本文,你将理解 Biome 处理超长有序列表标记的完整调用链,并掌握如何复现与验证这一行为。
测试规格全景:issue-17778 到底在测什么
关联文档 issue-17778.md 是 Biome Markdown 格式化器的回归测试输入文件,全文仅 4 行内容:
1. list item 999999999. list item 1. list item 1000000000. ordered list marker can't have more than 9 digits这份输入被刻意设计成一组对比实验:
| 行内容 | 数字位数 | 是否为合法有序列表标记 |
|---|---|---|
1. list item | 1 位 | 是 |
999999999. list item | 9 位 | 是(恰好是上限值) |
1. list item | 1 位 | 是 |
1000000000. ordered list marker can't have more than 9 digits | 10 位 | 否(超出上限) |
第二行的注释文字甚至直接把结论写进了测试里:"ordered list marker can't have more than 9 digits"。这正是对 CommonMark 规范的直接引用——有序列表标记最多只能有 9 位数字。
与之配套的快照文件 issue-17778.md.snap 完整记录了输入、Prettier 差异和 Biome 的格式化输出:
1. list item 2. list item 3. list item 1000000000. ordered list marker can't have more than 9 digits也就是说,Biome 将前三个合法标记(1.、999999999.、1.)识别为同一个有序列表并重新编号为1.、2.、3.,而 10 位数字的1000000000.行被当作普通文本原样保留。这个测试文件虽然只有 4 行,却同时覆盖了解析器的标记识别边界和格式化器的列表重编号两大核心逻辑。
CommonMark §5.2 的 9 位上限:从规范到常量
有序列表标记的位数限制并非 Biome 的独创,而是 CommonMark 规范明确定义的。Biome 的 Markdown 解析器用常量把这个规范固化到了源码中。
在 crates/biome_markdown_parser/src/syntax/mod.rs 中可以找到这条约束的直接实现:
// CommonMark §5.2 caps ordered list markers at 9 digits. pub(crate) const MAX_ORDERED_LIST_MARKER_DIGITS: usize = 9;同样的约束在 crates/biome_markdown_parser/src/syntax/list.rs 的模块文档注释中也有完整表述:
//! ## Ordered List Markers (§5.2) //! - `1.` through `999999999.` (1-9 digits followed by `.`) //! - `1)` through `999999999)` (1-9 digits followed by `)`)这里有两个值得注意的细节:
- 标记结构:有序列表标记由"1~9 位数字 + 分隔符"组成,分隔符既可以是
.(点号),也可以是)(右括号),即1.、1)、10.、999999999)都是合法标记; - 位数上下限:至少 1 位数字,至多 9 位数字。
999999999.恰好是 9 位,因此是合法的——这正是测试用例第二行选择它的原因;而1000000000.有 10 位数字,超出了MAX_ORDERED_LIST_MARKER_DIGITS的上限,不再是合法标记。
值得一提的还有文件注释中列出的解析器限制:为避免病态输入(深度嵌套列表)导致栈溢出,嵌套深度由MarkdownParserOptions::max_nesting_depth限制(默认 100),更深的嵌套会发出诊断并降级处理。这说明 Biome 的 Markdown 解析器在规范兼容之外还做了健壮性防护。
Lexer 层:10 位数字如何被"降级"为文本
9 位上限在词法分析(Lexing)阶段就已经生效。当 Lexer 遇到一行以数字开头的文本时,会尝试将其识别为有序列表标记,识别逻辑位于 crates/biome_markdown_parser/src/lexer/mod.rs 的consume_ordered_list_marker_or_textual函数:
/// Try to consume an ordered list marker (e.g., "1.", "2)", "10."). /// Returns MD_ORDERED_LIST_MARKER if valid, otherwise falls back to textual. /// Per CommonMark: 1-9 digits followed by `.` or `)` followed by whitespace. fn consume_ordered_list_marker_or_textual(&mut self) -> MarkdownSyntaxKind { self.assert_at_char_boundary(); let start_position = self.position; let mut digit_count = 0; // Consume 1-9 digits while let Some(byte) = self.current_byte() { if byte.is_ascii_digit() { self.advance(1); digit_count += 1; // CommonMark limits to 9 digits max if digit_count > MAX_ORDERED_LIST_MARKER_DIGITS { // Too many digits, not a valid marker self.position = start_position; return self.consume_textual_impl::<false>(MarkdownLexContext::Regular); } } else { break; } } // Must have at least one digit if digit_count == 0 { self.position = start_position; return self.consume_textual_impl::<false>(MarkdownLexContext::Regular); } // Must be followed by . or ) let delimiter = self.current_byte(); if !matches!(delimiter, Some(b'.' | b')')) { self.position = start_position; return self.consume_textual_impl::<false>(MarkdownLexContext::Regular); } self.advance(1); // Must be followed by at least one space (or end of line for edge cases) // ... }这段代码清晰地呈现了"合法有序列表标记"的三个必要条件:
- 1~9 位 ASCII 数字:一旦
digit_count超过MAX_ORDERED_LIST_MARKER_DIGITS(9),立即回退(self.position = start_position)并调用consume_textual_impl把整段内容当作普通文本处理; - 数字后紧跟
.或):否则同样降级为文本; - 分隔符后必须跟随空白或行尾:这是 CommonMark 中"标记后面要有空格"要求的体现。
这正好解释了测试用例的第四行:1000000000.在第 10 位数字处触发位数检查,Lexer 判定它不是合法标记,将整行作为文本 token 输出,因此它永远不会成为列表项。此外,Lexer 还在同一文件 crates/biome_markdown_parser/src/lexer/mod.rs 的另一个分支中再次使用digit_count > MAX_ORDERED_LIST_MARKER_DIGITS做防御性检查,确保所有数字开头的路径都遵守同一上限。
Parser 层:9 位标记如何进入有序列表 AST
词法层面产出MD_ORDERED_LIST_MARKERtoken 后,语法分析器需要判定"行首出现有序列表标记"才算一个列表项。这一判定逻辑位于 crates/biome_markdown_parser/src/syntax/list.rs:
/// Check if we're at the start of an ordered list item (e.g., "1.", "2)"). /// /// An ordered list marker is a sequence of 1-9 digits followed by `.` or `)`, /// at the start of a line. pub(crate) fn at_order_list_item(p: &mut MarkdownParser) -> bool { at_order_list_item_with_base_indent(p, list_marker_base_indent(p)) } fn at_order_list_item_with_base_indent(p: &mut MarkdownParser, base_indent: usize) -> bool { p.lookahead(|p| { if !list_item_within_indent(p, base_indent) { return false; } skip_leading_whitespace_tokens(p); // Check for ordered list marker token at line start if !p.at(MD_ORDERED_LIST_MARKER) { return false; } p.bump(MD_ORDERED_LIST_MARKER); marker_followed_by_whitespace_or_eol(p) }) }由于 Lexer 已经完成了 9 位数字的过滤,Parser 只需确认当前 token 是MD_ORDERED_LIST_MARKER且后随空白或行尾,即可安全地把该行作为有序列表项(MdOrderedListItem)进入 AST。
有趣的是,解析器对"纯文本形式的有序标记"(即未通过 Lexer 识别、以普通文本 token 呈现的数字开头行)也有兜底逻辑。crates/biome_markdown_parser/src/syntax/list.rs 中的textual_starts_with_ordered_marker函数同样检查digit_count > MAX_ORDERED_LIST_MARKER_DIGITS并返回false——即便在文本回退路径上,10 位数字也永远不可能被当作有序列表项。两道防线共同保证了规范一致性:无论走哪条路径,1000000000.都不会成为列表项。
对于测试输入而言,结果是确定的:
1. list item、999999999. list item、1. list item(空行分隔)解析为同一个有序列表的三个项,起始号为 1;1000000000. ordered list marker can't have more than 9 digits解析为普通文本内容,排在列表之后。
Formatter 层:列表重编号与标记规范化
解析完成后,轮到格式化器决定输出。Biome 的 Markdown 格式化器会对有序列表做统一重编号,核心实现位于 crates/biome_markdown_formatter/src/bullet_list.rs:
/// The marker choice for a single parsed ordered list node. struct OrderedMarkerPlan { start: usize, delimiter: OrderedListDelimiter, use_git_diff_friendly_numbering: bool, } impl OrderedMarkerPlan { fn from_list(node: &MdBulletList, list_sibling_index: usize) -> Option<Self> { let numbers = node .iter() .filter_map(|bullet| bullet.ordered_marker_number()) .take(3) .collect::<Vec<_>>(); let start = numbers.first().copied()?; let use_git_diff_friendly_numbering = has_git_diff_friendly_ordered_list(&numbers); Some(Self { start, delimiter: ordered_delimiter_for_list(list_sibling_index), use_git_diff_friendly_numbering, }) } /// Returns the ordered marker for a bullet, including its delimiter. fn marker_for_index(&self, index: usize) -> OrderedMarker { let number = if index == 0 { self.start } else if self.use_git_diff_friendly_numbering { 1 } else { self.start.saturating_add(index) }; OrderedMarker::new(number, self.delimiter) } }这段代码揭示了重编号的三条规则:
- 首项保留起始号:
index == 0时输出self.start。CommonMark 以列表首项数字作为起始值,因此格式化后首项仍保持原始起始数字; - 顺序列表递增编号:默认情况下,后续项按
start + index递增。这就是测试输入中999999999.和第二个1.分别被重写为2.、3.的原因——它们属于同一列表,编号被归一化为从 1 开始连续递增; - Git diff 友好编号:当源码采用
1, 1, 1这类"每项都从 1 开始"的写法时,格式化器会保留这种风格。判定函数has_git_diff_friendly_ordered_list的注释(crates/biome_markdown_formatter/src/bullet_list.rs)给出了完整判定矩阵:1, 2, 3是顺序列表、1, 1, 1是 Git diff 友好列表、10, 1, 2按10, 1, 1输出、0, 1是顺序列表、0, 1, 1则判定为 Git diff 友好。其价值在于:中间插入新项时只有一行发生变化,Git 差异更小。
分隔符的选择同样有讲究:ordered_delimiter_for_list(crates/biome_markdown_formatter/src/bullet_list.rs)会依据列表在相邻兄弟列表中的序号,在.与)之间交替,从而避免格式化后相邻的两个列表被重新解析合并成一个。这与无序列表标记(-、*、+)的交替策略(同文件 L440-L448)思路一致。
数字最终如何写入输出,由 crates/biome_markdown_formatter/src/markdown/auxiliary/list_marker_prefix.rs 中的OrderedMarker类型负责——它按位分解数字并用 token 逐位输出,同时附上.或)分隔符;其width()方法则计算"数字位数 + 分隔符宽度",用于对齐多行列表的缩进(min_post_marker_len保证标记后至少有指定数量的空格)。此外,该文件中还处理了有序标记分隔符的规范化:当源码使用1)形式时,格式化器会将其转换为1.(见FormatMdListMarkerPrefix中is_ordered_with_paren()分支)。
Prettier 差异:一处值得注意的行为分歧
快照文件中的 "Prettier differences" 部分记录了 Biome 与 Prettier 在 10 位数字标记上的分歧:
--- Prettier +++ Biome @@ -2,4 +2,4 @@ 2. list item 3. list item -4. ordered list marker can't have more than 9 digits +1000000000. ordered list marker can't have more than 9 digits- Prettier把 10 位数字的行也视为列表项并重编号为
4.,即最终输出为1. 2. 3. 4.四行完整列表; - Biome严格遵守 CommonMark §5.2,将
1000000000.保留为原始文本,只对三个合法标记重编号。
从规范角度讲,Biome 的行为更贴近 CommonMark 原文:"An ordered list marker is a sequence of 1–9 arabic digits"。Prettier 的实现则相对宽松,在位数超限时仍按列表项处理。这类差异正是parser-regression目录存在的意义——把上游(Prettier 测试集)暴露过的边界场景固化为回归用例,防止行为在后续重构中漂移。
测试如何驱动:从测试规格到快照
该测试规格通过宏自动接入测试框架。crates/biome_markdown_formatter/tests/prettier_tests.rs 中有一行:
tests_macros::gen_tests! {"tests/specs/prettier/markdown/**/*.{md}", crate::test_snapshot, ""}gen_tests!宏会遍历tests/specs/prettier/markdown/下的所有.md文件,为每个文件生成一个测试函数;test_snapshot函数(同文件 L13-L29)用PrettierSnapshot比较 Biome 输出与 Prettier 参考输出,同时生成/校验.snap快照。因此issue-17778.md天然成为一个自动化的回归测试,任何对解析器或格式化器的改动如果改变了 9 位数字标记的行为,快照测试都会立即失败告警。
在本地复现该行为(仅查看与运行,不修改仓库)的方式:
# 运行 Biome Markdown 格式化器的全部 prettier 规格测试(含 issue-17778) cargo test -p biome_markdown_formatter # 若安装了 biome CLI,也可直接格式化测试输入验证 biome format crates/biome_markdown_formatter/tests/specs/prettier/markdown/list/parser-regression/issue-17778.md同目录下的其他回归用例(如 issue-19152.md,测试制表符与空格混合的列表缩进)共同构成了有序列表边界场景的覆盖矩阵。
小结:一条规范如何贯穿 Lexer → Parser → Formatter
回顾整个调用链,CommonMark §5.2 的"有序列表标记最多 9 位数字"在 Biome 中被完整地实现为一道贯穿三层的约束:
- Lexer(crates/biome_markdown_parser/src/lexer/mod.rs):数字位数超过 9 即放弃标记识别,降级为文本 token;
- Parser(crates/biome_markdown_parser/src/syntax/list.rs):仅接受
MD_ORDERED_LIST_MARKERtoken 作为列表项起点,并对文本形式的数字行二次校验位数; - Formatter(crates/biome_markdown_formatter/src/bullet_list.rs):对合法列表项统一重编号(保留起始号、连续递增或 Git diff 友好编号),非法标记行保持原文。
issue-17778 这个只有 4 行的测试文件,恰好踩中了这条链路中最微妙的一个边界:999999999.(9 位,合法上限)与1000000000.(10 位,非法)仅一位之差,却走向完全不同的格式化结果。理解这个用例,也就理解了 Biome 在"规范合规"与"格式化友好"之间取舍的典型范式——当两者冲突时,规范优先。
【免费下载链接】biomeA toolchain for web projects, aimed to provide functionalities to maintain them. Biome offers formatter and linter, usable via CLI and LSP.项目地址: https://gitcode.com/gh_mirrors/bi/biome
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考