首页
/ bat 语法高亮回归测试详解:以 Markdown/TypeScript 高亮夹具与嵌套作用域着色为例

bat 语法高亮回归测试详解:以 Markdown/TypeScript 高亮夹具与嵌套作用域着色为例

2026-09-05 16:17:41作者:范靓好Udolf

本文以 bat(A cat(1) clone with wings)仓库中的一对语法测试夹具为主体:被 bat 高亮后的 Markdown 文件 typescript.md 及其原始源文件 typescript.md。通过逐段解析该夹具中的 ANSI 转义序列、对照生成与比对脚本 create_highlighted_versions.pycompare_highlighted_versions.py,你将掌握 bat 如何在 Markdown 围栏代码块内嵌套高亮 TypeScript、如何阅读 24 位真彩色的语法着色输出,以及如何用 update.sh 复现并比对该高亮结果以做回归验证。

这个夹具是什么:一对“源文件 → 高亮输出”的回归测试工件

在 bat 的语法测试体系中,每个被支持的语言都维护两份文件:

  • 源文件tests/syntax-tests/source/<语言目录>/<文件名>,是普通的、未着色的文本。本文的主体源文件 tests/syntax-tests/source/Markdown/typescript.md 是一份标题为 # Typescript test 的 Markdown 文档,其中嵌入了一个完整的 TypeScript 围栏代码块;
  • 高亮输出tests/syntax-tests/highlighted/<语言目录>/<文件名>,是把 bat 对该源文件的高亮结果(含 ANSI 转义码)原样落盘得到的“快照”。tests/syntax-tests/highlighted/Markdown/typescript.md 就是本文分析的这份快照。

这类测试的价值在于:sublime 语法文件(.sublime-syntax)与主题(.tmTheme)一旦发生改动,着色结果就可能变化。把预期输出固化成文件后,CI 只要重新运行一遍高亮并做 diff,就能发现“某个 token 的颜色/斜体被意外改掉”这类回归。

用固定选项复现高亮:update.shcreate_highlighted_versions.py

快照不是手工制作的,而是由脚本调用 bat 二进制生成。入口是一行式脚本 update.sh

#!/usr/bin/env bash
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
python="python3"
if ! command -v python3 &>/dev/null; then python="python"; fi
"$python" create_highlighted_versions.py -O highlighted

它调用 create_highlighted_versions.py,该脚本对 source/ 下每个子目录的文件并行执行 bat 并把 stdout 写入 highlighted/ 对应路径。为保证结果可复现,脚本在两个层面做了固定:

  1. 命令行选项固定BAT_OPTIONS,见该文件 L13-L19):

    BAT_OPTIONS = [
        "--no-config",          # 不读取任何用户配置
        "--style=plain",        # 去掉行号、文件头、分隔线
        "--color=always",       # 强制输出 ANSI 颜色
        "--theme=Monokai Extended",  # 固定主题,避免 default 因系统外观不同而变化
        "--italic-text=always", # 强制斜体语义,避免终端不支持时丢失斜体标记
    ]
    

    脚本注释专门解释了为什么不用 default 主题——它在 macOS 上会跟随系统外观明暗切换,导致快照不稳定。

  2. 环境变量清洗:子进程环境中剥除 BAT_CACHE_PATHBAT_CONFIG_DIRBAT_THEMEBAT_OPTSNO_COLORPAGER 等全部可能影响输出的变量,并显式设置 COLORTERM=truecolor(见 L45-L56),确保输出的是 24 位真彩(38;2;R;G;B)而非退化的 256 色或 16 色。

另外,脚本支持在某个源目录下放置 bat_options 文件为个别语言追加选项(get_options,L29-L40)。例如本仓库中 Log 等目录的源就利用了这一机制。

比对环节由 compare_highlighted_versions.py 完成:它对两个 highlighted/ 目录做 difflib.unified_diff,任何一个文件有差异即输出统一 diff 并以退出码 1 结束,同时提示新增语言目录需要运行 update.sh 补夹具(L45-L47)。

阅读高亮输出:ANSI 序列逐段拆解

