yggdrasil/src/highlight.rs
xfy 373498870a
Some checks failed
CI / check (push) Failing after 5m35s
CI / build (push) Has been skipped
style: apply cargo fmt to workspace
2026-06-29 13:47:40 +08:00

367 lines
14 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::SyntaxSet;
use syntect::util::LinesWithEndings;
/// 全局语法集合,懒加载时合并内置语法与 `syntaxes/` 目录下的自定义语法。
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
// 使用 CARGO_MANIFEST_DIR 派生的绝对路径,避免运行时工作目录不确定导致加载失败
let syntaxes_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/syntaxes");
tracing::info!("Loading custom syntaxes from: {}", syntaxes_dir);
match builder.add_from_folder(syntaxes_dir, true) {
Ok(()) => tracing::info!("Custom syntaxes loaded successfully"),
Err(e) => tracing::warn!("Failed to load custom syntaxes: {:?}", 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"),
];
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 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);
}
}