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 ("Zig", include_str!("../syntaxes/Zig.sublime-syntax")),
33 ];
34
35 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 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 if let Some(s) = ss.find_syntax_by_extension(lang) {
65 return s;
66 }
67 if let Some(s) = ss.find_syntax_by_name(lang) {
69 return s;
70 }
71 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 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 let aliases: &[(&str, &str)] = &[
88 ("rust", "rs"),
89 ("js", "js"),
90 ("javascript", "js"),
91 ("typescript", "ts"),
92 ("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", "vue"),
109 ];
110 for &(from, to) in aliases {
111 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 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 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 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 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 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 let lower = highlight_code("fn main() {}", Some("rust"));
252 let upper = highlight_code("fn main() {}", Some("RUST"));
253 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 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 assert!(by_alias.contains("span"));
266 }
267
268 #[test]
269 fn highlight_code_resolves_bash_alias() {
270 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 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 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 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 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 let code = "fn a() {}\nfn b() {}";
307 let result = highlight_code(code, Some("rust"));
308 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(by_alias, by_upper);
494 }
495
496 #[test]
497 fn highlight_code_resolves_bun_alias_to_typescript() {
498 let code = "const x: number = 1;";
501 let result = highlight_code(code, Some("bun"));
502 assert!(
504 result.contains("<span"),
505 "bun 别名应触发语法高亮, got: {}",
506 result
507 );
508 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 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 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 let by_sh = highlight_code(code, Some("sh"));
542 assert_eq!(result, by_sh);
543 }
544 #[test]
545 fn highlight_code_toml() {
546 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 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 assert!(
571 result.contains("entity name section"),
572 "TOML 表头未识别为 section: {}",
573 result
574 );
575 assert!(
577 result.contains("string unquoted"),
578 "TOML 裸键未被识别: {}",
579 result
580 );
581 assert!(
583 result.contains("string quoted double"),
584 "TOML 字符串值未被识别: {}",
585 result
586 );
587 assert!(
589 result.contains("constant numeric"),
590 "TOML 数字未被识别: {}",
591 result
592 );
593 assert!(
595 result.contains("constant language boolean"),
596 "TOML 布尔值未被识别: {}",
597 result
598 );
599 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 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}