yggdrasil/src/highlight.rs
xfy ccee9131a9 fix(highlight): 编译期内嵌自定义语法,修复线上 Docker 镜像代码高亮缺失
生产镜像是 FROM scratch 的静态 musl 二进制,运行时不存在 syntaxes/ 目录;
CARGO_MANIFEST_DIR 烘焙的是构建机路径(Docker 内为 /build),运行时
add_from_folder 必然失败,Kotlin/Swift/TypeScript/TSX/JSX/Zig/Vue 七种
语言静默回退为纯文本。

改为 include_str! 编译期嵌入 .sublime-syntax,彻底消除运行时文件依赖;
新增两个守护测试:内嵌列表与 syntaxes/ 目录一致性、自定义语法均已加载。
2026-07-21 14:10:38 +08:00

490 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 语法高亮模块。
//!
//! 仅在 `server` feature 启用时可用,使用 `syntect` 将代码块转换为带 CSS class 的 HTML
//! 配合 `public/highlight.css` 中生成的主题规则实现亮/暗主题高亮。
#[cfg(feature = "server")]
pub mod server {
use std::sync::LazyLock;
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::{SyntaxDefinition, SyntaxSet};
use syntect::util::LinesWithEndings;
/// 编译期内嵌的自定义语法定义(文件名 stem → .sublime-syntax 内容)。
///
/// 生产镜像是 `FROM scratch` 的静态 musl 二进制,容器内不存在 `syntaxes/`
/// 目录;而 `CARGO_MANIFEST_DIR` 烘焙的是构建机路径Docker 里是 /build
/// 运行时 `add_from_folder` 注定失败、这些语言静默回退为纯文本。
/// 因此改为 `include_str!` 编译期嵌入,彻底消除运行时文件依赖。
/// 列表与 `syntaxes/` 目录的一致性由测试 `custom_syntax_list_matches_directory` 保证。
pub(crate) const CUSTOM_SYNTAXES: &[(&str, &str)] = &[
("JSX", include_str!("../syntaxes/JSX.sublime-syntax")),
("Kotlin", include_str!("../syntaxes/Kotlin.sublime-syntax")),
("Swift", include_str!("../syntaxes/Swift.sublime-syntax")),
("TSX", include_str!("../syntaxes/TSX.sublime-syntax")),
("TypeScript", include_str!("../syntaxes/TypeScript.sublime-syntax")),
("Vue", include_str!("../syntaxes/Vue.sublime-syntax")),
("Zig", include_str!("../syntaxes/Zig.sublime-syntax")),
];
/// 全局语法集合,懒加载时合并内置语法与内嵌的自定义语法。
pub(crate) static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
for (name, src) in CUSTOM_SYNTAXES {
match SyntaxDefinition::load_from_str(src, true, Some(name)) {
Ok(def) => {
builder.add(def);
}
Err(e) => tracing::warn!("Failed to load embedded syntax {}: {:?}", name, e),
}
}
let built = builder.build();
tracing::info!(
"SyntaxSet built: {} syntaxes, swift={:?}",
built.syntaxes().len(),
built.find_syntax_by_extension("swift").map(|s| &s.name)
);
built
});
/// 根据语言标识查找对应的语法定义。
///
/// 依次尝试:扩展名、语法名称、小写扩展名/名称、常用别名映射。
/// 如果全部失败,则回退到纯文本语法。
fn find_syntax(lang: Option<&str>) -> &'static syntect::parsing::SyntaxReference {
let ss = &*SYNTAX_SET;
if let Some(lang) = lang {
if !lang.is_empty() {
// 尝试按扩展名匹配
if let Some(s) = ss.find_syntax_by_extension(lang) {
return s;
}
// 尝试按语法名称匹配
if let Some(s) = ss.find_syntax_by_name(lang) {
return s;
}
// 小写扩展名再匹配一次(部分语言的扩展名习惯小写)
let lower = lang.to_lowercase();
if lower != lang {
if let Some(s) = ss.find_syntax_by_extension(&lower) {
return s;
}
}
// 大小写不敏感的语法名称匹配syntect 的语法名通常首字母大写,如 Haskell
if let Some(s) = ss
.syntaxes()
.iter()
.find(|s| s.name.eq_ignore_ascii_case(lang))
{
return s;
}
// 常用语言别名映射表
let aliases: &[(&str, &str)] = &[
("rust", "rs"),
("js", "js"),
("javascript", "js"),
("typescript", "ts"),
("py", "py"),
("python", "py"),
("rb", "rb"),
("ruby", "rb"),
("sh", "sh"),
("bash", "sh"),
("yaml", "yaml"),
("yml", "yaml"),
("md", "md"),
("markdown", "md"),
("kotlin", "kt"),
("swift", "swift"),
("golang", "go"),
// Vue SFC:file_extensions 已含 vue,此条兜底大写 ```Vue 等边界。
("vue", "vue"),
];
for &(from, to) in aliases {
// 别名比较同样不区分大小写,保证 "RUST" 与 "rust" 等价。
if lang.eq_ignore_ascii_case(from) {
if let Some(s) = ss.find_syntax_by_extension(to) {
return s;
}
}
}
}
}
ss.find_syntax_by_extension("txt")
.or_else(|| ss.find_syntax_by_name("Plain Text"))
.expect("no plain text syntax")
}
/// 对给定代码字符串按指定语言进行高亮,返回 HTML 字符串。
///
/// 输出使用 spaced CSS class 风格,便于与 `highlight.css` 中的选择器匹配。
pub fn highlight_code(code: &str, lang: Option<&str>) -> String {
let trimmed = code.trim();
let syntax = find_syntax(lang);
let ss = &*SYNTAX_SET;
let mut generator =
ClassedHTMLGenerator::new_with_class_style(syntax, ss, ClassStyle::Spaced);
// 逐行解析,出错时记录警告并继续
for line in LinesWithEndings::from(trimmed) {
if let Err(e) = generator.parse_html_for_line_which_includes_newline(line) {
tracing::warn!("syntect parse error: {:?}", e);
}
}
generator.finalize()
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::server::*;
#[test]
fn custom_syntax_list_matches_directory() {
// CUSTOM_SYNTAXES 必须与 syntaxes/ 目录下的 .sublime-syntax 文件一一对应,
// 防止新增/删除语法文件后忘记同步内嵌列表(仿 migrations 数组的编译期校验)。
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/syntaxes");
let mut on_disk: Vec<String> = std::fs::read_dir(dir)
.expect("syntaxes/ 目录应存在")
.filter_map(|e| {
let p = e.ok()?.path();
if p.extension()? == "sublime-syntax" {
Some(p.file_stem()?.to_string_lossy().into_owned())
} else {
None
}
})
.collect();
on_disk.sort();
let mut embedded: Vec<&str> = CUSTOM_SYNTAXES.iter().map(|(name, _)| *name).collect();
embedded.sort();
assert_eq!(embedded, on_disk, "CUSTOM_SYNTAXES 与 syntaxes/ 目录不一致");
}
#[test]
fn custom_syntaxes_are_loaded() {
// 内嵌语法必须真正进入 SyntaxSet守护生产环境回退纯文本的回归
for (name, _) in CUSTOM_SYNTAXES {
let lower = name.to_lowercase();
assert!(
SYNTAX_SET.find_syntax_by_name(name).is_some()
|| SYNTAX_SET.find_syntax_by_extension(&lower).is_some(),
"自定义语法 {} 未加载",
name
);
}
}
#[test]
fn highlight_code_rust() {
let result = highlight_code("fn main() {}", Some("rust"));
assert!(result.contains(r#"<span class="storage type function rust">fn</span>"#));
assert!(result.contains(r#"<span class="entity name function rust">main</span>"#));
}
#[test]
fn highlight_code_javascript_alias() {
let result = highlight_code("console.log('hi')", Some("js"));
assert!(result.contains(r#"<span class="support type object console js">console</span>"#));
assert!(result.contains(r#"<span class="support function console js">log</span>"#));
}
#[test]
fn highlight_code_python_alias() {
let result = highlight_code("print('hi')", Some("python"));
assert!(result.contains(r#"<span class="support function builtin python">print</span>"#));
}
#[test]
fn highlight_code_unknown_language() {
let result = highlight_code("some text", Some("brainfuck"));
assert!(result.contains(r#"<span class="text plain">some text</span>"#));
}
#[test]
fn highlight_code_none_language() {
let result = highlight_code("plain text", None);
assert!(result.contains(r#"<span class="text plain">plain text</span>"#));
}
#[test]
fn highlight_code_empty() {
let result = highlight_code("", None);
assert!(result.is_empty());
}
#[test]
fn highlight_code_produces_span_tags() {
let result = highlight_code("let x = 1;", Some("rust"));
assert!(result.contains(r#"<span class="storage type rust">let</span>"#));
assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
}
#[test]
fn highlight_code_haskell_by_full_name() {
// Haskell 语法名首字母大写,扩展名为 hs直接写 "haskell" 应能匹配。
let code = "factorial :: Integer -> Integer\nfactorial 0 = 1";
let result = highlight_code(code, Some("haskell"));
assert!(
!result.contains(r#"<span class="text plain">"#),
"Haskell 不应回退到纯文本: {}",
result
);
assert!(
result.contains("source haskell"),
"Haskell 应输出 source haskell: {}",
result
);
}
#[test]
fn highlight_code_uppercase_language_falls_back_via_lowercase() {
// 大写语言标识应通过小写回退路径匹配到对应语法。
let lower = highlight_code("fn main() {}", Some("rust"));
let upper = highlight_code("fn main() {}", Some("RUST"));
// 大写标识的输出必须与小写标识完全一致,证明回退路径生效。
assert_eq!(lower, upper);
assert!(lower.contains(r#"<span class="storage type function rust">fn</span>"#));
}
#[test]
fn highlight_code_resolves_golang_alias() {
// 别名表中 "golang" 映射到 "go" 扩展名,输出应与直接用 "go" 一致。
let by_alias = highlight_code("package main", Some("golang"));
let by_ext = highlight_code("package main", Some("go"));
assert_eq!(by_alias, by_ext);
// 别名解析必须产出带 span 的高亮输出,而非纯文本。
assert!(by_alias.contains("span"));
}
#[test]
fn highlight_code_resolves_bash_alias() {
// 别名表中 "bash" 映射到 "sh" 扩展名。
let result = highlight_code("echo hello", Some("bash"));
assert!(result.contains("span"));
}
#[test]
fn highlight_code_resolves_yml_alias() {
// 别名表中 "yml" 映射到 "yaml" 扩展名。
let result = highlight_code("key: value", Some("yml"));
assert!(!result.is_empty());
}
#[test]
fn highlight_code_unknown_language_falls_back_to_plain_text() {
// 无法识别的语言应回退到纯文本语法,仍能输出内容。
let result = highlight_code("hello world", Some("totally-not-a-language-xyz"));
assert!(result.contains("hello world"));
}
#[test]
fn highlight_code_empty_language_string_falls_back_to_plain_text() {
// 空字符串语言标识应走纯文本回退路径。
let result = highlight_code("just text", Some(""));
assert!(result.contains("just text"));
}
#[test]
fn highlight_code_trims_surrounding_whitespace() {
// 代码首尾的空白会被 trim 掉再高亮。
let result = highlight_code(" \nfn main() {}\n ", Some("rust"));
assert!(result.contains(r#"<span class="storage type function rust">fn</span>"#));
}
#[test]
fn highlight_code_multiline_output_spans_all_lines() {
// 多行代码每一行都应被解析为带 span 的输出。
let code = "fn a() {}\nfn b() {}";
let result = highlight_code(code, Some("rust"));
// 两处 fn 关键字都应出现
assert_eq!(
result
.matches(r#"<span class="storage type function rust">fn</span>"#)
.count(),
2
);
}
#[test]
fn highlight_code_swift_keyword_and_func() {
// Swift 关键字 func/import/let 应生成 declaration/keyword span而不是纯文本。
let code =
"import Foundation\nfunc greet(person: String) -> String {\n return \"Hi\"\n}";
let result = highlight_code(code, Some("swift"));
assert!(
result.contains("keyword"),
"Swift 输出缺少关键字高亮: {}",
result
);
// 函数名应被识别为函数(声明名 entity name function 或调用 variable function
assert!(
result.contains("name function") || result.contains("variable function"),
"Swift func 名缺少函数高亮: {}",
result
);
}
#[test]
fn highlight_code_swift_types_and_strings() {
// Swift 标准库类型与字符串字面量都应被识别。
let code = "let count: Int = 42\nlet name = \"hello\"";
let result = highlight_code(code, Some("swift"));
assert!(
result.contains("support type") || result.contains("entity name type"),
"Swift Int 类型未被识别为类型: {}",
result
);
assert!(
result.contains("string"),
"Swift 字符串未被识别: {}",
result
);
}
#[test]
fn highlight_code_typescript_keywords_and_types() {
// TS 关键字 interface/const/=> 与类型 string/number 应被识别。
let code = "interface User { name: string; }\nconst x: number = 42;";
let result = highlight_code(code, Some("typescript"));
assert!(
result.contains("keyword"),
"TypeScript 关键字未被识别: {}",
result
);
assert!(
result.contains("support type") || result.contains("entity name type"),
"TypeScript 类型未被识别: {}",
result
);
}
#[test]
fn highlight_code_jsx_tags_and_attributes() {
// JSX 标签名与属性名都应被识别。
let code = "const el = <Button title=\"save\" onClick={fn}>OK</Button>;";
for lang in &["jsx", "tsx"] {
let result = highlight_code(code, Some(lang));
assert!(
result.contains("entity name tag"),
"{lang} JSX 标签名未识别: {result}"
);
assert!(
result.contains("attribute"),
"{lang} JSX 属性名未识别: {result}"
);
}
}
#[test]
fn highlight_code_typescript_resolves_ts_alias() {
// 别名 "ts" 与 "typescript" 输出应一致。
let code = "const x: number = 1;";
let by_ext = highlight_code(code, Some("ts"));
let by_name = highlight_code(code, Some("typescript"));
assert_eq!(by_ext, by_name);
assert!(by_ext.contains("keyword"));
}
#[test]
fn highlight_code_zig_keywords_and_fn() {
// Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。
let code = "const std = @import(\"std\");\npub fn main() void {}";
let result = highlight_code(code, Some("zig"));
assert!(result.contains("keyword"), "Zig 关键字未被识别: {}", result);
assert!(
result.contains("name function"),
"Zig 函数名未被识别: {}",
result
);
assert!(
result.contains("builtin") || result.contains("support function"),
"Zig 内建函数 @import 未被识别: {}",
result
);
}
#[test]
fn highlight_code_zig_types_and_strings() {
// Zig 整数类型、字符串字面量与十六进制数字应被识别。
let code = "const x: u32 = 0xFF;\nconst s = \"hello\"";
let result = highlight_code(code, Some("zig"));
assert!(
result.contains("support type") || result.contains("keyword"),
"Zig u32 类型未被识别: {}",
result
);
assert!(result.contains("string"), "Zig 字符串未被识别: {}", result);
assert!(result.contains("numeric"), "Zig 数字未被识别: {}", result);
}
#[test]
fn highlight_code_vue_sfc() {
// Vue SFC 三段(template HTML + script JS + style CSS)都应被识别,
// 不回退到纯文本(text plain)。
let code = "\
<template>
<div class=\"hello\" @click=\"onClick\">{{ message }}</div>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Hello Vue!')
</script>
<style scoped>
.hello { color: #42b983; }
</style>";
let result = highlight_code(code, Some("vue"));
assert!(
!result.contains(r#"<span class="text plain">"#),
"Vue 不应回退到纯文本: {}",
result
);
assert!(
result.contains("entity name tag"),
"Vue template 标签未被识别: {}",
result
);
assert!(
result.contains("source js"),
"Vue script 段未嵌入 JS 高亮: {}",
result
);
assert!(
result.contains("source css"),
"Vue style 段未嵌入 CSS 高亮: {}",
result
);
assert!(
result.contains("entity other attribute-name"),
"Vue 指令/属性未被识别: {}",
result
);
}
#[test]
fn highlight_code_vue_script_lang_ts() {
// <script lang="ts"> 应嵌入 TypeScript(scope source ts),而非 JS。
let code = "<script lang=\"ts\">\nconst x: number = 42\n</script>";
let result = highlight_code(code, Some("vue"));
assert!(
result.contains("source ts"),
"Vue lang=ts 应嵌入 TS: {}",
result
);
}
#[test]
fn highlight_code_vue_resolves_vue_alias() {
// 别名表 "vue" 与扩展名 "vue" 输出应一致。
let code = "<template><p>{{ msg }}</p></template>";
let by_alias = highlight_code(code, Some("vue"));
let by_upper = highlight_code(code, Some("Vue"));
// 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。
assert_eq!(by_alias, by_upper);
}
}