现在逐行解读主体文档 tests/syntax-tests/highlighted/Markdown/typescript.md。它每一行都由形如 ESC[<序列>m 的 SGR(Select Graphic Rendition)序列与文本交替组成。以第 1 行(Markdown 一级标题)为例:

[38;2;253;151;31m#[0m[38;2;253;151;31m [0m[38;2;253;151;31mTypescript test[0m

38;2;253;151;31 是 24 位 RGB 颜色(#FD971F,橙色),[0m 复位。标题整行使用同一颜色,说明 Markdown 语法规则把 # 标题 整体匹配为 heading scope。

主题色板与 token 的对应关系

快照使用 Monokai Extended 主题(对应主题资源在 assets/themes/ 下打包为二进制资产),从输出中可以提取出完整的 token→颜色映射:

Token 类别 源文件中的例子 ANSI 序列 颜色(RGB) 语义
Markdown heading #Typescript test [38;2;253;151;31m #FD971F 橙,Markdown 标题
围栏代码标记 ``` [38;2;255;255;255m #FFFFFF 白,围栏本身
语言信息字符串 typescript [38;2;190;132;255m #BE84FF 紫,Markdown fence info
存储关键字 enuminterfaceclassfunctionconst [3;38;2;102;217;239m #66D9EF + 斜体 青,且带斜体(S001 类 scope 惯例)
控制/类型关键字 privatereturnnewifasynctypeofas [38;2;249;38;114m #F92672 粉红
类型名(entity.name.type) StatusTaskTaskManagerPromise [38;2;166;226;46m #A6E22E 绿,类/接口/枚举名
函数/方法名 addTaskfetchTasksisTaskfilterlog [38;2;166;226;46m #A6E22E 绿,entity.name.function
普通标识符 tasksidtitle [38;2;255;255;255m #FFFFFF 白,plain
标点/运算符 :;{[====? [38;2;249;38;114m #F92672 粉红(punctuation)
字符串 "Write docs"'number'、模板串 [38;2;230;219;116m #E6DB74
数字字面量 500123 [38;2;190;132;255m #BE84FF
注释 // Simulate async fetch [38;2;117;113;94m #75715E

值得注意的两个细节:

  • 关键字带斜体enuminterface 等前面多了 3; 序列(SGR 3 = 斜体),这正是 --italic-text=always 的作用——即使目标终端未必支持斜体,bat 也会保留斜体标记,从而让快照对“斜体语义是否被语法规则丢失”这一回归也敏感;
  • 嵌套作用域(embedded scope)``` 之后,着色规则从 Markdown 语法规则切换为 TypeScript 语法规则(关键字、类型名、字符串各自独立上色),直到收尾的 ``` 处又切回 Markdown。这说明 bat 的语法引擎(基于 syntect)正确处理了 Sublime 语法体系中的 embed/嵌套 scope 机制——这是该夹具最核心的测试点:不仅 Markdown 本身要高亮对,内嵌的 TypeScript 也必须高亮对。

源文件中承载的 TypeScript 知识点

源文件 tests/syntax-tests/source/Markdown/typescript.md 的代码块刻意覆盖了 TypeScript 的主要语法形态,让每一类 token 都至少出现一次。完整内容如下:

enum Status {
    Pending,
    InProgress,
    Completed,
}

interface Task {
    id: number;
    title: string;
    status: Status;
    assignee?: string;
}

class TaskManager<T extends Task> {
    private tasks: T[] = [];

    addTask(task: T): void {
        this.tasks.push(task);
    }

    getTasksByStatus(status: Status): T[] {
        return this.tasks.filter(task => task.status === status);
    }

    async fetchTasks(): Promise<T[]> {
        // Simulate async fetch
        return new Promise(resolve => setTimeout(() => resolve(this.tasks), 500));
    }
}

// Type guard
function isTask(obj: any): obj is Task {
    return typeof obj.id === 'number' && typeof obj.title === 'string';
}

// Usage
const manager = new TaskManager<Task>();
manager.addTask({ id: 1, title: "Write docs", status: Status.Pending });
manager.addTask({ id: 2, title: "Review PR", status: Status.InProgress, assignee: "Alice" });

(async () => {
    const allTasks = await manager.fetchTasks();
    allTasks.forEach(task => {
        if (isTask(task)) {
            console.log(`Task #${task.id}: ${task.title} [${Status[task.status]}]`);
        }
    });
})();

