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_script_function_definitions_differ_from_calls() {
373        // 函数定义与调用配色不同(定义用 clay,调用退为灰),scope 必须分开。
374        let code = "function setup() {}\nconst run = () => setup();\nclass A { load(): void { if (ok(x)) {} } }";
375        for lang in &["ts", "tsx", "jsx"] {
376            let result = highlight_code(code, Some(lang));
377            for name in ["setup", "run", "load"] {
378                assert!(
379                    result.contains(&format!(r#"entity name function {lang}">{name}</span>"#)),
380                    "{lang} 函数定义 {name} 未识别: {result}"
381                );
382            }
383            for name in ["setup", "ok"] {
384                assert!(
385                    result.contains(&format!(r#"variable function {lang}">{name}</span>"#)),
386                    "{lang} 函数调用 {name} 未识别: {result}"
387                );
388            }
389            assert!(
390                result.contains(&format!(r#"keyword control {lang}">if</span>"#)),
391                "{lang} 控制语句被识别为方法定义: {result}"
392            );
393        }
394    }
395
396    #[test]
397    fn highlight_code_jsx_tags_and_attributes() {
398        // JSX 标签名与属性名都应被识别。
399        let code = "const el = <Button title=\"save\" onClick={fn}>OK</Button>;";
400        for lang in &["jsx", "tsx"] {
401            let result = highlight_code(code, Some(lang));
402            assert!(
403                result.contains("entity name tag"),
404                "{lang} JSX 标签名未识别: {result}"
405            );
406            assert!(
407                result.contains("attribute"),
408                "{lang} JSX 属性名未识别: {result}"
409            );
410        }
411    }
412
413    #[test]
414    fn highlight_code_typescript_resolves_ts_alias() {
415        // 别名 "ts" 与 "typescript" 输出应一致。
416        let code = "const x: number = 1;";
417        let by_ext = highlight_code(code, Some("ts"));
418        let by_name = highlight_code(code, Some("typescript"));
419        assert_eq!(by_ext, by_name);
420        assert!(by_ext.contains("keyword"));
421    }
422
423    #[test]
424    fn highlight_code_zig_keywords_and_fn() {
425        // Zig 关键字 const/fn/pub 与内建函数 @import 都应被高亮。
426        let code = "const std = @import(\"std\");\npub fn main() void {}";
427        let result = highlight_code(code, Some("zig"));
428        assert!(result.contains("keyword"), "Zig 关键字未被识别: {}", result);
429        assert!(
430            result.contains("name function"),
431            "Zig 函数名未被识别: {}",
432            result
433        );
434        assert!(
435            result.contains("builtin") || result.contains("support function"),
436            "Zig 内建函数 @import 未被识别: {}",
437            result
438        );
439    }
440
441    #[test]
442    fn highlight_code_zig_types_and_strings() {
443        // Zig 整数类型、字符串字面量与十六进制数字应被识别。
444        let code = "const x: u32 = 0xFF;\nconst s = \"hello\"";
445        let result = highlight_code(code, Some("zig"));
446        assert!(
447            result.contains("support type") || result.contains("keyword"),
448            "Zig u32 类型未被识别: {}",
449            result
450        );
451        assert!(result.contains("string"), "Zig 字符串未被识别: {}", result);
452        assert!(result.contains("numeric"), "Zig 数字未被识别: {}", result);
453    }
454
455    #[test]
456    fn highlight_code_vue_sfc() {
457        // Vue SFC 三段(template HTML + script JS + style CSS)都应被识别,
458        // 不回退到纯文本(text plain)。
459        let code = "\
460<template>
461  <div class=\"hello\" @click=\"onClick\">{{ message }}</div>
462</template>
463
464<script setup>
465import { ref } from 'vue'
466const message = ref('Hello Vue!')
467</script>
468
469<style scoped>
470.hello { color: #42b983; }
471</style>";
472        let result = highlight_code(code, Some("vue"));
473        assert!(
474            !result.contains(r#"<span class="text plain">"#),
475            "Vue 不应回退到纯文本: {}",
476            result
477        );
478        assert!(
479            result.contains("entity name tag"),
480            "Vue template 标签未被识别: {}",
481            result
482        );
483        assert!(
484            result.contains("source js"),
485            "Vue script 段未嵌入 JS 高亮: {}",
486            result
487        );
488        assert!(
489            result.contains("source css"),
490            "Vue style 段未嵌入 CSS 高亮: {}",
491            result
492        );
493        assert!(
494            result.contains("entity other attribute-name"),
495            "Vue 指令/属性未被识别: {}",
496            result
497        );
498    }
499
500    #[test]
501    fn highlight_code_vue_script_lang_ts() {
502        // <script lang="ts"> 应嵌入 TypeScript(scope source ts),而非 JS。
503        let code = "<script lang=\"ts\">\nconst x: number = 42\n</script>";
504        let result = highlight_code(code, Some("vue"));
505        assert!(
506            result.contains("source ts"),
507            "Vue lang=ts 应嵌入 TS: {}",
508            result
509        );
510    }
511
512    #[test]
513    fn highlight_code_vue_resolves_vue_alias() {
514        // 别名表 "vue" 与扩展名 "vue" 输出应一致。
515        let code = "<template><p>{{ msg }}</p></template>";
516        let by_alias = highlight_code(code, Some("vue"));
517        let by_upper = highlight_code(code, Some("Vue"));
518        // 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。
519        assert_eq!(by_alias, by_upper);
520    }
521
522    #[test]
523    fn highlight_code_resolves_bun_alias_to_typescript() {
524        // bun 运行器跑 TypeScript;别名表把 "bun" 归一为 "ts" 扩展名。
525        // 用类型注解(TS 特有语法)验证命中的是 TypeScript 语法而非纯 JS。
526        let code = "const x: number = 1;";
527        let result = highlight_code(code, Some("bun"));
528        // 不应回退纯文本(纯文本无 <span> 高亮 span)。
529        assert!(
530            result.contains("<span"),
531            "bun 别名应触发语法高亮, got: {}",
532            result
533        );
534        // 与直接传 ts 的输出一致——别名表正确归一。
535        let by_ts = highlight_code(code, Some("ts"));
536        assert_eq!(result, by_ts);
537    }
538
539    #[test]
540    fn highlight_code_json_produces_spans() {
541        // MCP 客户端配置页的 JSON 片段经此路径高亮;验证 json 解析为彩色语法而非纯文本。
542        let code = r#"{"mcpServers": {"yggdrasil": {"type": "http"}}}"#;
543        let result = highlight_code(code, Some("json"));
544        assert!(
545            result.contains("<span"),
546            "json 应触发语法高亮, got: {}",
547            result
548        );
549        assert!(
550            !result.contains(r#"<span class="text plain">"#),
551            "json 不应回退纯文本: {}",
552            result
553        );
554    }
555
556    #[test]
557    fn highlight_code_bash_alias_produces_spans() {
558        // MCP 客户端配置页的 CLI 一行命令经 bash 别名高亮。
559        let code = "claude mcp add --transport http yggdrasil https://example.com/mcp";
560        let result = highlight_code(code, Some("bash"));
561        assert!(
562            result.contains("<span"),
563            "bash 别名应触发语法高亮, got: {}",
564            result
565        );
566        // bash 别名与直接传 sh 的输出须一致(别名表 "bash" -> "sh")。
567        let by_sh = highlight_code(code, Some("sh"));
568        assert_eq!(result, by_sh);
569    }
570    #[test]
571    fn highlight_code_toml() {
572        // 官方 sublimehq TOML 语法(version: 2),覆盖表头/键/字符串/数字/布尔/注释。
573        let code = "\
574[package]
575name = \"yggdrasil\"
576version = \"1.0\"
577edition = 2021
578
579[dependencies]
580syntect = { version = \"5\", features = [\"html\"] }
581published = true
582# a comment";
583        let result = highlight_code(code, Some("toml"));
584        // 根作用域 source.toml —— 证明命中真正的 TOML 语法而非纯文本。
585        assert!(
586            result.contains(r#"<span class="source toml">"#),
587            "TOML 未命中 source.toml 根作用域: {}",
588            result
589        );
590        assert!(
591            !result.contains(r#"<span class="text plain">"#),
592            "TOML 不应回退到纯文本: {}",
593            result
594        );
595        // [package] 表头 -> entity name section
596        assert!(
597            result.contains("entity name section"),
598            "TOML 表头未识别为 section: {}",
599            result
600        );
601        // 键 name/version/edition -> string unquoted
602        assert!(
603            result.contains("string unquoted"),
604            "TOML 裸键未被识别: {}",
605            result
606        );
607        // "yggdrasil" 字符串值 -> string quoted double
608        assert!(
609            result.contains("string quoted double"),
610            "TOML 字符串值未被识别: {}",
611            result
612        );
613        // 2021 -> constant numeric
614        assert!(
615            result.contains("constant numeric"),
616            "TOML 数字未被识别: {}",
617            result
618        );
619        // true -> constant language boolean
620        assert!(
621            result.contains("constant language boolean"),
622            "TOML 布尔值未被识别: {}",
623            result
624        );
625        // # a comment -> comment line number-sign
626        assert!(
627            result.contains("comment line number-sign"),
628            "TOML 注释未被识别: {}",
629            result
630        );
631    }
632
633    #[test]
634    fn highlight_code_toml_uppercase_matches_lowercase() {
635        // 语法注册了 .toml 扩展名;大写标识经小写回退路径应与 toml 等价。
636        let code = "[package]\nname = \"x\"";
637        let lower = highlight_code(code, Some("toml"));
638        let upper = highlight_code(code, Some("TOML"));
639        assert_eq!(lower, upper);
640        assert!(lower.contains(r#"<span class="source toml">"#));
641    }
642    #[test]
643    fn highlight_code_wgsl() {
644        // relrelb/sublime-wgsl 语法,覆盖 attribute/fn/类型/字面量/控制流/struct/注释。
645        let code = "\
646@vertex
647fn vs_main(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
648  let x: f32 = 1.5;
649  return vec4<f32>(pos, x);
650}
651// comment
652struct Uniforms { m: mat4x4<f32> }";
653        let result = highlight_code(code, Some("wgsl"));
654        // 根作用域 source.wgsl —— 证明命中真正的 WGSL 语法而非纯文本。
655        assert!(
656            result.contains(r#"<span class="source wgsl">"#),
657            "WGSL 未命中 source.wgsl 根作用域: {}",
658            result
659        );
660        assert!(
661            !result.contains(r#"<span class="text plain">"#),
662            "WGSL 不应回退到纯文本: {}",
663            result
664        );
665        // @vertex -> entity name attribute
666        assert!(
667            result.contains("entity name attribute"),
668            "WGSL @attribute 未识别: {}",
669            result
670        );
671        // fn -> keyword other fn
672        assert!(
673            result.contains("keyword other fn"),
674            "WGSL fn 未识别: {}",
675            result
676        );
677        // vec3/f32/mat4x4 -> storage type
678        assert!(
679            result.contains("storage type"),
680            "WGSL 类型(vec3/f32/mat4x4)未识别: {}",
681            result
682        );
683        // 1.5 -> constant numeric float
684        assert!(
685            result.contains("constant numeric float"),
686            "WGSL 浮点字面量未识别: {}",
687            result
688        );
689        // 0 -> constant numeric
690        assert!(
691            result.contains("constant numeric decimal"),
692            "WGSL 整数字面量未识别: {}",
693            result
694        );
695        // return -> keyword control
696        assert!(
697            result.contains("keyword control"),
698            "WGSL 控制关键字未识别: {}",
699            result
700        );
701        // struct -> keyword declaration struct
702        assert!(
703            result.contains("keyword declaration struct"),
704            "WGSL struct 未识别: {}",
705            result
706        );
707        // Uniforms -> entity name type
708        assert!(
709            result.contains("entity name type"),
710            "WGSL 自定义类型名未识别: {}",
711            result
712        );
713        // // comment -> comment line double-slash
714        assert!(
715            result.contains("comment line double-slash"),
716            "WGSL 行注释未识别: {}",
717            result
718        );
719        // pos/x/m -> variable other
720        assert!(
721            result.contains("variable other"),
722            "WGSL 变量未识别: {}",
723            result
724        );
725    }
726
727    #[test]
728    fn highlight_code_wgsl_uppercase_matches_lowercase() {
729        // 语法注册了 .wgsl 扩展名;大写标识经小写回退路径应与 wgsl 等价。
730        let code = "@compute @workgroup_size(1)\nfn main() {}";
731        let lower = highlight_code(code, Some("wgsl"));
732        let upper = highlight_code(code, Some("WGSL"));
733        assert_eq!(lower, upper);
734        assert!(lower.contains(r#"<span class="source wgsl">"#));
735    }
736}