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