1#[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 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 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 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 if let Some(s) = ss.find_syntax_by_extension(lang) {
66 return s;
67 }
68 if let Some(s) = ss.find_syntax_by_name(lang) {
70 return s;
71 }
72 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 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 let aliases: &[(&str, &str)] = &[
89 ("rust", "rs"),
90 ("js", "js"),
91 ("javascript", "js"),
92 ("typescript", "ts"),
93 ("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", "vue"),
110 ];
111 for &(from, to) in aliases {
112 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 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 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 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 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 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 let lower = highlight_code("fn main() {}", Some("rust"));
253 let upper = highlight_code("fn main() {}", Some("RUST"));
254 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 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 assert!(by_alias.contains("span"));
267 }
268
269 #[test]
270 fn highlight_code_resolves_bash_alias() {
271 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 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 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 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 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 let code = "fn a() {}\nfn b() {}";
308 let result = highlight_code(code, Some("rust"));
309 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(by_alias, by_upper);
520 }
521
522 #[test]
523 fn highlight_code_resolves_bun_alias_to_typescript() {
524 let code = "const x: number = 1;";
527 let result = highlight_code(code, Some("bun"));
528 assert!(
530 result.contains("<span"),
531 "bun 别名应触发语法高亮, got: {}",
532 result
533 );
534 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 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 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 let by_sh = highlight_code(code, Some("sh"));
568 assert_eq!(result, by_sh);
569 }
570 #[test]
571 fn highlight_code_toml() {
572 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 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 assert!(
597 result.contains("entity name section"),
598 "TOML 表头未识别为 section: {}",
599 result
600 );
601 assert!(
603 result.contains("string unquoted"),
604 "TOML 裸键未被识别: {}",
605 result
606 );
607 assert!(
609 result.contains("string quoted double"),
610 "TOML 字符串值未被识别: {}",
611 result
612 );
613 assert!(
615 result.contains("constant numeric"),
616 "TOML 数字未被识别: {}",
617 result
618 );
619 assert!(
621 result.contains("constant language boolean"),
622 "TOML 布尔值未被识别: {}",
623 result
624 );
625 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 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 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 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 assert!(
667 result.contains("entity name attribute"),
668 "WGSL @attribute 未识别: {}",
669 result
670 );
671 assert!(
673 result.contains("keyword other fn"),
674 "WGSL fn 未识别: {}",
675 result
676 );
677 assert!(
679 result.contains("storage type"),
680 "WGSL 类型(vec3/f32/mat4x4)未识别: {}",
681 result
682 );
683 assert!(
685 result.contains("constant numeric float"),
686 "WGSL 浮点字面量未识别: {}",
687 result
688 );
689 assert!(
691 result.contains("constant numeric decimal"),
692 "WGSL 整数字面量未识别: {}",
693 result
694 );
695 assert!(
697 result.contains("keyword control"),
698 "WGSL 控制关键字未识别: {}",
699 result
700 );
701 assert!(
703 result.contains("keyword declaration struct"),
704 "WGSL struct 未识别: {}",
705 result
706 );
707 assert!(
709 result.contains("entity name type"),
710 "WGSL 自定义类型名未识别: {}",
711 result
712 );
713 assert!(
715 result.contains("comment line double-slash"),
716 "WGSL 行注释未识别: {}",
717 result
718 );
719 assert!(
721 result.contains("variable other"),
722 "WGSL 变量未识别: {}",
723 result
724 );
725 }
726
727 #[test]
728 fn highlight_code_wgsl_uppercase_matches_lowercase() {
729 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}