[bat-cli] 语法映射 | SyntaxMapping
第六章:语法映射
在上一章高亮资源中,我们学习了 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
如何处理显示代码的特定部分。
下一章:行范围处理