☰
Vale Substitution 检查测试剖析:test.md 中一行行文本如何验证 9 条替换规则的全部边界
2026/10/15 3:47:04 网站建设 项目流程
  • 开发工具
  • 代码质量
  • CLI

【免费下载链接】vale

:pencil: A markup-aware linter for prose built with speed and extensibility in mind.

项目地址:https://gitcode.com/gh_mirrors/va/vale
点击查看免费下载

本文围绕 Vale 仓库中的 e2e 测试夹具 test.md 展开。这份看似平淡的测试文本,实际上是 Valesubstitution类检查的一次“全要素回归”:它与同目录下的 test2.md、9 个 YAML 规则文件以及 .vale.ini 配置一起,完整验证了swap替换、ignorecase、capitalize、nonword、捕获组引用、URL 上下文排除、空替换等一系列核心行为。读完本文,你将掌握 substitution 规则的完整配置写法,并理解每条测试行与源码实现的精确对应关系。

测试夹具的构成:一个目录,一次全量回归

substitution是 Vale 中最常用的检查类型,规则通过extends: substitution声明,并在swap字段中给出“坏写法 → 好写法”的映射。本目录是 testdata/e2e/checks.yaml 中名为substitution的 e2e 用例的工作目录:

# testdata/e2e/checks.yaml - name: substitution dir: Substitution args: . exit: 1 want: | test.md:5:5:Bugs.Newline:Use 'test' rather than 'linuxptp'. ...

测试框架(见 internal/e2e/suite_test.go 与 internal/e2e/e2e_test.go)会先构建 Vale 二进制,再进入testdata/fixtures/checks/Substitution/目录执行vale .(附带--output=line --sort --normalize --relative --no-global固定参数),并把实际输出与want:逐行比对。用例期望退出码为1——因为确实检出了告警。

目录内共三个文件:

  • test.md:验证 8 条Bugs.*规则的主测试文本;
  • test2.md:验证demo.CapSub(捕获组 +scope: raw)与Bugs.WrongExp(nonword正则替换);
  • .vale.ini:按文件粒度启用规则的配置文件。

.vale.ini:按文件启用规则

夹具的配置值得完整摘录,它展示了 Vale 的两种典型启用方式——全局启用(直接Rule = YES)与按文件启用([文件]小节):

# testdata/fixtures/checks/Substitution/.vale.ini StylesPath = ../../../styles/ Vocab = Cap [test.md] Bugs.Newline = YES Bugs.URLCtx = YES Bugs.MatchCase = YES Bugs.KeepCase = YES Bugs.TermCase = YES Bugs.SameCase = YES Bugs.EmptyReplace = YES Bugs.Commas = YES [test2.md] demo.CapSub = YES Bugs.WrongExp = YES

这里有两个细节:StylesPath指向仓库统一的测试样式库 testdata/styles,其中Bugs/与demo/子目录存放了本夹具用到的全部规则;Vocab = Cap加载了 testdata/styles/config/vocabularies/Cap 词汇表,用于验证“词汇表已接受的词不应再告警”这一排除逻辑。

test.md 逐行解析:8 条规则,8 组测试文本

下面按规则分组解读 test.md 的 33 行内容。每一组都对应一条Bugs/下的 YAML 规则和一个精确的期望输出(引自 testdata/e2e/checks.yaml 的want:块)。

Bugs.Newline:代码片段内的词不告警

测试文本(第 5–8 行):

The linuxptp resource. The `linuxptp` resource. The linuxptp resource.

规则 testdata/styles/Bugs/Newline.yml:

extends: substitution ignorecase: false level: error message: Use '%s' rather than '%s'. action: name: replace swap: linuxptp: test

期望输出只在第 5 行和第 8 行告警,第 7 行的反引号代码`linuxptp`被跳过:

test.md:5:5:Bugs.Newline:Use 'test' rather than 'linuxptp'. test.md:8:5:Bugs.Newline:Use 'test' rather than 'linuxptp'.