// Type assertion
const unknownValue: unknown = { id: 3, title: "Test", status: Status.Completed };
const assertedTask = unknownValue as Task;
console.log(assertedTask.title);

对照高亮快照可以验证各处的着色是否符合预期:

  • 枚举与接口(源文件 L4-L15):enum/interface 为青色斜体关键字,Status/Task 为绿色类型名,成员 id: number;: 为粉红、内置类型 number 显示为绿色斜体([3;38;2;166;226;46m,与类型名同色但带斜体,属于 Monokai Extended 对 support.type 的处理),可选成员标记 ? 归入粉红标点;
  • 泛型类(L17-L32):classextendsprivateasync 各归关键字色;TaskManager<T extends Task> 中的 TTask 为绿色,尖括号 <> 为粉红;方法 addTask/getTasksByStatus/fetchTasks 为绿色函数名,参数名 task/status 为橙红色变量色([38;2;253;151;31m,与 Markdown 标题同色,对应 variable.parameter scope);500 为紫色数字,// Simulate async fetch 为灰色注释;
  • 类型守卫(L35-L37):function 关键字、isTask 函数名、obj is Task 中的 is 关键字、any 类型、单引号字符串 'number'/'string' 均按上表着色;
  • IIFE 与模板字符串(L44-L51):async/await 关键字、forEach 方法名、反引号模板串整体为黄色,其中 ${task.id} 插值内的 task 回归白色标识符、.id 为粉色属性访问——快照 L48 中可以看到模板串与插值内部标识符交替出现的完整 SGR 序列,这是验证字符串内嵌 scope(interpolation)是否正确的关键行;
  • 类型断言(L54-L56):unknown 为绿色类型,as Taskas 为粉色关键字、Task 为绿色。

在 CI 中运行与验证

该夹具的完整工作流是“生成 → 比对 → 更新”三步:

  1. 生成基线(本地一次性操作,需要 bat 在 PATH 上):

    cd tests/syntax-tests
    ./update.sh        # 等价于 python3 create_highlighted_versions.py -O highlighted
    
  2. 比对回归:把仓库中已入库的 highlighted/ 作为 OLD,把当前 bat 版本重新生成的目录作为 NEW:

    python3 compare_highlighted_versions.py highlighted <新生成的目录>
    

    若有差异,脚本会打印统一 diff 并返回非零退出码;对新增而无夹具的语言目录,会提示 No fixture for this language, run update.sh

  3. 修复夹具:确认着色变化是有意为之(例如语法文件升级)后,重新运行 update.sh 覆盖 highlighted/ 并提交。

需要说明的适用前提:create_highlighted_versions.py 依赖 PATH 上存在 bat 可执行文件,且固定使用 Monokai Extended 主题与真彩输出,因此该夹具反映的是该主题下的着色行为,而不是用户在任意终端配置下的所见;--no-config 保证了结果不受本机 bat 配置影响。

小结

tests/syntax-tests/highlighted/Markdown/typescript.md 表面上只是一份“带颜色的文件”,实质是 bat 语法高亮回归测试的一个锚点:它同时锁定了两件事——Markdown 语法对外层文档的着色,以及嵌套作用域内 TypeScript 语法对围栏代码块的着色。结合 create_highlighted_versions.py 的固定选项(--no-config--style=plain--color=always--theme=Monokai Extended--italic-text=alwaysCOLORTERM=truecolor)与 compare_highlighted_versions.py 的 diff 机制,构成了 bat 对语法/主题资产(打包于 assets/syntaxesassets/themes)持续演进的防回归防线。理解这份夹具的生成方式与 ANSI 序列含义,也就掌握了阅读 bat 全部语法测试快照的方法。

登录后查看全文
热门项目推荐
相关项目推荐