Prettier Markdown 列表缩进规范化解析:issue-19146 回归测试与列表打印实现原理
【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier
本篇以 tests/format/markdown/list/parser-regression/issue-19146.md 这个 3 行的 Markdown 回归测试夹具为核心,讲解 Prettier 如何处理列表项前导空白中 Tab 与空格混合的“脏”缩进。读完你能掌握:该测试的输入/输出契约、它在 Jest 快照体系中的运行方式,以及 src/language-markdown/print/list.js 中前缀计算与“4 空格红线”的源码级实现。
一、回归测试用例本体:一行 Tab 缩进的嵌套列表
issue-19146.md 的全部正文只有 3 行,其价值恰恰在于每一行的前导空白都刻意不同:
- foo - bar - baz逐行拆解其缩进结构(\t表示 Tab 字符):
| 行 | 前导空白 | 内容 | 特点 |
|---|---|---|---|
| 1 | 1 个空格 | - foo | 列表项允许最多 3 个前导空格,这里只占 1 个 |
| 2 | 3 个空格 | - bar | 嵌套层缩进用了 3 空格而非惯常的 2 空格 |
| 3 | 1 个 Tab + 1 个空格 | - baz | Tab 与空格混用的缩进 |
也就是说,这份输入是一个三层嵌套的无序列表(foo 包含 bar,bar 包含 baz),但源文件用“空格 1、空格 3、Tab+空格”三种不一致的方式表达了层级关系。文件名中的19146对应上游仓库的 issue 编号——该目录 tests/format/markdown/list/parser-regression/ 按命名约定专门归集“解析/打印回归”夹具,同目录还有 issue-7474、issue-9314、issue-10063、issue-11202、issue-12677、issue-17778、issue-19152 等兄弟用例。
这类测试的意义在于:CommonMark 对缩进相当宽容(3 个空格以内的前导空白、Tab 的列宽展开规则都可能出现在真实文档中),但格式化工具的输出必须是确定且稳定的——同样的嵌套结构,无论源文件用空格还是 Tab 表达层级,都必须收敛到同一种规范缩进。
二、期望输出:快照文件中的输入/输出契约
该夹具的预期结果固化在 Jest 快照 tests/format/markdown/list/parser-regression/snapshots/format.test.js.snap 中(issue-19146.md - {"proseWrap":"always"} format 1条目)。快照头部记录的测试选项为:parsers: ["markdown"]、proseWrap: "always"、printWidth: 80 (default)。
输入与输出对照:
输入 输出 ================================ ================================= - foo - foo - bar - bar - baz (4 个空格)- baz规范化结果体现为三条可验证的规律:
- 前导空白被完全重写:顶层
- foo之前的 1 个空格被去除,列表对齐到第 0 列; - 层级缩进收敛为等宽递进:第 2 层 2 个空格、第 3 层 4 个空格,与默认
tabWidth: 2一致——源文件里的“3 空格”和“Tab+空格”全部被抹平; - Tab 被替换为空格:输出中不再出现 Tab 字符,Prettier 的 Markdown 列表缩进一律以空格表达。
三、测试如何运行:runFormatTest 与快照机制
同目录的 tests/format/markdown/list/parser-regression/format.test.js 只有一行核心代码:
runFormatTest(import.meta, ["markdown"], { proseWrap: "always" });即:对当前目录下所有夹具文件,以markdownparser 加上proseWrap: "always"选项执行格式化成对测试,并把每次运行的选项、输入、输出写入快照文件。这解释了为什么快照中的输入/输出是逐字匹配的:任何一次改动若让- foo / 3 空格 / Tab这组输入产生不同的缩进(例如 Tab 被错误保留、或嵌套层级被解析丢失),快照比对就会失败,从而把回归问题拦在提交之前。
本地复验方式:在仓库根目录(Jest 配置见 jest.config.js)针对该目录运行 Jest,例如npx jest tests/format/markdown/list/parser-regression,即可看到该用例的输入、输出与快照比对结果。
四、源码级原理:列表打印机如何决定每行的缩进
Markdown 打印入口在 src/language-markdown/print/,其中列表由 src/language-markdown/print/list.js 的printList/printListItem负责。结合 issue-19146 这个用例,可以把输出结果逐行对应回源码逻辑。
4.1 前缀生成:getPrefix只关心“有序/无序与兄弟序号”
printList内部通过闭包函数getPrefix(见 src/language-markdown/print/list.js#L56-L90)计算每个列表项的标记前缀:无序列表根据列表在兄弟节点中的序号奇偶,取-或*作为前缀;有序列表则按start序号与分隔符./)拼接。对 issue-19146 而言,三层列表全部是无序列表,每层前缀都是 2 字符的-。
关键点在于:前缀的计算完全不读取源文件的原始缩进。解析阶段(解析管线位于 src/language-markdown/parse/,包含 micromark 扩展等模块,从源码结构看 Prettier 基于 unified/micromark 系工具产出 AST)已经只保留了“foo 是 bar 的父项、bar 是 baz 的父项”这一结构信息,- foo的 1 个前导空格、- bar的 3 个前导空格、Tab 的列宽展开都不再参与输出——这正是“Tab 缩进被规范化为 2/4 空格”的根因:缩进是由树深度重新生成的,而不是从原文继承的。
4.2 缩进逐层叠加:align文档构造器
每层列表项的内容被包裹在文档构造器align中(src/language-markdown/print/list.js#L48-L54,align定义于 src/document/builders/align.js):
return [ prefix, align( " ".repeat(prefix.length), printListItem(path, options, print, prefix), ), ];语义是:为列表项内容的每一行叠加“前缀长度”的列偏移。issue-19146 的三层结构因此产生递进偏移——顶层项偏移 0;第二层列表位于第一项内容内,继承 2 列偏移后自身前缀又贡献 2 列,得到- bar;第三层再叠加 2 列,得到 4 空格的- baz。输出中 2/4 空格的等差递进,就是“每层前缀长度 2”沿align链逐层累加的直接结果,与源文件用了何种空白字符无关。
printListItem内对子节点的处理器(src/language-markdown/print/list.js#L95-L117)还有第二道对齐钳制:
const alignment = " ".repeat( clamp(options.tabWidth - listPrefix.length, 0, 3), // 4+ will cause indented code block );tabWidth与列表前缀长度之差被钳制在 0~3 之间,本例中2 - 2 = 0,即不再额外补位。
4.3 “4 空格红线”:为什么补位上限是 3
源码中多处出现同一句注释——4+ will cause indented code block(见 src/language-markdown/print/list.js#L80-L84 与 L110-L112)。这是 CommonMark 的硬约束:列表项内容相对起始列额外缩进 4 个空格(或 Tab)时会被解析为缩进代码块。因此打印器在需要为“紧随列表的缩进代码块”让路时(requiredIndent函数,见 src/language-markdown/print/list.js#L125-L143),前缀补位也刻意截断在 3~4 空格的阈值内,避免格式化行为把普通文本意外变成代码块。issue-19146 虽然不触发这条路径,但它与 issue-19152.md(- foo/-\t\tfoo,即标记后过度缩进的输入)共同守护着这条边界:前者验证“缩进不足/Tab 混用被收敛”,后者验证“过度缩进不会导致内容丢失或被误解析为代码块”。
4.4 预处理的两个辅助标记
打印前,AST 会经过 src/language-markdown/print/preprocess.js 的预处理:
markAlignedList(L369-L407)为每个列表打isAligned标记;对无序列表,源码结构上isAligned恒为true(if (!list.ordered) return true),而有序列表则依据首两项起始列与tabWidth的关系判定是否“对齐式缩进”。由于alignListPrefix(前缀补齐到tabWidth倍数)只在isAligned && node.ordered时启用,issue-19146 的三层无序列表走的是普通前缀路径,输出保持简洁的-前缀;transformIndentedCodeblock(L255-L271)用/^\n?(?: {4,}|\t)/识别以 4 空格或 Tab 起始的代码节点并标记isIndented,为上文“4 空格红线”提供解析侧依据。
五、小结:一份 3 行夹具守护的格式契约
issue-19146 用最小的输入覆盖了一个高价值契约:Markdown 列表的层级结构以解析出的 AST 为准,缩进由tabWidth和前缀长度按层重新生成,源文件中的前导空格、3 空格缩进、Tab+空格混合缩进均不会泄漏到输出中。配合快照文件(snapshots/format.test.js.snap)与 format.test.js 的runFormatTest配置,它能在每次 CI 中自动验证该契约。若要进一步研究,可以沿着本文引用的 print/list.js、print/preprocess.js 与 print/children.js 继续深入 Prettier 的 Markdown 打印管线。
【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考