[bat-cli] 语法映射 | SyntaxMapping

发布于:2025-09-06 ⋅ 阅读:(19) ⋅ 点赞:(0)

第六章:语法映射

在上一章高亮资源中,我们学习了 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 用于确定文件语言的所有特定模式。

这本规则手册由以下内容构建:

  1. 内置规则bat 附带了许多预定义规则,用于常见文件和路径(如 PKGBUILD 用于 Arch Linux 包,或 /etc/profile 用于 Bash)。
  2. 自定义规则:我们可以添加自己的规则,以教 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();
}

说明:

  1. printer.add_mapping("my_notes", MappingTarget::MapTo("Markdown")):告诉 bat 任何名为“my_notes”的文件应高亮为“Markdown”。
  2. printer.add_mapping("*.config", MappingTarget::MapTo("INI")):使用通配符模式。任何以 .config 结尾的文件(如 app.configserver.config)现在将高亮为“INI”。
  3. printer.add_mapping("/etc/my-app/data.conf", MappingTarget::MapTo("YAML")):使用完整路径。如果 bat 遇到此路径的文件,它将使用“YAML”语法。
  4. 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 高亮。

语法映射的内部工作原理

控制器(我们的项目经理)收到一个文件并需要知道其语言时,它会协调以下检测步骤:

  1. 显式语言:首先,控制器检查是否通过 --language 命令行选项或 Config 显式设置了语言(例如 bat --language rust my_file)。如果是,这就是最终答案。
  2. 咨询高亮资源:如果没有显式语言,控制器委托给高亮资源模块。
  3. 基于路径的映射(高优先级)HighlightingAssets 首先查阅 SyntaxMapping 规则手册。它检查我们的 custom_mappingsbatbuiltin_mappings
    • 获取文件的完整路径和文件名。
    • 将这些与 SyntaxMapping 规则手册中的所有通配符模式进行匹配。
    • 优先级:自定义规则(我们添加的)优先于内置规则。在自定义规则中,后添加的规则优先。在内置规则中,bat 内部列表(基于内部 .toml 文件的文件名顺序)中较早定义的规则优先。
    • 如果规则匹配,SyntaxMapping 返回一个 MappingTarget(例如 MapTo("Python")MapToUnknown)。
  4. 处理映射目标
    • 如果是 MapTo("Language Name")HighlightingAssets 使用此名称从其 SyntaxSet(语法书)中查找对应的语法定义。这就是检测到的语言。
    • 如果是 MapToUnknownMapExtensionToUnknown:这告诉 HighlightingAssets 此特定映射规则没有给出最终答案,因此应继续使用较低优先级的检测方法。
  5. 文件名/扩展名匹配(中优先级):如果没有基于路径的映射产生明确的 MapTo(或明确导致 MapToUnknown),HighlightingAssets 接下来尝试基于文件的简单文件名(如 Dockerfile)或其扩展名(如 .rs)猜测语言。在此步骤中,ignored_suffixes 也会被应用(例如去掉 .bak 以将 test.rs.bak 检测为 Rust)。
  6. 第一行检测(低优先级):如果所有先前方法都失败,HighlightingAssets 读取文件的第一行。它查找常见模式,如 shebang(#!/bin/bash)或 XML 声明(<?xml ...?>)来识别语言。
  7. 回退:如果连第一行检测都失败,bat 可能会默认为“纯文本”或报告“未检测到语法”错误(如果无法继续)。

以下是描述涉及 SyntaxMapping 的主要语法检测流程的简化序列图:

在这里插入图片描述

深入代码:src/syntax_mapping.rssrc/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 如何处理显示代码的特定部分。

下一章:行范围处理


网站公告

今日签到

点亮在社区的每一天
去签到