1use std::{
19 fs,
20 path::{Path, PathBuf},
21 sync::Mutex,
22};
23
24use rustc_hash::FxHashMap;
25use syntastica::{
26 Processor,
27 language_set::{HighlightConfiguration, LanguageSet},
28 render,
29 renderer::HtmlRenderer,
30};
31use syntastica_core::theme::ResolvedTheme;
32use syntastica_parsers::Lang;
33use syntastica_query_preprocessor::{
34 process_highlights,
35 process_injections,
36 process_locals,
37};
38
39use super::{
40 error::{SyntaxError, SyntaxResult},
41 types::{SyntaxConfig, SyntaxHighlighter, SyntaxManager},
42};
43
44pub struct SyntasticaHighlighter {
46 themes: FxHashMap<String, ResolvedTheme>,
47 default_theme: ResolvedTheme,
48 language_set: &'static UserQueryLanguageSet,
49 workers: Mutex<Vec<HighlightWorker>>,
50}
51
52struct HighlightWorker {
53 processor: Processor<'static, UserQueryLanguageSet>,
54 renderer: HtmlRenderer,
55}
56
57struct UserQueryLanguageSet {
58 configs: Mutex<FxHashMap<Lang, &'static HighlightConfiguration>>,
59 syntax_queries_dir: Option<PathBuf>,
60}
61
62impl UserQueryLanguageSet {
63 fn new(syntax_queries_dir: Option<&Path>) -> Self {
64 Self {
65 configs: Mutex::new(FxHashMap::default()),
66 syntax_queries_dir: syntax_queries_dir.map(Path::to_path_buf),
67 }
68 }
69
70 fn config_for(
71 &self,
72 lang: Lang,
73 ) -> syntastica::Result<&'static HighlightConfiguration> {
74 {
75 let configs = self.configs.lock().map_err(|e| {
76 syntastica::Error::UnsupportedLanguage(format!(
77 "syntax language-set lock poisoned: {e}"
78 ))
79 })?;
80
81 if let Some(config) = configs.get(&lang).copied() {
82 return Ok(config);
83 }
84 }
85
86 let config =
87 build_highlight_config(lang, self.syntax_queries_dir.as_deref())
88 .map_err(|e| syntastica::Error::UnsupportedLanguage(e.to_string()))?;
89
90 let mut configs = self.configs.lock().map_err(|e| {
91 syntastica::Error::UnsupportedLanguage(format!(
92 "syntax language-set lock poisoned: {e}"
93 ))
94 })?;
95
96 let config = configs.get(&lang).copied().unwrap_or_else(|| {
97 let config: &'static HighlightConfiguration = Box::leak(Box::new(config));
98 configs.insert(lang, config);
99 config
100 });
101 drop(configs);
102
103 Ok(config)
104 }
105}
106
107fn build_highlight_config(
108 lang: Lang,
109 syntax_queries_dir: Option<&Path>,
110) -> SyntaxResult<HighlightConfiguration> {
111 let mut highlights_query = lang.highlights_query().to_string();
112 let mut injections_query = lang.injections_query().to_string();
113 let mut locals_query = lang.locals_query().to_string();
114
115 if let Some(base_dir) = syntax_queries_dir {
116 if let Some(query) = read_user_query(base_dir, lang, "highlights.scm")? {
117 let extends = is_extends_query(&query);
118 let processed =
119 process_highlights("", true, &rewrite_any_of_predicates(&query));
120 if extends {
121 highlights_query = format!("{highlights_query}\n{processed}");
122 } else {
123 highlights_query = processed;
124 }
125 }
126
127 if let Some(query) = read_user_query(base_dir, lang, "injections.scm")? {
128 let extends = is_extends_query(&query);
129 let processed =
130 process_injections("", true, &rewrite_any_of_predicates(&query));
131 if extends {
132 injections_query = format!("{injections_query}\n{processed}");
133 } else {
134 injections_query = processed;
135 }
136 }
137
138 if let Some(query) = read_user_query(base_dir, lang, "locals.scm")? {
139 let extends = is_extends_query(&query);
140 let processed =
141 process_locals("", true, &rewrite_any_of_predicates(&query));
142 if extends {
143 locals_query = format!("{locals_query}\n{processed}");
144 } else {
145 locals_query = processed;
146 }
147 }
148 }
149
150 let mut config = HighlightConfiguration::new(
151 lang.get(),
152 <&str>::from(lang),
153 &highlights_query,
154 &injections_query,
155 &locals_query,
156 )
157 .map_err(|e| {
158 SyntaxError::BackendError(format!(
159 "failed to build highlight config for '{}': {e}",
160 <&str>::from(lang)
161 ))
162 })?;
163 config.configure(syntastica::theme::THEME_KEYS);
164 Ok(config)
165}
166
167impl LanguageSet<'_> for UserQueryLanguageSet {
168 type Language = Lang;
169
170 fn get_language(
171 &self,
172 language: Self::Language,
173 ) -> syntastica::Result<&HighlightConfiguration> {
174 self.config_for(language)
175 }
176}
177
178fn is_extends_query(content: &str) -> bool {
179 content
180 .lines()
181 .next()
182 .is_some_and(|l| matches!(l.trim(), ";; extends" | ";;extends"))
183}
184
185fn rewrite_any_of_predicates(query: &str) -> String {
191 const NEEDLE: &str = "#any-of?";
192 let mut result = String::with_capacity(query.len());
193 let mut remaining = query;
194
195 loop {
196 match remaining.find(NEEDLE) {
197 None => {
198 result.push_str(remaining);
199 break;
200 },
201 Some(pos) => {
202 result.push_str(&remaining[..pos]);
203 let from = &remaining[pos..];
204 if let Some((replacement, consumed)) = parse_any_of_predicate(from) {
205 result.push_str(&replacement);
206 remaining = &from[consumed..];
207 } else {
208 result.push_str(NEEDLE);
209 remaining = &from[NEEDLE.len()..];
210 }
211 },
212 }
213 }
214
215 result
216}
217
218fn parse_any_of_predicate(s: &str) -> Option<(String, usize)> {
219 const NEEDLE: &str = "#any-of?";
220 let mut pos = NEEDLE.len();
221
222 let skip_ws = |p: usize| p + s[p..].len() - s[p..].trim_start().len();
223
224 pos = skip_ws(pos);
225
226 if !s[pos..].starts_with('@') {
227 return None;
228 }
229
230 let cap_start = pos;
231 pos += 1;
232 while pos < s.len() {
233 let b = s.as_bytes()[pos];
234 if b.is_ascii_whitespace() || b == b')' {
235 break;
236 }
237 pos += 1;
238 }
239 let capture_name = &s[cap_start..pos];
240
241 pos = skip_ws(pos);
242
243 let mut values: Vec<&str> = Vec::new();
244 while pos < s.len() && s.as_bytes()[pos] == b'"' {
245 pos += 1;
246 let val_start = pos;
247 while pos < s.len() && s.as_bytes()[pos] != b'"' {
248 if s.as_bytes()[pos] == b'\\' {
249 pos += 1;
250 }
251 pos += 1;
252 }
253 if pos >= s.len() {
254 return None;
255 }
256 values.push(&s[val_start..pos]);
257 pos += 1;
258 pos = skip_ws(pos);
259 }
260
261 if values.is_empty() {
262 return None;
263 }
264
265 let pattern = format!(
266 "^({})$",
267 values
268 .iter()
269 .map(|v| ts_regex_escape(v))
270 .collect::<Vec<_>>()
271 .join("|")
272 );
273 Some((format!("#match? {capture_name} \"{pattern}\""), pos))
274}
275
276fn ts_regex_escape(s: &str) -> String {
277 let mut out = String::with_capacity(s.len());
278 for c in s.chars() {
279 if matches!(
280 c,
281 '.'
282 | '*'
283 | '+'
284 | '?'
285 | '^'
286 | '$'
287 | '{'
288 | '}'
289 | '['
290 | ']'
291 | '|'
292 | '('
293 | ')'
294 | '\\'
295 ) {
296 out.push('\\');
297 }
298 out.push(c);
299 }
300 out
301}
302
303fn read_user_query(
304 base_dir: &Path,
305 lang: Lang,
306 file_name: &str,
307) -> SyntaxResult<Option<String>> {
308 let query_path = query_path_for_lang(base_dir, lang, file_name);
309 if !query_path.exists() {
310 return Ok(None);
311 }
312
313 fs::read_to_string(&query_path).map(Some).map_err(|e| {
314 SyntaxError::BackendError(format!(
315 "failed to read query override '{}': {e}",
316 query_path.display()
317 ))
318 })
319}
320
321fn query_path_for_lang(
322 base_dir: &Path,
323 lang: Lang,
324 file_name: &str,
325) -> PathBuf {
326 base_dir.join(<&str>::from(lang)).join(file_name)
327}
328
329impl SyntasticaHighlighter {
330 pub fn new(syntax_queries_dir: Option<&Path>) -> SyntaxResult<Self> {
337 let mut themes = FxHashMap::default();
338
339 for theme_name in syntastica_themes::THEMES {
341 if let Some(theme) = syntastica_themes::from_str(theme_name) {
342 themes.insert((*theme_name).to_string(), theme);
343 }
344 }
345
346 let default_theme = syntastica_themes::one::dark();
347
348 let language_set_static: &'static UserQueryLanguageSet =
354 Box::leak(Box::new(UserQueryLanguageSet::new(syntax_queries_dir)));
355 Ok(Self {
356 themes,
357 default_theme,
358 language_set: language_set_static,
359 workers: Mutex::new(Vec::new()),
360 })
361 }
362
363 pub fn add_theme(&mut self, name: String, theme: ResolvedTheme) {
365 self.themes.insert(name, theme);
366 }
367
368 pub fn set_default_theme(&mut self, theme: ResolvedTheme) {
370 self.default_theme = theme;
371 }
372
373 fn parse_language(language: &str) -> Option<Lang> {
375 match language.to_lowercase().as_str() {
376 "rust" | "rs" => Some(Lang::Rust),
377 "python" | "py" => Some(Lang::Python),
378 "javascript" | "js" => Some(Lang::Javascript),
379 "typescript" | "ts" => Some(Lang::Typescript),
380 "tsx" => Some(Lang::Tsx),
381 "nix" => Some(Lang::Nix),
382 "bash" | "sh" | "shell" => Some(Lang::Bash),
383 "c" => Some(Lang::C),
384 "cpp" | "c++" | "cxx" => Some(Lang::Cpp),
385 "c_sharp" | "csharp" | "cs" => Some(Lang::CSharp),
386 "go" => Some(Lang::Go),
387 "java" => Some(Lang::Java),
388 "json" => Some(Lang::Json),
389 "yaml" | "yml" => Some(Lang::Yaml),
390 "html" => Some(Lang::Html),
391 "css" => Some(Lang::Css),
392 "markdown" | "md" => Some(Lang::Markdown),
393 "markdown_inline" => Some(Lang::MarkdownInline),
394 "sql" => Some(Lang::Sql),
395 "lua" => Some(Lang::Lua),
396 "ruby" | "rb" => Some(Lang::Ruby),
397 "php" => Some(Lang::Php),
398 "php_only" => Some(Lang::PhpOnly),
399 "haskell" | "hs" => Some(Lang::Haskell),
400 "scala" => Some(Lang::Scala),
401 "swift" => Some(Lang::Swift),
402 "makefile" | "make" => Some(Lang::Make),
403 "cmake" => Some(Lang::Cmake),
404 "asm" | "assembly" => Some(Lang::Asm),
405 "diff" | "patch" => Some(Lang::Diff),
406 "elixir" | "ex" | "exs" => Some(Lang::Elixir),
407 "jsdoc" => Some(Lang::Jsdoc),
408 "printf" => Some(Lang::Printf),
409 "regex" | "regexp" => Some(Lang::Regex),
410 "zig" => Some(Lang::Zig),
411 #[expect(clippy::match_same_arms, reason = "Explicit for documentation")]
412 "text" | "txt" | "plain" => None, _ => None,
414 }
415 }
416
417 fn get_theme(&self, theme_name: Option<&str>) -> &ResolvedTheme {
419 theme_name
420 .and_then(|name| self.themes.get(name))
421 .unwrap_or(&self.default_theme)
422 }
423}
424
425impl SyntaxHighlighter for SyntasticaHighlighter {
426 fn name(&self) -> &'static str {
427 "Syntastica"
428 }
429
430 fn supported_languages(&self) -> Vec<String> {
431 vec![
432 "rust",
433 "rs",
434 "python",
435 "py",
436 "javascript",
437 "js",
438 "typescript",
439 "ts",
440 "tsx",
441 "nix",
442 "bash",
443 "sh",
444 "shell",
445 "c",
446 "cpp",
447 "c++",
448 "cxx",
449 "c_sharp",
450 "csharp",
451 "cs",
452 "go",
453 "java",
454 "json",
455 "yaml",
456 "yml",
457 "html",
458 "css",
459 "markdown",
460 "md",
461 "markdown_inline",
462 "sql",
463 "lua",
464 "ruby",
465 "rb",
466 "php",
467 "php_only",
468 "haskell",
469 "hs",
470 "scala",
471 "swift",
472 "makefile",
473 "make",
474 "cmake",
475 "asm",
476 "assembly",
477 "diff",
478 "patch",
479 "elixir",
480 "ex",
481 "exs",
482 "jsdoc",
483 "printf",
484 "regex",
485 "regexp",
486 "zig",
487 "text",
488 "txt",
489 "plain",
490 ]
491 .into_iter()
492 .map(String::from)
493 .collect()
494 }
495
496 fn available_themes(&self) -> Vec<String> {
497 let mut themes: Vec<String> = self.themes.keys().cloned().collect();
498 themes.sort();
499 themes
500 }
501
502 fn highlight(
503 &self,
504 code: &str,
505 language: &str,
506 theme: Option<&str>,
507 ) -> SyntaxResult<String> {
508 let lang = Self::parse_language(language)
509 .ok_or_else(|| SyntaxError::UnsupportedLanguage(language.to_string()))?;
510
511 let theme = self.get_theme(theme);
512
513 let mut worker = self
514 .workers
515 .lock()
516 .map_err(|e| {
517 SyntaxError::HighlightingFailed(format!(
518 "Worker pool lock poisoned: {e}"
519 ))
520 })?
521 .pop()
522 .unwrap_or_else(|| {
523 HighlightWorker {
524 processor: Processor::new(self.language_set),
525 renderer: HtmlRenderer::new(),
526 }
527 });
528
529 let result = worker
530 .processor
531 .process(code, lang)
532 .map(|highlights| render(&highlights, &mut worker.renderer, theme))
533 .map_err(|e| SyntaxError::HighlightingFailed(e.to_string()));
534
535 self
536 .workers
537 .lock()
538 .map_err(|e| {
539 SyntaxError::HighlightingFailed(format!(
540 "Worker pool lock poisoned: {e}"
541 ))
542 })?
543 .push(worker);
544
545 result
546 }
547
548 fn language_from_extension(&self, extension: &str) -> Option<String> {
549 match extension.to_lowercase().as_str() {
550 "rs" => Some("rust".to_string()),
551 "py" | "pyw" => Some("python".to_string()),
552 "js" | "mjs" => Some("javascript".to_string()),
553 "ts" => Some("typescript".to_string()),
554 "tsx" => Some("tsx".to_string()),
555 "nix" => Some("nix".to_string()),
556 "sh" | "bash" | "zsh" | "fish" => Some("bash".to_string()),
557 "c" | "h" => Some("c".to_string()),
558 "cpp" | "cxx" | "cc" | "hpp" | "hxx" | "hh" => Some("cpp".to_string()),
559 "cs" => Some("c_sharp".to_string()),
560 "go" => Some("go".to_string()),
561 "java" => Some("java".to_string()),
562 "json" => Some("json".to_string()),
563 "yaml" | "yml" => Some("yaml".to_string()),
564 "html" | "htm" => Some("html".to_string()),
565 "css" => Some("css".to_string()),
566 "md" | "markdown" => Some("markdown".to_string()),
567 "sql" => Some("sql".to_string()),
568 "lua" => Some("lua".to_string()),
569 "rb" => Some("ruby".to_string()),
570 "php" => Some("php".to_string()),
571 "hs" => Some("haskell".to_string()),
572 "ml" | "mli" => Some("ocaml".to_string()),
573 "scala" => Some("scala".to_string()),
574 "swift" => Some("swift".to_string()),
575 "s" | "asm" => Some("asm".to_string()),
576 "diff" | "patch" => Some("diff".to_string()),
577 "ex" | "exs" => Some("elixir".to_string()),
578 "zig" => Some("zig".to_string()),
579 "txt" => Some("text".to_string()),
580 _ => None,
581 }
582 }
583}
584
585pub fn create_syntastica_manager(
594 syntax_queries_dir: Option<&Path>,
595) -> SyntaxResult<SyntaxManager> {
596 let highlighter = Box::new(SyntasticaHighlighter::new(syntax_queries_dir)?);
597 let config = SyntaxConfig {
598 default_theme: Some("one-dark".to_string()),
599 ..Default::default()
600 };
601 Ok(SyntaxManager::new(highlighter, config))
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn test_is_extends_query() {
610 assert!(is_extends_query(";; extends\n(foo) @bar"));
611 assert!(is_extends_query(";;extends\n(foo) @bar"));
612 assert!(!is_extends_query("(foo) @bar"));
613 assert!(!is_extends_query(""));
614 assert!(!is_extends_query("; extends")); }
616
617 #[test]
618 fn test_rewrite_any_of_basic() {
619 let input = r#"((identifier) @_name (#any-of? @_name "foo" "bar"))"#;
620 let output = rewrite_any_of_predicates(input);
621 assert!(output.contains("#match?"));
622 assert!(output.contains("@_name"));
623 assert!(output.contains("^(foo|bar)$"));
624 assert!(!output.contains("#any-of?"));
625 }
626
627 #[test]
628 fn test_rewrite_any_of_multiple() {
629 let input = r#"
630 ((identifier) @a (#any-of? @a "x" "y"))
631 ((identifier) @b (#any-of? @b "p" "q" "r"))
632 "#;
633 let output = rewrite_any_of_predicates(input);
634 assert_eq!(output.matches("#match?").count(), 2);
635 assert!(!output.contains("#any-of?"));
636 assert!(output.contains("^(x|y)$"));
637 assert!(output.contains("^(p|q|r)$"));
638 }
639
640 #[test]
641 fn test_rewrite_any_of_regex_escaping() {
642 let input = r#"((identifier) @a (#any-of? @a "foo.bar" "baz"))"#;
643 let output = rewrite_any_of_predicates(input);
644 assert!(output.contains("foo\\.bar"));
645 }
646
647 #[test]
648 fn test_rewrite_any_of_no_match_passthrough() {
649 let input = "(foo) @bar (#eq? @bar \"baz\")";
650 let output = rewrite_any_of_predicates(input);
651 assert_eq!(input, output);
652 }
653
654 #[test]
655 fn test_rewrite_any_of_nvf_nix_query() {
656 let input = r#"
658;; extends
659
660((apply_expression
661 function: (variable_expression
662 name: (identifier) @_func
663 (#any-of? @_func "mkLuaInline" "entryAnywhere"))
664 argument: (indented_string_expression
665 (string_fragment) @injection.content))
666(#set! injection.language "lua")
667(#set! injection.combined))
668"#;
669 let output = rewrite_any_of_predicates(input);
670 assert!(!output.contains("#any-of?"));
671 assert!(
672 output.contains("#match? @_func \"^(mkLuaInline|entryAnywhere)$\"")
673 );
674 assert!(output.contains("#set! injection.language"));
676 assert!(output.contains("#set! injection.combined"));
677 assert!(output.contains(";; extends"));
678 }
679}