这是 substitution 检查的基本盘:Vale 的 lint 引擎按“文本块/代码块”切分文档,substitution规则默认只在正文文本中运行,行内代码块天然被排除。ignorecase: false则意味着Linuxptp、LINUXPTP这类大小写变体不会被命中。

Bugs.URLCtx:URL 路径中的词不告警

测试文本(第 10–13 行):

term https://abc.com/term https://abc.com/term

规则 testdata/styles/Bugs/URLCtx.yml:

extends: substitution message: Use '%s' instead of '%s' level: error swap: "term": Term

注意这条规则没有写ignorecase: false,而 swap 的键带引号,默认按词边界匹配。期望输出:

test.md:10:1:Bugs.URLCtx:Use 'Term' instead of 'term' test.md:17:14:Bugs.URLCtx:Use 'Term' instead of 'term'

第 11、13 行的 URL 内term均不告警——Vale 在词法解析阶段就会把 URL 识别为独立 token 并豁免。第 17 行Mankind is a term.中句尾的裸term照常命中,说明豁免只针对 URL 上下文,而非整个句子。

Bugs.MatchCase:capitalize + 大小写自适应

测试文本(第 15–17 行):

The innovation was good for mankind. Mankind is a term.

规则 testdata/styles/Bugs/MatchCase.yml:

extends: substitution message: "Consider using '%s' instead of '%s'." ignorecase: true capitalize: true level: error action: name: replace swap: mankind: human kind

期望输出:

test.md:15:29:Bugs.MatchCase:Consider using 'human kind' instead of 'mankind'. test.md:17:1:Bugs.MatchCase:Consider using 'Human kind' instead of 'Mankind'.

这组同时验证了两个机制:ignorecase: true使句中mankind被命中;capitalize: true使句首的Mankind得到的建议自动变为大写开头的Human kind,而不是无脑给出小写建议。源码中,这个行为由 internal/check/substitution.go 的subMsg函数实现:当观测到的词首字母大写时,core.CapFirst(expected)会把替换值一并转为首字母大写。

Bugs.KeepCase:精确大小写匹配

测试文本(第 19 行):

The Mutual TLS is a good practice. VNIC is another word.

规则 testdata/styles/Bugs/KeepCase.yml:

extends: substitution ignorecase: false level: error message: "Use %s rather than %s." action: name: replace swap: Mutual TLS: mutual TLS VNIC: vNIC

期望输出:

test.md:19:5:Bugs.KeepCase:Use 'mutual TLS' rather than Mutual TLS. test.md:19:36:Bugs.KeepCase:Use 'vNIC' rather than VNIC.

这条规则演示了多词短语替换:swap的键可以是完整短语Mutual TLS,且ignorecase: false使匹配严格区分大小写——这正是“保留正确专有名词大小写”场景的标准写法(TLS 必须全大写,只有Mutual需要改小写)。

Bugs.TermCase:ignorecase 统一术语写法

测试文本(第 21、23 行):

Javascript is a programming language. I developed a javascript application. IOS is a mobile operating system.

规则 testdata/styles/Bugs/TermCase.yml:

extends: substitution ignorecase: true level: error message: "Use %s rather than %s." action: name: replace swap: javascript: JavaScript ios: iOS

期望输出:

test.md:21:1:Bugs.TermCase:Use 'JavaScript' rather than Javascript. test.md:21:53:Bugs.TermCase:Use 'JavaScript' rather than javascript. test.md:23:1:Bugs.TermCase:Use 'iOS' rather than IOS.

ignorecase: true让同一行内的Javascript和javascript都被命中并统一替换为规范写法JavaScript;ios→iOS则覆盖了“正确写法并非全小写/全大写”的术语。这是团队术语表(terms)最常见的形态。

Bugs.SameCase:正则键归一化多种拼写

测试文本(第 25–29 行):

mpl 2.0 MPL2.0 MPL 2.0

规则 testdata/styles/Bugs/SameCase.yml:

extends: substitution ignorecase: true level: warning message: Use '%s' instead of '%s' action: name: replace swap: MPL: MPL MPL[ -]?2(?:\.0)?: MPL 2.0

