- 开发工具
- 代码质量
- CLI
【免费下载链接】vale
:pencil: A markup-aware linter for prose built with speed and extensibility in mind.
本文围绕 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.这条规则有两个特殊点:
nonword: true使匹配不受词边界约束,因此,,、,,,这类非单词字符也能被命中;- 替换值
',|...'中的|是建议分隔符,表示“可以用单个逗号,也可以用省略号”两个候选。源码中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 的执行链路
把夹具行为映射回源码,可以看清一次完整的规则生命周期:
规则构建(
NewSubstitution,internal/check/substitution.go):- 解析
swap、exceptions等字段;Affix非空时先用词缀文件展开每个键; - 所有键按长度降序排序,若多个键共享同一个前置 lookaround(如
(?<=...)),会被sharedLookaround提到交替外层,把重复的零宽断言求值次数从“每个词每位置一次”降为“每位置一次”(有单测 TestSharedLookaround 覆盖); - 键里的捕获组被
convertCaptureGroups改写为非捕获组,避免破坏交替分支与repl替换表的索引对应关系。
- 解析
运行匹配(
Run方法):- 先做两次廉价预判——
MightMatch(blk.Lower)基于预筛词表快速否决,MatchStringStd找到首个命中即止,避免对无命中块调用FindAllStringSubmatchIndex;源码注释明确说明这是实测更省的写法; - 对每个命中位置:若观测文本已是可接受形式(
replace动作下按字面量比对各候选,防止...这类候选被误当成正则——对应 issue #1038 的注释)、命中Exceptions、处于短语内部、或被词汇表接受,则静默跳过; - 否则按
capitalize/MatchCase调整候选大小写(recase),填入Action.Params与Suggestions,最终由alertFor生成告警并anchor到块内位置。
- 先做两次廉价预判——
告警呈现:
message中的两个%s由formatMessages填充为“建议”与“观测值”,这正是want:输出中Use 'JavaScript' rather than javascript.这类文案的来源。
如何复现与验证
在本仓库内运行这条 e2e 用例:
go test ./internal/e2e -run 'TestScenarios/checks/substitution'测试框架会自动编译 cmd/vale 下的二进制、进入夹具目录执行vale .并比对上述want:输出。如果想手工体验同样效果,可在任意项目中仿照本夹具组织:
- 建
styles/Bugs/Newline.yml等规则文件,写法照抄上文任一 YAML; - 在项目
.vale.ini中写StylesPath = styles/并用[文件名]小节按文件启用规则; - 运行
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.
相关推荐
vale metric 检查实战:以 e2e 样例文档 test.md 为主线,剖析公式型规则如何被求值
vale metric 检查实战:以 e2e 样例文档 test.md 为主线,剖析公式型规则如何被求值 vale 的 metric 检查类型允许用一条可动态求
开发工具代码质量CLIvale 的 capitalization 测试夹具详解:Capitalization/test.md 如何验证标题大小写、句首大写与阈值行为
vale 的 capitalization 测试夹具详解:Capitalization/test.md 如何验证标题大小写、句首大写与阈值行为 本篇技术文章围绕
开发工具代码质量CLIVale测试和验证:如何确保你的自定义规则准确无误
Vale测试和验证:如何确保你的自定义规则准确无误 Vale是一个功能强大的 语法检查工具 ,专门为技术文档和写作内容设计。作为一个 语法检查器 ,Vale可以
开发工具代码质量CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考