Skip to main content

yggdrasil/
highlight.rs

1//! 语法高亮模块。
2//!
3//! 仅在 `server` feature 启用时可用,使用 `syntect` 将代码块转换为带 CSS class 的 HTML,
4//! 配合 `public/highlight.css` 中生成的主题规则实现亮/暗主题高亮。
5
6#[cfg(feature = "server")]
7pub mod server {
8    use std::sync::LazyLock;
9
10    use syntect::html::{ClassStyle, ClassedHTMLGenerator};
11    use syntect::parsing::{SyntaxDefinition, SyntaxSet};
12    use syntect::util::LinesWithEndings;
13
14    /// 编译期内嵌的自定义语法定义(文件名 stem → .sublime-syntax 内容)。
15    ///
16    /// 生产镜像是 `FROM scratch` 的静态 musl 二进制,容器内不存在 `syntaxes/`
17    /// 目录;而 `CARGO_MANIFEST_DIR` 烘焙的是构建机路径(Docker 里是 /build),
18    /// 运行时 `add_from_folder` 注定失败、这些语言静默回退为纯文本。
19    /// 因此改为 `include_str!` 编译期嵌入,彻底消除运行时文件依赖。
20    /// 列表与 `syntaxes/` 目录的一致性由测试 `custom_syntax_list_matches_directory` 保证。
21    pub(crate) const CUSTOM_SYNTAXES: &[(&str, &str)] = &[
22        ("JSX", include_str!("../syntaxes/JSX.sublime-syntax")),
23        ("Kotlin", include_str!("../syntaxes/Kotlin.sublime-syntax")),
24        ("Swift", include_str!("../syntaxes/Swift.sublime-syntax")),
25        ("TOML", include_str!("../syntaxes/TOML.sublime-syntax")),
26        ("TSX", include_str!("../syntaxes/TSX.sublime-syntax")),
27        (
28            "TypeScript",
29            include_str!("../syntaxes/TypeScript.sublime-syntax"),
30        ),
31        ("Vue", include_str!("../syntaxes/Vue.sublime-syntax")),
32        ("Zig", include_str!("../syntaxes/Zig.sublime-syntax")),
33    ];
34
35    /// 全局语法集合,懒加载时合并内置语法与内嵌的自定义语法。
36    pub(crate) static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| {
37        let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
38        for (name, src) in CUSTOM_SYNTAXES {
39            match SyntaxDefinition::load_from_str(src, true, Some(name)) {
40                Ok(def) => {
41                    builder.add(def);
42                }
43                Err(e) => tracing::warn!("Failed to load embedded syntax {}: {:?}", name, e),
44            }
45        }
46        let built = builder.build();
47        tracing::info!(
48            "SyntaxSet built: {} syntaxes, swift={:?}",
49            built.syntaxes().len(),
50            built.find_syntax_by_extension("swift").map(|s| &s.name)
51        );
52        built
53    });
54
55    /// 根据语言标识查找对应的语法定义。
56    ///
57    /// 依次尝试:扩展名、语法名称、小写扩展名/名称、常用别名映射。
58    /// 如果全部失败,则回退到纯文本语法。
59    fn find_syntax(lang: Option<&str>) -> &'static syntect::parsing::SyntaxReference {
60        let ss = &*SYNTAX_SET;
61        if let Some(lang) = lang {
62            if !lang.is_empty() {
63                // 尝试按扩展名匹配
64                if let Some(s) = ss.find_syntax_by_extension(lang) {
65                    return s;
66                }
67                // 尝试按语法名称匹配
68                if let Some(s) = ss.find_syntax_by_name(lang) {
69                    return s;
70                }
71                // 小写扩展名再匹配一次(部分语言的扩展名习惯小写)
72                let lower = lang.to_lowercase();
73                if lower != lang {
74                    if let Some(s) = ss.find_syntax_by_extension(&lower) {
75                        return s;
76                    }
77                }
78                // 大小写不敏感的语法名称匹配(syntect 的语法名通常首字母大写,如 Haskell)
79                if let Some(s) = ss
80                    .syntaxes()
81                    .iter()
82                    .find(|s| s.name.eq_ignore_ascii_case(lang))
83                {
84                    return s;
85                }
86                // 常用语言别名映射表
87                let aliases: &[(&str, &str)] = &[
88                    ("rust", "rs"),
89                    ("js", "js"),
90                    ("javascript", "js"),
91                    ("typescript", "ts"),
92                    // bun 运行器跑的是 TypeScript,归一化后用 ts 语法高亮。
93                    ("bun", "ts"),
94                    ("py", "py"),
95                    ("python", "py"),
96                    ("rb", "rb"),
97                    ("ruby", "rb"),
98                    ("sh", "sh"),
99                    ("bash", "sh"),
100                    ("yaml", "yaml"),
101                    ("yml", "yaml"),
102                    ("md", "md"),
103                    ("markdown", "md"),
104                    ("kotlin", "kt"),
105                    ("swift", "swift"),
106                    ("golang", "go"),
107                    // Vue SFC:file_extensions 已含 vue,此条兜底大写 ```Vue 等边界。
108                    ("vue", "vue"),
109                ];
110                for &(from, to) in aliases {
111                    // 别名比较同样不区分大小写,保证 "RUST" 与 "rust" 等价。
112                    if lang.eq_ignore_ascii_case(from) {
113                        if let Some(s) = ss.find_syntax_by_extension(to) {
114                            return s;
115                        }
116                    }
117                }
118            }
119        }
120        ss.find_syntax_by_extension("txt")
121            .or_else(|| ss.find_syntax_by_name("Plain Text"))
122            .expect("no plain text syntax")
123    }
124
125    /// 对给定代码字符串按指定语言进行高亮,返回 HTML 字符串。
126    ///
127    /// 输出使用 spaced CSS class 风格,便于与 `highlight.css` 中的选择器匹配。
128    pub fn highlight_code(code: &str, lang: Option<&str>) -> String {
129        let trimmed = code.trim();
130        let syntax = find_syntax(lang);
131        let ss = &*SYNTAX_SET;
132        let mut generator =
133            ClassedHTMLGenerator::new_with_class_style(syntax, ss, ClassStyle::Spaced);
134
135        // 逐行解析,出错时记录警告并继续
136        for line in LinesWithEndings::from(trimmed) {
137            if let Err(e) = generator.parse_html_for_line_which_includes_newline(line) {
138                tracing::warn!("syntect parse error: {:?}", e);
139            }
140        }
141
142        generator.finalize()
143    }
144}
145
146#[cfg(all(test, feature = "server"))]
147mod tests {
148    use super::server::*;
149
150    #[test]
151    fn custom_syntax_list_matches_directory() {
152        // CUSTOM_SYNTAXES 必须与 syntaxes/ 目录下的 .sublime-syntax 文件一一对应,
153        // 防止新增/删除语法文件后忘记同步内嵌列表(仿 migrations 数组的编译期校验)。
154        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/syntaxes");
155        let mut on_disk: Vec<String> = std::fs::read_dir(dir)
156            .expect("syntaxes/ 目录应存在")
157            .filter_map(|e| {
158                let p = e.ok()?.path();
159                if p.extension()? == "sublime-syntax" {
160                    Some(p.file_stem()?.to_string_lossy().into_owned())
161                } else {
162                    None
163                }
164            })
165            .collect();
166        on_disk.sort();
167        let mut embedded: Vec<&str> = CUSTOM_SYNTAXES.iter().map(|(name, _)| *name).collect();
168        embedded.sort();
169        assert_eq!(embedded, on_disk, "CUSTOM_SYNTAXES 与 syntaxes/ 目录不一致");
170    }
171
172    #[test]
173    fn custom_syntaxes_are_loaded() {
174        // 内嵌语法必须真正进入 SyntaxSet(守护生产环境回退纯文本的回归)。
175        for (name, _) in CUSTOM_SYNTAXES {
176            let lower = name.to_lowercase();
177            assert!(
178                SYNTAX_SET.find_syntax_by_name(name).is_some()
179                    || SYNTAX_SET.find_syntax_by_extension(&lower).is_some(),
180                "自定义语法 {} 未加载",
181                name
182            );
183        }
184    }
185
186    #[test]
187    fn highlight_code_rust() {
188        let result = highlight_code("fn main() {}", Some("rust"));
189        assert!(result.contains(r#"<span class="storage type function rust">fn</span>"#));
190        assert!(result.contains(r#"<span class="entity name function rust">main</span>"#));
191    }
192
193    #[test]
194    fn highlight_code_javascript_alias() {
195        let result = highlight_code("console.log('hi')", Some("js"));
196        assert!(result.contains(r#"<span class="support type object console js">console</span>"#));
197        assert!(result.contains(r#"<span class="support function console js">log</span>"#));
198    }
199
200    #[test]
201    fn highlight_code_python_alias() {
202        let result = highlight_code("print('hi')", Some("python"));
203        assert!(result.contains(r#"<span class="support function builtin python">print</span>"#));
204    }
205
206    #[test]
207    fn highlight_code_unknown_language() {
208        let result = highlight_code("some text", Some("brainfuck"));
209        assert!(result.contains(r#"<span class="text plain">some text</span>"#));
210    }
211
212    #[test]
213    fn highlight_code_none_language() {
214        let result = highlight_code("plain text", None);
215        assert!(result.contains(r#"<span class="text plain">plain text</span>"#));
216    }
217
218    #[test]
219    fn highlight_code_empty() {
220        let result = highlight_code("", None);
221        assert!(result.is_empty());
222    }
223
224    #[test]
225    fn highlight_code_produces_span_tags() {
226        let result = highlight_code("let x = 1;", Some("rust"));
227        assert!(result.contains(r#"<span class="storage type rust">let</span>"#));
228        assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
229    }
230
231    #[test]
232    fn highlight_code_haskell_by_full_name() {
233        // Haskell 语法名首字母大写,扩展名为 hs;直接写 "haskell" 应能匹配。
234        let code = "factorial :: Integer -> Integer\nfactorial 0 = 1";
235        let result = highlight_code(code, Some("haskell"));
236        assert!(
237            !result.contains(r#"<span class="text plain">"#),
238            "Haskell 不应回退到纯文本: {}",
239            result
240        );
241        assert!(
242            result.contains("source haskell"),
243            "Haskell 应输出 source haskell: {}",
244            result
245        );
246    }
247
248    #[test]
249    fn highlight_code_uppercase_language_falls_back_via_lowercase() {
250        // 大写语言标识应通过小写回退路径匹配到对应语法。
251        let lower = highlight_code("fn main() {}", Some("rust"));
252        let upper = highlight_code("fn main() {}", Some("RUST"));
253        // 大写标识的输出必须与小写标识完全一致,证明回退路径生效。
254        assert_eq!(lower, upper);
255        assert!(lower.contains(r#"<span class="storage type function rust">fn</span>"#));
256    }
257
258    #[test]
259    fn highlight_code_resolves_golang_alias() {
260        // 别名表中 "golang" 映射到 "go" 扩展名,输出应与直接用 "go" 一致。
261        let by_alias = highlight_code("package main", Some("golang"));
262        let by_ext = highlight_code("package main", Some("go"));
263        assert_eq!(by_alias, by_ext);
264        // 别名解析必须产出带 span 的高亮输出,而非纯文本。
265        assert!(by_alias.contains("span"));
266    }
267
268    #[test]
269    fn highlight_code_resolves_bash_alias() {
270        // 别名表中 "bash" 映射到 "sh" 扩展名。
271        let result = highlight_code("echo hello", Some("bash"));
272        assert!(result.contains("span"));
273    }
274
275    #[test]
276    fn highlight_code_resolves_yml_alias() {
277        // 别名表中 "yml" 映射到 "yaml" 扩展名。
278        let result = highlight_code("key: value", Some("yml"));
279        assert!(!result.is_empty());
280    }
281
282    #[test]
283    fn highlight_code_unknown_language_falls_back_to_plain_text() {
284        // 无法识别的语言应回退到纯文本语法,仍能输出内容。
285        let result = highlight_code("hello world", Some("totally-not-a-language-xyz"));
286        assert!(result.contains("hello world"));
287    }
288
289    #[test]
290    fn highlight_code_empty_language_string_falls_back_to_plain_text() {
291        // 空字符串语言标识应走纯文本回退路径。
292        let result = highlight_code("just text", Some(""));
293        assert!(result.contains("just text"));
294    }
295
296    #[test]
297    fn highlight_code_trims_surrounding_whitespace() {
298        // 代码首尾的空白会被 trim 掉再高亮。
299        let result = highlight_code("  \nfn main() {}\n  ", Some("rust"));
300        assert!(result.contains(r#"<span class="storage type function rust">fn</span>"#));
301    }
302
303    #[test]
304    fn highlight_code_multiline_output_spans_all_lines() {
305        // 多行代码每一行都应被解析为带 span 的输出。
306        let code = "fn a() {}\nfn b() {}";
307        let result = highlight_code(code, Some("rust"));
308        // 两处 fn 关键字都应出现
309        assert_eq!(
310            result
311                .matches(r#"<span class="storage type function rust">fn</span>"#)
312                .count(),
313            2
314        );
315    }
316
317    #[test]
318    fn highlight_code_swift_keyword_and_func() {
319        // Swift 关键字 func/import/let 应生成 declaration/keyword span,而不是纯文本。
320        let code =
321            "import Foundation\nfunc greet(person: String) -> String {\n    return \"Hi\"\n}";
322        let result = highlight_code(code, Some("swift"));
323        assert!(
324            result.contains("keyword"),
325            "Swift 输出缺少关键字高亮: {}",
326            result
327        );
328        // 函数名应被识别为函数(声明名 entity name function 或调用 variable function)。
329        assert!(
330            result.contains("name function") || result.contains("variable function"),
331            "Swift func 名缺少函数高亮: {}",
332            result
333        );
334    }
335
336    #[test]
337    fn highlight_code_swift_types_and_strings() {
338        // Swift 标准库类型与字符串字面量都应被识别。
339        let code = "let count: Int = 42\nlet name = \"hello\"";
340        let result = highlight_code(code, Some("swift"));
341        assert!(
342            result.contains("support type") || result.contains("entity name type"),
343            "Swift Int 类型未被识别为类型: {}",
344            result
345        );
346        assert!(
347            result.contains("string"),
348            "Swift 字符串未被识别: {}",
349            result
350        );
351    }
352
353    #[test]
354    fn highlight_code_typescript_keywords_and_types() {
355        // TS 关键字 interface/const/=> 与类型 string/number 应被识别。
356        let code = "interface User { name: string; }\nconst x: number = 42;";
357        let result = highlight_code(code, Some("typescript"));
358        assert!(
359            result.contains("keyword"),
360            "TypeScript 关键字未被识别: {}",
361            result
362        );
363        assert!(
364            result.contains("support type") || result.contains("entity name type"),
365            "TypeScript 类型未被识别: {}",
366            result
367        );
368    }
369
370    #[test]
371    fn highlight_code_jsx_tags_and_attributes() {
372        // JSX 标签名与属性名都应被识别。
373        let code = "const el = <Button title=\"save\" onClick={fn}>OK</Button>;";
374        for lang in &["jsx", "tsx"] {
375            let result = highlight_code(code, Some(lang));
376            assert!(
377                result.contains("entity name tag"),
378                "{lang} JSX 标签名未识别: {result}"
379            );
380            assert!(
381                result.contains("attribute"),
382                "{lang} JSX 属性名未识别: {result}"
383            );
384        }
385    }
386
387    #[test]
388    fn highlight_code_typescript_resolves_ts_alias() {
389        // 别名 "ts" 与 "typescript" 输出应一致。
390        let code = "const x: number = 1;";
391        let by_ext = highlight_code(code, Some("ts"));
392        let by_name = highlight_code(code, Some("typescript"));
393        assert_eq!(by_ext, by_name);
394        assert!(by_ext.contains("keyword"));
395    }
396
397    #[test]
398    fn highlight_code_zig_keywords_and_fn() {
399        // Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。
400        let code = "const std = @import(\"std\");\npub fn main() void {}";
401        let result = highlight_code(code, Some("zig"));
402        assert!(result.contains("keyword"), "Zig 关键字未被识别: {}", result);
403        assert!(
404            result.contains("name function"),
405            "Zig 函数名未被识别: {}",
406            result
407        );
408        assert!(
409            result.contains("builtin") || result.contains("support function"),
410            "Zig 内建函数 @import 未被识别: {}",
411            result
412        );
413    }
414
415    #[test]
416    fn highlight_code_zig_types_and_strings() {
417        // Zig 整数类型、字符串字面量与十六进制数字应被识别。
418        let code = "const x: u32 = 0xFF;\nconst s = \"hello\"";
419        let result = highlight_code(code, Some("zig"));
420        assert!(
421            result.contains("support type") || result.contains("keyword"),
422            "Zig u32 类型未被识别: {}",
423            result
424        );
425        assert!(result.contains("string"), "Zig 字符串未被识别: {}", result);
426        assert!(result.contains("numeric"), "Zig 数字未被识别: {}", result);
427    }
428
429    #[test]
430    fn highlight_code_vue_sfc() {
431        // Vue SFC 三段(template HTML + script JS + style CSS)都应被识别,
432        // 不回退到纯文本(text plain)。
433        let code = "\
434<template>
435  <div class=\"hello\" @click=\"onClick\">{{ message }}</div>
436</template>
437
438<script setup>
439import { ref } from 'vue'
440const message = ref('Hello Vue!')
441</script>
442
443<style scoped>
444.hello { color: #42b983; }
445</style>";
446        let result = highlight_code(code, Some("vue"));
447        assert!(
448            !result.contains(r#"<span class="text plain">"#),
449            "Vue 不应回退到纯文本: {}",
450            result
451        );
452        assert!(
453            result.contains("entity name tag"),
454            "Vue template 标签未被识别: {}",
455            result
456        );
457        assert!(
458            result.contains("source js"),
459            "Vue script 段未嵌入 JS 高亮: {}",
460            result
461        );
462        assert!(
463            result.contains("source css"),
464            "Vue style 段未嵌入 CSS 高亮: {}",
465            result
466        );
467        assert!(
468            result.contains("entity other attribute-name"),
469            "Vue 指令/属性未被识别: {}",
470            result
471        );
472    }
473
474    #[test]
475    fn highlight_code_vue_script_lang_ts() {
476        // <script lang="ts"> 应嵌入 TypeScript(scope source ts),而非 JS。
477        let code = "<script lang=\"ts\">\nconst x: number = 42\n</script>";
478        let result = highlight_code(code, Some("vue"));
479        assert!(
480            result.contains("source ts"),
481            "Vue lang=ts 应嵌入 TS: {}",
482            result
483        );
484    }
485
486    #[test]
487    fn highlight_code_vue_resolves_vue_alias() {
488        // 别名表 "vue" 与扩展名 "vue" 输出应一致。
489        let code = "<template><p>{{ msg }}</p></template>";
490        let by_alias = highlight_code(code, Some("vue"));
491        let by_upper = highlight_code(code, Some("Vue"));
492        // 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。
493        assert_eq!(by_alias, by_upper);
494    }
495
496    #[test]
497    fn highlight_code_resolves_bun_alias_to_typescript() {
498        // bun 运行器跑 TypeScript;别名表把 "bun" 归一为 "ts" 扩展名。
499        // 用类型注解(TS 特有语法)验证命中的是 TypeScript 语法而非纯 JS。
500        let code = "const x: number = 1;";
501        let result = highlight_code(code, Some("bun"));
502        // 不应回退纯文本(纯文本无 <span> 高亮 span)。
503        assert!(
504            result.contains("<span"),
505            "bun 别名应触发语法高亮, got: {}",
506            result
507        );
508        // 与直接传 ts 的输出一致——别名表正确归一。
509        let by_ts = highlight_code(code, Some("ts"));
510        assert_eq!(result, by_ts);
511    }
512
513    #[test]
514    fn highlight_code_json_produces_spans() {
515        // MCP 客户端配置页的 JSON 片段经此路径高亮;验证 json 解析为彩色语法而非纯文本。
516        let code = r#"{"mcpServers": {"yggdrasil": {"type": "http"}}}"#;
517        let result = highlight_code(code, Some("json"));
518        assert!(
519            result.contains("<span"),
520            "json 应触发语法高亮, got: {}",
521            result
522        );
523        assert!(
524            !result.contains(r#"<span class="text plain">"#),
525            "json 不应回退纯文本: {}",
526            result
527        );
528    }
529
530    #[test]
531    fn highlight_code_bash_alias_produces_spans() {
532        // MCP 客户端配置页的 CLI 一行命令经 bash 别名高亮。
533        let code = "claude mcp add --transport http yggdrasil https://example.com/mcp";
534        let result = highlight_code(code, Some("bash"));
535        assert!(
536            result.contains("<span"),
537            "bash 别名应触发语法高亮, got: {}",
538            result
539        );
540        // bash 别名与直接传 sh 的输出须一致(别名表 "bash" -> "sh")。
541        let by_sh = highlight_code(code, Some("sh"));
542        assert_eq!(result, by_sh);
543    }
544    #[test]
545    fn highlight_code_toml() {
546        // 官方 sublimehq TOML 语法(version: 2),覆盖表头/键/字符串/数字/布尔/注释。
547        let code = "\
548[package]
549name = \"yggdrasil\"
550version = \"1.0\"
551edition = 2021
552
553[dependencies]
554syntect = { version = \"5\", features = [\"html\"] }
555published = true
556# a comment";
557        let result = highlight_code(code, Some("toml"));
558        // 根作用域 source.toml —— 证明命中真正的 TOML 语法而非纯文本。
559        assert!(
560            result.contains(r#"<span class="source toml">"#),
561            "TOML 未命中 source.toml 根作用域: {}",
562            result
563        );
564        assert!(
565            !result.contains(r#"<span class="text plain">"#),
566            "TOML 不应回退到纯文本: {}",
567            result
568        );
569        // [package] 表头 -> entity name section
570        assert!(
571            result.contains("entity name section"),
572            "TOML 表头未识别为 section: {}",
573            result
574        );
575        // 键 name/version/edition -> string unquoted
576        assert!(
577            result.contains("string unquoted"),
578            "TOML 裸键未被识别: {}",
579            result
580        );
581        // "yggdrasil" 字符串值 -> string quoted double
582        assert!(
583            result.contains("string quoted double"),
584            "TOML 字符串值未被识别: {}",
585            result
586        );
587        // 2021 -> constant numeric
588        assert!(
589            result.contains("constant numeric"),
590            "TOML 数字未被识别: {}",
591            result
592        );
593        // true -> constant language boolean
594        assert!(
595            result.contains("constant language boolean"),
596            "TOML 布尔值未被识别: {}",
597            result
598        );
599        // # a comment -> comment line number-sign
600        assert!(
601            result.contains("comment line number-sign"),
602            "TOML 注释未被识别: {}",
603            result
604        );
605    }
606
607    #[test]
608    fn highlight_code_toml_uppercase_matches_lowercase() {
609        // 语法注册了 .toml 扩展名;大写标识经小写回退路径应与 toml 等价。
610        let code = "[package]\nname = \"x\"";
611        let lower = highlight_code(code, Some("toml"));
612        let upper = highlight_code(code, Some("TOML"));
613        assert_eq!(lower, upper);
614        assert!(lower.contains(r#"<span class="source toml">"#));
615    }
616}