期望输出:

test.md:25:1:Bugs.SameCase:Use 'MPL 2.0' instead of 'mpl 2.0' test.md:27:1:Bugs.SameCase:Use 'MPL 2.0' instead of 'MPL2.0'

这组验证了swap的键本身就是正则表达式:MPL[ -]?2(?:\.0)?一个模式同时覆盖mpl 2.0、MPL2.0等写法,统一归一为MPL 2.0。第 29 行已是规范写法,不产生告警。从源码结构看,所有键会被按长度降序排列后拼进同一个交替(alternation)模式——见 internal/check/substitution.go 中NewSubstitution的sort.Slice与tokens += "(" + regexstr + ")|"逻辑,因此长模式优先匹配,MPL[ -]?2...不会被MPL抢先截断。

Bugs.EmptyReplace:替换为空(直接删除)

测试文本(第 31 行):

The answer is obvious.

规则 testdata/styles/Bugs/EmptyReplace.yml:

extends: substitution message: "Use '%s' instead of '%s'." ignorecase: false level: warning action: name: replace swap: (?:obvious|obviously|Obviously): ""

期望输出:

test.md:31:15:Bugs.EmptyReplace:Use instead of 'obvious'.

swap 值为空字符串即“建议删除该词”。注意键里用了一个捕获组(?:...)——源码会自动把捕获组转为非捕获组(convertCaptureGroups,有对应单测 TestConvertGroups),因为多键交替模式下捕获组编号会影响替换建议的映射。

Bugs.Commas:nonword + 多个候选建议

测试文本(第 33 行):

Two,, commas and three,,, commas.

规则 testdata/styles/Bugs/Commas.yml:

extends: substitution message: "Use a single comma or an ellipsis, not multiple consecutive commas." level: error nonword: true action: name: replace swap: ',{2,}': ',|...'

期望输出:

test.md:33:4:Bugs.Commas:Use a single comma or an ellipsis, not multiple consecutive commas. test.md:33:23:Bugs.Commas:Use a single comma or an ellipsis, not multiple consecutive commas.

这条规则有两个特殊点:

  1. nonword: true使匹配不受词边界约束,因此,,、,,,这类非单词字符也能被命中;
  2. 替换值',|...'中的|是建议分隔符,表示“可以用单个逗号,也可以用省略号”两个候选。源码中getOptions函数(internal/check/substitution.go)按未转义的|拆分建议,转义的\|则视为字面量管道符。

test2.md:捕获组引用与 raw 作用域

第二个夹具 test2.md 只有 5 行正文,但验证了两个更高级的能力:

# Test Change into the `/etc` directory. Within the home directory. Change into the `/home/user` directory. Change into the `/etc/X11` directory. Blue vs red.

对应的规则 testdata/styles/demo/CapSub.yml:

extends: substitution message: "Use '%s' instead of '%s'." level: error scope: raw capitalize: true ignorecase: true action: name: replace swap: "change in(?: )?to the (.*) directory": change to the $1 directory

期望输出:

test2.md:3:1:demo.CapSub:Use 'Change to the `/etc` directory' instead of 'Change into the `/etc` directory'. test2.md:7:1:demo.CapSub:Use 'Change to the `/home/user` directory' instead of 'Change into the `/home/user` directory'. test2.md:9:1:demo.CapSub:Use 'Change to the `/etc/X11` directory' instead of 'Change into the `/etc/X11` directory'. test2.md:11:6:Bugs.WrongExp:Use 'versus' instead of 'vs '.

