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_jsx_tags_and_attributes() {
373 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 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 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 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 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 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 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 assert_eq!(by_alias, by_upper);
495 }
496
497 #[test]
498 fn highlight_code_resolves_bun_alias_to_typescript() {
499 let code = "const x: number = 1;";
502 let result = highlight_code(code, Some("bun"));
503 assert!(
505 result.contains("<span"),
506 "bun 别名应触发语法高亮, got: {}",
507 result
508 );
509 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 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 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 let by_sh = highlight_code(code, Some("sh"));
543 assert_eq!(result, by_sh);
544 }
545 #[test]
546 fn highlight_code_toml() {
547 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 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 assert!(
572 result.contains("entity name section"),
573 "TOML 表头未识别为 section: {}",
574 result
575 );
576 assert!(
578 result.contains("string unquoted"),
579 "TOML 裸键未被识别: {}",
580 result
581 );
582 assert!(
584 result.contains("string quoted double"),
585 "TOML 字符串值未被识别: {}",
586 result
587 );
588 assert!(
590 result.contains("constant numeric"),
591 "TOML 数字未被识别: {}",
592 result
593 );
594 assert!(
596 result.contains("constant language boolean"),
597 "TOML 布尔值未被识别: {}",
598 result
599 );
600 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 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 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 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 assert!(
642 result.contains("entity name attribute"),
643 "WGSL @attribute 未识别: {}",
644 result
645 );
646 assert!(
648 result.contains("keyword other fn"),
649 "WGSL fn 未识别: {}",
650 result
651 );
652 assert!(
654 result.contains("storage type"),
655 "WGSL 类型(vec3/f32/mat4x4)未识别: {}",
656 result
657 );
658 assert!(
660 result.contains("constant numeric float"),
661 "WGSL 浮点字面量未识别: {}",
662 result
663 );
664 assert!(
666 result.contains("constant numeric decimal"),
667 "WGSL 整数字面量未识别: {}",
668 result
669 );
670 assert!(
672 result.contains("keyword control"),
673 "WGSL 控制关键字未识别: {}",
674 result
675 );
676 assert!(
678 result.contains("keyword declaration struct"),
679 "WGSL struct 未识别: {}",
680 result
681 );
682 assert!(
684 result.contains("entity name type"),
685 "WGSL 自定义类型名未识别: {}",
686 result
687 );
688 assert!(
690 result.contains("comment line double-slash"),
691 "WGSL 行注释未识别: {}",
692 result
693 );
694 assert!(
696 result.contains("variable other"),
697 "WGSL 变量未识别: {}",
698 result
699 );
700 }
701
702 #[test]
703 fn highlight_code_wgsl_uppercase_matches_lowercase() {
704 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}