markdownlint ATX 标题空格规则实战剖析:基于 short-headings.md 场景测试解析 MD018–MD021

原创2026-10-15 19:19:021,652 阅读
文章标签:开发工具Lint静态分析代码质量

markdownlint ATX 标题空格规则实战剖析:基于 short-headings.md 场景测试解析 MD018–MD021

本文以仓库中的 short-headings.md 场景测试文件为核心载体,系统拆解 markdownlint 中 4 条 ATX 标题空格规则(MD018/MD019/MD020/MD021)的触发条件、判定逻辑与自动修复行为。读完本文,你将掌握这些规则在开放式(# Heading)与闭合式(# Heading #)标题上的完整行为矩阵,理解 {MD018:5} 这类测试标记的语义,并能在自己的文档中准确排查、修复同类格式问题。

一、场景测试文件的定位:为何用它讲解规则

在 markdownlint 仓库中,test/short-headings.md 并非普通示例文档,而是一份场景测试输入:它的每一行都经过精心编排,用来在最小篇幅内穷举 ATX 标题空格规则的各种触发形态,并由 {MD###:行号} 标记声明预期报告位置。

markdownlint-test-scenarios.mjs 会把 test/ 目录下所有 .md 文件当作测试用例逐一遍历:先调用 lint() 真实执行全部规则,再解析正文中的 {MD###:行号} 标记生成期望结果,最后断言两者完全一致。因此这份文件的每一处 {MD018:5} 都是经过测试验证的规则行为事实,非常适合作为讲解四条标题空格规则的权威基线。

二、先看文件全貌:结构、分组与禁用的干扰规则

文件首部有一行关键指令:

<!-- markdownlint-disable single-title heading-style -->

这是 markdownlint 的内联配置语法。因为文件里写了多个顶层标题(# A、# MM 等),会触发 MD025/single-title(single-h1)(同一文档不允许出现多个顶级标题);同时文件混用了 # A(atx)与 # E #(atx_closed)两种风格,会触发 MD003/heading-style(标题风格需一致)。测试目标只聚焦空格规则,所以用 disable 指令先屏蔽这两个"噪音"规则,保证报告里只留下 MD018–MD021。

正文共 82 行,可以清晰分成 4 组,每组 20 行、包含 5 个标题和 5 个标记:

组别 标题示例 覆盖规则 标记行号
第 1 组(第 5–23 行) #A、# B、# C、#D#、# E #、# F # MD018 / MD019 / MD020 / MD021 5 / 11 / 15 / 21
第 2 组(第 25–43 行) ##G、## H、## I、##J##、## K ##、## L ## 同上 25 / 31 / 35 / 41
第 3 组(第 45–63 行) #MM、# NN、# OO、#PP#、# QQ #、# RR # 同上 45 / 51 / 55 / 61
第 4 组(第 65–82 行) ##SS、## TT、## UU、##VV##、## WW ##、## XX ## 同上 65 / 71 / 75 / 81

设计意图很清晰:用短标题(1 个或 2 个字符)压缩行宽,让同一组内尽量多放用例;用 # 与 ## 验证规则与标题级别无关;用开放/闭合两种形态分别验证对应的规则对。

三、四条规则的官方定义与参数速览

先给出四条规则在仓库规则文档中的完整定义,它们都属于 headings / spaces 相关标签:

规则 别名 官方描述 触发条件 修复方式
MD018 no-missing-space-atx No space after hash on atx style heading atx 标题的 # 后缺失空格,如 #Heading 在 # 后插入一个空格
MD019 no-multiple-space-atx Multiple spaces after hash on atx style heading atx 标题的 # 后出现多个空格,如 # Heading 删除多余空格,保留一个
MD020 no-missing-space-closed-atx No space inside hashes on closed atx style heading 闭合式标题 #Heading# 任一侧缺失空格 两侧补上单个空格
MD021 no-multiple-space-closed-atx Multiple spaces inside hashes on closed atx style heading 闭合式标题 # Heading # 任一侧出现多个空格 删除两侧多余空格

需要特别留意两条"闭合式"规则的边界行为:

  • MD020 的任一侧语义:只要 # 与正文之间有任何一侧缺空格就会报告(见 doc/md020.md 的 Note)。#D#、##J## 属于两侧同时缺失,同样命中。
  • MD021 的任一侧语义:闭合式标题任意一侧出现多个空格即报告(见 doc/md021.md 的 Note)。由于两侧可以同时违规,一行最多产生两条 MD021 报告——这正是本文件第 21、41、61、81 行各有两条标记的原因。

四、逐组解读 20 个用例的触发矩阵

4.1 开放 atx 标题:缺空格与多空格(MD018 / MD019)

先看第一组中开放的 3 个标题:

#A        → {MD018:5}  缺空格,需在 # 后补一个空格
# B       → 合规       单个空格是标准写法
#  C      → {MD019:11} 两个空格,多余一个

对应到 ## 级别完全等价(第 25、31 行),验证了规则对标题级别不敏感。第 3 组、第 4 组的 #MM、# OO、##SS、## UU 进一步证明:标题文本长度(1 个字符还是 2 个字符)不影响判定,判定的唯一依据是 # 序列与正文之间的空格数量是否为 1。

4.2 闭合 atx 标题:两侧空格校验(MD020 / MD021)

闭合式标题需要同时校验"开头一侧"和"结尾一侧":

#D#      → {MD020:15} 两侧都缺空格
# E #    → 合规        两侧各一个空格
#  F  #  → {MD021:21} 两侧各有两个空格,报告两条

##J##、## K ##、## L ##(第 35、41 行)及第 3、4 组的 #PP#、# QQ #、# RR #、##VV##、## WW ##、## XX ## 均复现同一模式。其中 MD021 在 # F #(第 21 行)、## L ##(第 41 行)、# RR #(第 61 行)、## XX ##(第 81 行)上都是一行两条报告,合计 8 条,与快照中的输出完全一致。

4.3 汇总:20 条预期报告

规则 预期报告位置 数量
MD018 5、25、45、65 4
MD019 11、31、51、71 4
MD020 15、35、55、75 4
MD021 21、41、61、81(每行 2 条) 8

合计 20 条。这个矩阵同时验证了快照中所有 errors 条目的行号与 ruleNames 字段。

五、测试标记 {MD018:5} 的语法与校验机制

文件正文里的 {MD018:5} 并不是渲染给读者看的内容,而是测试骨架使用的期望声明。其语法在 markdownlint-test-scenarios.mjs 中由正则 /\{(MD\d+)(?::([-+]?)(\d+))?\}/g 定义:

  • MD018:期望命中的规则名(取错误 ruleNames 数组的第一个元素,即标准名);
  • 冒号后的数字:默认表示绝对行号,例如 {MD018:5} 表示"第 5 行应报告 MD018";
  • 可选的 +/- 前缀:表示相对行号偏移,例如 {MD020:+2} 表示"当前标记行往下数 2 行应报告 MD020"。

测试执行时,骨架收集实际 lint 结果中每条错误的行号,与解析出的期望行号数组逐条 deepEqual 比对,任何多报、漏报或行号错位都会导致测试失败。这意味着文件里每个标记的位置都经过真实引擎验证,本文件因此可以作为四条规则行为的最小回归样本库。

六、源码级原理:四条规则如何实现

6.1 MD018:正则驱动的逐行扫描

lib/md018.mjs 的实现是四条规则中最直观的:它逐行检查,核心判定为

/^#+[^# \t]/.test(line) && !/#\s*$/.test(line)
  • 第一个正则命中"# 序列后紧跟非 #、非空格、非制表符的字符",即缺空格;
  • 第二个正则排除行尾本身就是 # 的闭合式标题(那是 MD020/MD021 的管辖范围);
  • 此外还通过 filterByTypesCached(["codeFenced", "codeIndented", "htmlFlow"]) 收集代码块、HTML 块的行号,把这些行从检查范围中剔除,避免误报代码内容;
  • 命中后生成 fixInfo:editColumn 为 # 序列长度 + 1,insertText 为单个空格,即"在缺空格的位置插入一个空格"。

6.2 MD019 与 MD021:基于 micromark 词法结构的校验

lib/md019-md021.mjs 共用同一个内部函数 validateHeadingSpaces(onError, heading, delta),这是两条规则共享的核心逻辑:

  1. 先用 filterByTypesCached(["atxHeading"]) 拿到所有 atx 标题 token;
  2. 用 getHeadingStyle()(定义在 helpers/micromark-helpers.cjs)判断风格:统计标题 token 子节点中 atxHeadingSequence 的个数,1 个判定为开放 atx,2 个判定为闭合 atx_closed;
  3. 在 token 子节点序列中定位 atxHeadingSequence,取其相邻的 whitespace 节点:若其 text.length > 1(即连续空格超过一个),就按该空格的起止列生成 fixInfo(deleteCount 为多余空格数);
  4. MD019 只沿 delta = 1 方向检查开头一侧;MD021 以 delta = 1 和 delta = -1 双向检查开头与结尾两侧——这正是闭合式标题一行最多报两条 MD021 的结构性原因。

6.3 规则元数据与可修复标记

四条规则都声明了 "parser": "micromark"(MD019/MD021 依赖词法树定位 whitespace 节点),并携带 names、description、tags 元数据。仓库在 lib/constants.mjs 中通过 fixableRuleNames 数组统一登记可自动修复的规则,MD018–MD021 全部在列,与 markdownlint-test-scenarios.mjs 中"带 fixInfo 的错误必须出现在可修复名单中"的断言相互印证。

七、自动修复:快照中的修复结果实证

场景测试在断言报告位置后,还会调用 applyFixes(content, errors) 对文件执行自动修复,并把结果写入快照 markdownlint-test-scenarios.mjs.snapshot(short-headings.md 一节位于该文件第 41529 行起)。修复逻辑的输入输出可从快照 fixed 字段完整还原:

原始文本 修复动作(fixInfo) 修复后文本
#A 第 2 列插入空格 # A
# C 第 3 列删除 1 个字符 # C
#D# 整行替换为 # D # # D #
# F # 第 3 列、第 6 列各删除 1 个字符 # F #

快照还显示:修复后立即对结果再次 lint 必须零残留可修复错误(见 markdownlint-test-scenarios.mjs)。也就是说,这份文件的 20 条违规全部可以通过 applyFixes 一键修复,无需手工处理。

对应到实际工程中,你可以在使用 markdownlint 时开启 fix 流程(如 markdownlint-cli 的 --fix 或编辑器插件自动修复),把这类空格问题批量收敛。

八、如何复现与验证

在仓库根目录安装依赖后即可复现本文件的全部断言:

npm install
node --test test/markdownlint-test-scenarios.mjs

该测试会遍历 test/*.md,其中就包含 short-headings.md;所有标记与真实报告一致、快照一致、修复后可再次 lint 通过,测试才算通过。若只想看单文件报告,可借助 test/harness.mjs 这类工具传入文件路径执行 lint 并打印结构化结果。

九、实战建议:把规则矩阵迁移到你的文档

  • # 与正文之间永远只保留一个空格:#Heading(缺空格)与 # Heading(多空格)分别命中 MD018 与 MD019,这是渲染正确性与格式一致性的双重要求(详见 doc/md018.md、doc/md019.md 的 Rationale)。
  • 闭合式标题必须双侧各一个空格:MD020/MD021 的"任一侧"判定意味着 #Text#、# Text#、#Text #、# Text # 全部违规;若团队文档大量使用闭合式 atx 风格,建议开启这两条规则以强制双侧规范。
  • 代码块与 HTML 块不受影响:MD018 实现中显式排除了 codeFenced、codeIndented、htmlFlow 区域,避免误伤代码示例,因此无需为代码里的 #foo 写豁免。
  • 冲突规则需按需禁用:若你的测试或文档同样存在多个顶级标题、混合标题风格,可仿照本文件在头部使用 <!-- markdownlint-disable single-title heading-style --> 内联指令临时屏蔽 MD025 与 MD003,聚焦当前要排查的规则维度。
登录后查看全文
markdownlint