第六章:语法映射
在上一章高亮资源中,我们学习了 bat 如何存储和管理所有用于高亮显示的“语法书”(语法定义)和“调色板”(颜色主题)。
但一个重要问题仍然存在:bat 如何为给定文件选择哪本语法书?
例如,如果我们有一个名为 README.md 的文件,bat 会正确识别它为“Markdown”。这很简单。但如果是一个名为 config 的文件放在 /etc 目录下呢?它没有 .conf 扩展名,但通常是一个配置文件。或者我们可能有一个名为 build 的自定义脚本没有扩展名,但它实际上是一个 Bash 脚本。
这就是语法映射的作用
语法映射解决了什么问题?
想象 bat 是一个聪明的侦探,试图识别文件的语言。大多数情况下,文件扩展名(如 .rs 表示 Rust 或 .py 表示 Python)是一个明确的线索。但有时线索会更复杂:
- 缺少扩展名:一个名为
Dockerfile的文件没有扩展名,但bat应该知道它是“Dockerfile”语法。 - 通用扩展名:一个名为
my_app.conf的文件是“配置文件”,但如果默认值太通用,bat可能需要知道它是“Apache Conf”或“Nginx Conf”。 - 特定路径:位于
/etc/fstab的文件通常是“fstab”(系统挂载)文件,而不仅仅是普通文本文件。
语法映射模块是 bat 的“复杂线索规则手册”。它包含一组规则,通常使用“通配符模式”(如 *.conf 或 /etc/profile),帮助 bat 像智能侦探一样工作。
它会查看文件的完整路径或文件名,并尝试与这些规则匹配以分配正确的“语言标签”。如果没有特定规则匹配,bat 会回退到其他方法,例如检查文件的扩展名甚至第一行。
本章的目标是理解 bat 如何使用这些规则来识别语言,以及如何添加自定义规则以使 bat 对我们的特定文件更加智能。
SyntaxMapping 对象:侦探的规则手册
语法映射的核心概念是 SyntaxMapping 结构体。可以将其视为一本全面的规则手册,包含 bat 用于确定文件语言的所有特定模式。
这本规则手册由以下内容构建:
- 内置规则:
bat附带了许多预定义规则,用于常见文件和路径(如PKGBUILD用于 Arch Linux 包,或/etc/profile用于 Bash)。 - 自定义规则:我们可以添加自己的规则,以教
bat识别默认情况下无法识别的文件。
当 bat 评估一个文件时,它会按特定顺序检查这些规则:自定义规则优先,然后是内置规则。第一个匹配的规则胜出!
这些规则的关键部分是 MappingTarget 枚举,它告诉 bat 当规则匹配时该做什么:
MapTo("Language Name"):将文件分配给特定的语法(例如MapTo("Markdown"))。MapToUnknown:不基于此规则分配特定语法,而是尝试使用其他方法(如查看文件的第一行)来确定语言。这对于覆盖“过于贪婪”的默认值很有用。MapExtensionToUnknown:类似于MapToUnknown,但专门用于扩展名。
添加自定义映射
我们可以使用 PrettyPrinter 在 Rust 代码中添加自定义语法映射规则,或通过 bat 的配置文件在命令行中使用。以下是使用 PrettyPrinter 的示例:
use bat::{PrettyPrinter, MappingTarget};
use std::path::Path;
fn main() {
let mut printer = PrettyPrinter::new();
// 1. 将没有扩展名的文件映射到特定语法
printer.add_mapping("my_notes", MappingTarget::MapTo("Markdown")).unwrap();
// 2. 将所有 '.config' 文件映射到 'INI' 语法,覆盖其他规则
printer.add_mapping("*.config", MappingTarget::MapTo("INI")).unwrap();
// 3. 将特定路径映射到语言
printer.add_mapping("/etc/my-app/data.conf", MappingTarget::MapTo("YAML")).unwrap();
// 4. 对于名为 'build' 的文件,不假设语言,尝试第一行(如 shebang)
printer.add_mapping("build", MappingTarget::MapToUnknown).unwrap();
// 现在看看它是如何工作的:
println!("--- 自定义映射实战 ---");
printer.input_from_bytes(b"# My meeting notes\n- Topic A\n- Topic B")
.name("my_notes") // 使用 "my_notes" 映射
.print().unwrap();
printer.input_from_bytes(b"[section]\nkey=value")
.name("app.config") // 使用 "*.config" 映射
.print().unwrap();
printer.input_from_bytes(b"#!/bin/bash\necho \"Hello\"")
.name("build") // 使用 "build" 映射到 MapToUnknown,然后通过第一行检测
.print().unwrap();
}
说明:
printer.add_mapping("my_notes", MappingTarget::MapTo("Markdown")):告诉bat任何名为“my_notes”的文件应高亮为“Markdown”。printer.add_mapping("*.config", MappingTarget::MapTo("INI")):使用通配符模式。任何以.config结尾的文件(如app.config、server.config)现在将高亮为“INI”。printer.add_mapping("/etc/my-app/data.conf", MappingTarget::MapTo("YAML")):使用完整路径。如果bat遇到此路径的文件,它将使用“YAML”语法。printer.add_mapping("build", MappingTarget::MapToUnknown):对于名为build的文件(没有扩展名或特定路径规则),bat将查看其第一行以查找 shebang(如#!/bin/bash)来确定语言。
运行此代码时,bat 将应用我们的自定义规则,将 my_notes 高亮为 Markdown,app.config 高亮为 INI,并由于 MapToUnknown 正确检测 build 中的 Bash shebang。
我们还可以在检测时忽略某些文件后缀,例如备份文件的 .bak:
use bat::{PrettyPrinter, MappingTarget};
fn main() {
let mut printer = PrettyPrinter::new();
printer.add_ignored_suffix(".bak"); // 检测语法时忽略 '.bak'
// 通常此文件会是纯文本。但忽略后缀后...
printer.input_from_bytes(b"fn main() {}")
.name("my_code.rs.bak") // Bat 会视为 "my_code.rs"
.print().unwrap();
}
说明:
printer.add_ignored_suffix(".bak") 告诉 bat 在尝试检测语法之前去掉 .bak 后缀。
因此 my_code.rs.bak 会被当作 my_code.rs 进行语言检测,从而得到 Rust 高亮。
语法映射的内部工作原理
当控制器(我们的项目经理)收到一个文件并需要知道其语言时,它会协调以下检测步骤:
- 显式语言:首先,控制器检查是否通过
--language命令行选项或Config显式设置了语言(例如bat --language rust my_file)。如果是,这就是最终答案。 - 咨询高亮资源:如果没有显式语言,控制器委托给高亮资源模块。
- 基于路径的映射(高优先级):
HighlightingAssets首先查阅SyntaxMapping规则手册。它检查我们的custom_mappings和bat的builtin_mappings。- 获取文件的完整路径和文件名。
- 将这些与
SyntaxMapping规则手册中的所有通配符模式进行匹配。 - 优先级:自定义规则(我们添加的)优先于内置规则。在自定义规则中,后添加的规则优先。在内置规则中,
bat内部列表(基于内部.toml文件的文件名顺序)中较早定义的规则优先。 - 如果规则匹配,
SyntaxMapping返回一个MappingTarget(例如MapTo("Python")、MapToUnknown)。
- 处理映射目标:
- 如果是
MapTo("Language Name"):HighlightingAssets使用此名称从其SyntaxSet(语法书)中查找对应的语法定义。这就是检测到的语言。 - 如果是
MapToUnknown或MapExtensionToUnknown:这告诉HighlightingAssets此特定映射规则没有给出最终答案,因此应继续使用较低优先级的检测方法。
- 如果是
- 文件名/扩展名匹配(中优先级):如果没有基于路径的映射产生明确的
MapTo(或明确导致MapToUnknown),HighlightingAssets接下来尝试基于文件的简单文件名(如Dockerfile)或其扩展名(如.rs)猜测语言。在此步骤中,ignored_suffixes也会被应用(例如去掉.bak以将test.rs.bak检测为Rust)。 - 第一行检测(低优先级):如果所有先前方法都失败,
HighlightingAssets读取文件的第一行。它查找常见模式,如 shebang(#!/bin/bash)或 XML 声明(<?xml ...?>)来识别语言。 - 回退:如果连第一行检测都失败,
bat可能会默认为“纯文本”或报告“未检测到语法”错误(如果无法继续)。
以下是描述涉及 SyntaxMapping 的主要语法检测流程的简化序列图:

深入代码:src/syntax_mapping.rs 和 src/assets.rs

让我们看看 bat 代码库中的关键结构和方法。
首先是 src/syntax_mapping.rs 中的核心 SyntaxMapping 结构体和 MappingTarget 枚举:
// src/syntax_mapping.rs
#[derive(Debug, Clone, Default)]
pub struct SyntaxMapping<'a> {
// 用户定义的映射。前面的规则优先。
custom_mappings: Vec<(GlobMatcher, MappingTarget<'a>)>,
pub(crate) ignored_suffixes: IgnoredSuffixes<'a>,
// ... (管理内置规则的内部字段)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MappingTarget<'a> {
MapTo(&'a str),
MapToUnknown,
MapExtensionToUnknown,
}
impl<'a> SyntaxMapping<'a> {
pub fn new() -> SyntaxMapping<'a> { /* ... */ }
// 添加自定义映射规则
pub fn insert(&mut self, from: &str, to: MappingTarget<'a>) -> Result<()> {
let matcher = make_glob_matcher(from)?; // 将 "from" 字符串转换为通配符匹配器
self.custom_mappings.push((matcher, to));
Ok(())
}
// 为给定路径查找第一个匹配的映射目标
pub fn get_syntax_for(&self, path: impl AsRef<Path>) -> Option<MappingTarget<'a>> {
let candidate = Candidate::new(&path);
let candidate_filename = path.as_ref().file_name().map(Candidate::new);
// 遍历所有映射(自定义优先,然后内置)
for (glob, syntax) in self.all_mappings() {
if glob.is_match_candidate(&candidate)
|| candidate_filename.as_ref().is_some_and(|filename| glob.is_match_candidate(filename))
{
return Some(*syntax); // 返回第一个匹配
}
}
// ... 也会尝试去掉后缀
None
}
pub fn insert_ignored_suffix(&mut self, suffix: &'a str) {
self.ignored_suffixes.add_suffix(suffix);
}
}
说明:
SyntaxMapping结构体:保存通配符模式(GlobMatcher)及其关联的MappingTarget集合。custom_mappings存储用户定义的规则,ignored_suffixes(我们之前简要提到)允许bat在检测前去掉常见的备份后缀。MappingTarget枚举:如前所述,定义成功匹配的结果。insert(from, to):这是我们用来添加新规则的方法。它接受一个字符串(from)如*.conf并将其转换为GlobMatcher(一个高效的模式匹配对象)。get_syntax_for(path):这是核心逻辑。它接受文件路径,为匹配创建“候选”(完整路径和仅文件名),然后遍历所有已知映射规则(先custom_mappings,后builtin_mappings)。第一个成功匹配路径或文件名候选的GlobMatcher决定MappingTarget。
现在看看 HighlightingAssets 如何在 src/assets.rs 中使用这个 SyntaxMapping:
// src/assets.rs
impl HighlightingAssets {
// 这是确定输入语法的整体方法。
pub(crate) fn get_syntax(
&self,
language: Option<&str>, // 来自 Config 的显式语言
input: &mut OpenedInput, // 要检查的文件/内容
mapping: &SyntaxMapping, // 我们的 SyntaxMapping 规则手册
) -> Result<SyntaxReferenceInSet<'_>> {
if let Some(language) = language {
// 步骤 1: Config 中的显式语言具有最高优先级
return self.find_syntax_by_token(language)? /* ... */;
}
let path = input.path();
let path_syntax = if let Some(path) = path {
// 步骤 2: 使用 SyntaxMapping 进行基于路径的检测
self.get_syntax_for_path(path.to_owned(), mapping)
} else {
Err(Error::UndetectedSyntax("[unknown]".into()))
};
match path_syntax {
// 如果基于路径的检测失败(或返回 MapToUnknown)
Err(Error::UndetectedSyntax(path)) => self
// 步骤 3: 回退到第一行检测
.get_first_line_syntax(&mut input.reader)? /* ... */,
_ => path_syntax, // 如果基于路径的检测成功(MapTo "Language")
}
}
// 此方法专门使用 SyntaxMapping 处理基于路径的检测。
pub fn get_syntax_for_path(
&self,
path: impl AsRef<Path>,
mapping: &SyntaxMapping,
) -> Result<SyntaxReferenceInSet<'_>> {
let path = path.as_ref();
let syntax_match = mapping.get_syntax_for(path); // 向 SyntaxMapping 请求目标
if let Some(MappingTarget::MapToUnknown) = syntax_match {
// 如果映射明确表示“未知”,则认为通过路径未检测到
return Err(Error::UndetectedSyntax(path.to_string_lossy().into()));
}
if let Some(MappingTarget::MapTo(syntax_name)) = syntax_match {
// 如果映射给出了特定名称,尝试查找该语法
return self.find_syntax_by_token(syntax_name)? /* ... */;
}
// 如果没有匹配的映射或涉及 MapExtensionToUnknown,回退到
// 文件名/扩展名检测。
let file_name = path.file_name().unwrap_or_default();
match (
self.get_syntax_for_file_name(file_name, &mapping.ignored_suffixes)?,
syntax_match, // 检查是否涉及 MapExtensionToUnknown
) {
(Some(syntax), _) => Ok(syntax), // 文件名匹配,返回
(_, Some(MappingTarget::MapExtensionToUnknown)) => {
Err(Error::UndetectedSyntax(path.to_string_lossy().into()))
}
_ => self.get_syntax_for_file_extension(file_name, &mapping.ignored_suffixes)? /* ... */,
}
}
// ... 其他方法如 get_syntax_for_file_name, get_syntax_for_file_extension, get_first_line_syntax
}
说明:
HighlightingAssets::get_syntax:这是Controller调用的顶层方法。它首先检查Config中是否有显式language。如果未找到,则调用get_syntax_for_path。如果这也没有返回具体的语法(例如,如果触发了MapToUnknown规则),则尝试get_first_line_syntax。HighlightingAssets::get_syntax_for_path:此方法专门与SyntaxMapping集成。- 调用
mapping.get_syntax_for(path)获取MappingTarget。 - 如果返回
MapToUnknown,则表示此基于路径的检测明确未产生确定的语法,允许启动较低优先级的检测方法。 - 如果返回
MapTo(syntax_name),HighlightingAssets然后在其SyntaxSet(“语法书”)中查找该syntax_name。 - 如果
SyntaxMapping未直接提供MapTo,则继续使用其自己的内部逻辑基于文件名(get_syntax_for_file_name)和扩展名(get_syntax_for_file_extension)检测语法,同时考虑ignored_suffixes。
- 调用
这种分层方法优先考虑显式设置,然后是 SyntaxMapping 的灵活基于路径的规则,最后回退到更通用的文件特征,使 bat 在识别各种文件类型时非常健壮。
结论
在本章中,我们学习了语法映射,这是 bat 用于识别文件语言的“智能侦探”系统。
我们看到了 bat 如何使用 SyntaxMapping 作为规则手册,将通配符模式应用于文件路径和名称以确定正确的高亮显示。
我们还学习了如何通过 MappingTarget 选项添加自定义规则来扩展这一智能,使 bat 完美适应我们独特的开发环境。
这个复杂的系统,结合文件名、扩展名和第一行检测,确保 bat 始终应用最准确和美观的语法高亮。
现在 bat 可以智能地识别整个文件的语言,如果我们只想高亮或显示文件的部分内容呢?
在下一章中,我们将深入探讨行范围处理,学习 bat 如何处理显示代码的特定部分。