这组输出展示了 substitution 的三个高级特性:

  • 捕获组引用:替换值中的$1引用键里(.*)捕获的内容,因此三个不同的路径各得到各异的替换建议,而非一个固定的死值。实现上,subMsg会用键本身作为正则对观测文本执行一次Replace,把$1展开(见 internal/check/substitution.go 中msgRe.Replace(observed, expected, -1, -1));
  • scope: raw:建议文本保留了行内代码反引号`/etc`。默认 scope 下 Vale 只把“文本”交给规则,反引号会被剥掉;scope: raw则把原始标记一起暴露给规则,适合需要感知行内标记的高级场景;
  • nonword正则键:同文件启用的 testdata/styles/Bugs/WrongExp.yml 用nonword: true把e.g.、i.e.、etc.、vs.、vs(含尾部空格)等缩写统一替换为for example、that is、and so on、versus,末行Blue vs red.正是其中\b(?:[Vv][Ss]\s)分支的验证。

从源码看 substitution 的执行链路

把夹具行为映射回源码,可以看清一次完整的规则生命周期:

  1. 规则构建(NewSubstitution,internal/check/substitution.go):

    • 解析swap、exceptions等字段;Affix非空时先用词缀文件展开每个键;
    • 所有键按长度降序排序,若多个键共享同一个前置 lookaround(如(?<=...)),会被sharedLookaround提到交替外层,把重复的零宽断言求值次数从“每个词每位置一次”降为“每位置一次”(有单测 TestSharedLookaround 覆盖);
    • 键里的捕获组被convertCaptureGroups改写为非捕获组,避免破坏交替分支与repl替换表的索引对应关系。
  2. 运行匹配(Run方法):

    • 先做两次廉价预判——MightMatch(blk.Lower)基于预筛词表快速否决,MatchStringStd找到首个命中即止,避免对无命中块调用FindAllStringSubmatchIndex;源码注释明确说明这是实测更省的写法;
    • 对每个命中位置:若观测文本已是可接受形式(replace动作下按字面量比对各候选,防止...这类候选被误当成正则——对应 issue #1038 的注释)、命中Exceptions、处于短语内部、或被词汇表接受,则静默跳过;
    • 否则按capitalize/MatchCase调整候选大小写(recase),填入Action.Params与Suggestions,最终由alertFor生成告警并anchor到块内位置。
  3. 告警呈现:message中的两个%s由formatMessages填充为“建议”与“观测值”,这正是want:输出中Use 'JavaScript' rather than javascript.这类文案的来源。

如何复现与验证

在本仓库内运行这条 e2e 用例:

go test ./internal/e2e -run 'TestScenarios/checks/substitution'

测试框架会自动编译 cmd/vale 下的二进制、进入夹具目录执行vale .并比对上述want:输出。如果想手工体验同样效果,可在任意项目中仿照本夹具组织:

  1. 建styles/Bugs/Newline.yml等规则文件,写法照抄上文任一 YAML;
  2. 在项目.vale.ini中写StylesPath = styles/并用[文件名]小节按文件启用规则;
  3. 运行vale <目录或文件>,退出码0表示无告警、1表示检出告警,与 e2e 用例中exit: 1的断言一致。

小结

test.md 表面上是一段 33 行的英文散文,实则是 substitution 检查能力矩阵的逐行注脚:正文/代码/URL 三类上下文的匹配边界(Newline、URLCtx)、大小写策略的三种形态(MatchCase 的capitalize、KeepCase 的精确匹配、TermCase 的ignorecase归一)、正则键与捕获组替换(SameCase、CapSub)、空替换与多候选建议(EmptyReplace、Commas 的nonword+|分隔),以及词汇表接受词的静默排除(Vocab = Cap)。配合 testdata/e2e/checks.yaml 中逐字符精确的期望输出,它既是 Vale 替换检查的“活文档”,也是自写规则时可直接对照的行为基线。

  • 开发工具
  • 代码质量
  • CLI

【免费下载链接】vale

:pencil: A markup-aware linter for prose built with speed and extensibility in mind.

项目地址:https://gitcode.com/gh_mirrors/va/vale
点击查看免费下载

相关推荐

上一篇:DeepSeek Harness 的 slot 类型链体系:单一 register 组合 UI、props 四份额与框架 store 席位
下一篇:Prisma 集群部署实战:使用 Docker Machine 将 Prisma 部署到 Digital Ocean Droplet

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询