Skip to main content

ndg_commonmark/syntax/
syntastica.rs

1//! Syntastica-based syntax highlighting backend.
2//!
3//! This module provides a modern tree-sitter based syntax highlighter using the
4//! Syntastica library, which offers excellent language support including native
5//! Nix highlighting.
6//!
7//! ## Theme Support
8//!
9//! We programmatically load all available themes from `syntastica-themes`
10//! Some of the popular themes included are:
11//!
12//! - github (dark/light variants)
13//! - gruvbox (dark/light)
14//! - nord, dracula, catppuccin
15//! - tokyo night, solarized, monokai
16//! - And many more...
17
18use 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
44/// Syntastica-based syntax highlighter.
45pub 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
185/// Rewrites `(#any-of? @cap "a" "b" ...)` into `(#match? @cap "^(a|b|...)$")`.
186///
187/// nvim-treesitter's `#any-of?` is a Lua-backed predicate with no standard
188/// tree-sitter equivalent. The rewrite preserves the same semantics using the
189/// `#match?` predicate that tree-sitter-highlight natively supports.
190fn 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  /// Create a new Syntastica highlighter with all available themes.
331  ///
332  /// # Errors
333  ///
334  /// Currently never returns an error, but returns a Result for API
335  /// consistency.
336  pub fn new(syntax_queries_dir: Option<&Path>) -> SyntaxResult<Self> {
337    let mut themes = FxHashMap::default();
338
339    // Load all available themes
340    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    // Leak the language set into a `'static` reference so the `Processor` can
349    // hold it for the remainder of the process lifetime. This is sound for a
350    // CLI: the process exits when documentation generation completes and the OS
351    // reclaims the memory. It avoids the unsound lifetime fabrication that a
352    // raw-pointer cast would require.
353    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  /// Add a custom theme
364  pub fn add_theme(&mut self, name: String, theme: ResolvedTheme) {
365    self.themes.insert(name, theme);
366  }
367
368  /// Set the default theme
369  pub fn set_default_theme(&mut self, theme: ResolvedTheme) {
370    self.default_theme = theme;
371  }
372
373  /// Convert a language string to a Lang enum
374  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, // use fallback for plain text
413      _ => None,
414    }
415  }
416
417  /// Get the theme by name, falling back to default
418  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
585/// Create a Syntastica-based syntax manager with default configuration.
586///
587/// Syntastica provides modern tree-sitter based syntax highlighting with
588/// excellent language support including native Nix highlighting.
589///
590/// # Errors
591///
592/// Returns an error if the Syntastica highlighter fails to initialize.
593pub 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")); // single semicolon is a comment, not the directive
615  }
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    // Matches the actual query from nvf's nix.nix
657    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    // Non-any-of predicates must be preserved
675    assert!(output.contains("#set! injection.language"));
676    assert!(output.contains("#set! injection.combined"));
677    assert!(output.contains(";; extends"));
678  }
679}