367 lines
14 KiB
Rust
367 lines
14 KiB
Rust
//! 语法高亮模块。
|
||
//!
|
||
//! 仅在 `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);
|
||
}
|
||
}
|