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::{collections::HashMap, sync::Mutex};
19
20use syntastica::{Processor, render, renderer::HtmlRenderer};
21use syntastica_core::theme::ResolvedTheme;
22use syntastica_parsers::{Lang, LanguageSetImpl};
23
24use super::{
25  error::{SyntaxError, SyntaxResult},
26  types::{SyntaxConfig, SyntaxHighlighter, SyntaxManager},
27};
28
29/// Syntastica-based syntax highlighter.
30pub struct SyntasticaHighlighter {
31  themes:        HashMap<String, ResolvedTheme>,
32  default_theme: ResolvedTheme,
33  processor:     Mutex<Processor<'static, LanguageSetImpl>>,
34  renderer:      Mutex<HtmlRenderer>,
35}
36
37impl SyntasticaHighlighter {
38  /// Create a new Syntastica highlighter with all available themes.
39  ///
40  /// # Errors
41  ///
42  /// Currently never returns an error, but returns a Result for API
43  /// consistency.
44  pub fn new() -> SyntaxResult<Self> {
45    let mut themes = HashMap::new();
46
47    // Load all available themes
48    for theme_name in syntastica_themes::THEMES {
49      if let Some(theme) = syntastica_themes::from_str(theme_name) {
50        themes.insert((*theme_name).to_string(), theme);
51      }
52    }
53
54    let default_theme = syntastica_themes::one::dark();
55
56    // Leak the language set into a `'static` reference so the `Processor` can
57    // hold it for the remainder of the process lifetime. This is sound for a
58    // CLI: the process exits when documentation generation completes and the OS
59    // reclaims the memory. It avoids the unsound lifetime fabrication that a
60    // raw-pointer cast would require.
61    let language_set_static: &'static LanguageSetImpl =
62      Box::leak(Box::new(LanguageSetImpl::new()));
63    let processor = Processor::new(language_set_static);
64
65    Ok(Self {
66      themes,
67      default_theme,
68      processor: Mutex::new(processor),
69      renderer: Mutex::new(HtmlRenderer::new()),
70    })
71  }
72
73  /// Add a custom theme
74  pub fn add_theme(&mut self, name: String, theme: ResolvedTheme) {
75    self.themes.insert(name, theme);
76  }
77
78  /// Set the default theme
79  pub fn set_default_theme(&mut self, theme: ResolvedTheme) {
80    self.default_theme = theme;
81  }
82
83  /// Convert a language string to a Lang enum
84  fn parse_language(language: &str) -> Option<Lang> {
85    match language.to_lowercase().as_str() {
86      "rust" | "rs" => Some(Lang::Rust),
87      "python" | "py" => Some(Lang::Python),
88      "javascript" | "js" => Some(Lang::Javascript),
89      "typescript" | "ts" => Some(Lang::Typescript),
90      "tsx" => Some(Lang::Tsx),
91      "nix" => Some(Lang::Nix),
92      "bash" | "sh" | "shell" => Some(Lang::Bash),
93      "c" => Some(Lang::C),
94      "cpp" | "c++" | "cxx" => Some(Lang::Cpp),
95      "c_sharp" | "csharp" | "cs" => Some(Lang::CSharp),
96      "go" => Some(Lang::Go),
97      "java" => Some(Lang::Java),
98      "json" => Some(Lang::Json),
99      "yaml" | "yml" => Some(Lang::Yaml),
100      "html" => Some(Lang::Html),
101      "css" => Some(Lang::Css),
102      "markdown" | "md" => Some(Lang::Markdown),
103      "markdown_inline" => Some(Lang::MarkdownInline),
104      "sql" => Some(Lang::Sql),
105      "lua" => Some(Lang::Lua),
106      "ruby" | "rb" => Some(Lang::Ruby),
107      "php" => Some(Lang::Php),
108      "php_only" => Some(Lang::PhpOnly),
109      "haskell" | "hs" => Some(Lang::Haskell),
110      "scala" => Some(Lang::Scala),
111      "swift" => Some(Lang::Swift),
112      "makefile" | "make" => Some(Lang::Make),
113      "cmake" => Some(Lang::Cmake),
114      "asm" | "assembly" => Some(Lang::Asm),
115      "diff" | "patch" => Some(Lang::Diff),
116      "elixir" | "ex" | "exs" => Some(Lang::Elixir),
117      "jsdoc" => Some(Lang::Jsdoc),
118      "printf" => Some(Lang::Printf),
119      "regex" | "regexp" => Some(Lang::Regex),
120      "zig" => Some(Lang::Zig),
121      #[allow(clippy::match_same_arms, reason = "Explicit for documentation")]
122      "text" | "txt" | "plain" => None, // use fallback for plain text
123      _ => None,
124    }
125  }
126
127  /// Get the theme by name, falling back to default
128  fn get_theme(&self, theme_name: Option<&str>) -> &ResolvedTheme {
129    theme_name
130      .and_then(|name| self.themes.get(name))
131      .unwrap_or(&self.default_theme)
132  }
133}
134
135impl SyntaxHighlighter for SyntasticaHighlighter {
136  fn name(&self) -> &'static str {
137    "Syntastica"
138  }
139
140  fn supported_languages(&self) -> Vec<String> {
141    vec![
142      "rust",
143      "rs",
144      "python",
145      "py",
146      "javascript",
147      "js",
148      "typescript",
149      "ts",
150      "tsx",
151      "nix",
152      "bash",
153      "sh",
154      "shell",
155      "c",
156      "cpp",
157      "c++",
158      "cxx",
159      "c_sharp",
160      "csharp",
161      "cs",
162      "go",
163      "java",
164      "json",
165      "yaml",
166      "yml",
167      "html",
168      "css",
169      "markdown",
170      "md",
171      "markdown_inline",
172      "sql",
173      "lua",
174      "ruby",
175      "rb",
176      "php",
177      "php_only",
178      "haskell",
179      "hs",
180      "scala",
181      "swift",
182      "makefile",
183      "make",
184      "cmake",
185      "asm",
186      "assembly",
187      "diff",
188      "patch",
189      "elixir",
190      "ex",
191      "exs",
192      "jsdoc",
193      "printf",
194      "regex",
195      "regexp",
196      "zig",
197      "text",
198      "txt",
199      "plain",
200    ]
201    .into_iter()
202    .map(String::from)
203    .collect()
204  }
205
206  fn available_themes(&self) -> Vec<String> {
207    let mut themes: Vec<String> = self.themes.keys().cloned().collect();
208    themes.sort();
209    themes
210  }
211
212  fn highlight(
213    &self,
214    code: &str,
215    language: &str,
216    theme: Option<&str>,
217  ) -> SyntaxResult<String> {
218    let lang = Self::parse_language(language)
219      .ok_or_else(|| SyntaxError::UnsupportedLanguage(language.to_string()))?;
220
221    let theme = self.get_theme(theme);
222
223    // Use the reusable processor via Mutex for thread-safe interior mutability
224    let highlights = self
225      .processor
226      .lock()
227      .map_err(|e| {
228        SyntaxError::HighlightingFailed(format!("Processor lock poisoned: {e}"))
229      })?
230      .process(code, lang)
231      .map_err(|e| SyntaxError::HighlightingFailed(e.to_string()))?;
232
233    // Use the reusable renderer via Mutex for thread-safe interior mutability
234    let html = {
235      let mut renderer = self.renderer.lock().map_err(|e| {
236        SyntaxError::HighlightingFailed(format!("Renderer lock poisoned: {e}"))
237      })?;
238      render(&highlights, &mut *renderer, theme)
239    };
240
241    Ok(html)
242  }
243
244  fn language_from_extension(&self, extension: &str) -> Option<String> {
245    match extension.to_lowercase().as_str() {
246      "rs" => Some("rust".to_string()),
247      "py" | "pyw" => Some("python".to_string()),
248      "js" | "mjs" => Some("javascript".to_string()),
249      "ts" => Some("typescript".to_string()),
250      "tsx" => Some("tsx".to_string()),
251      "nix" => Some("nix".to_string()),
252      "sh" | "bash" | "zsh" | "fish" => Some("bash".to_string()),
253      "c" | "h" => Some("c".to_string()),
254      "cpp" | "cxx" | "cc" | "hpp" | "hxx" | "hh" => Some("cpp".to_string()),
255      "cs" => Some("c_sharp".to_string()),
256      "go" => Some("go".to_string()),
257      "java" => Some("java".to_string()),
258      "json" => Some("json".to_string()),
259      "yaml" | "yml" => Some("yaml".to_string()),
260      "html" | "htm" => Some("html".to_string()),
261      "css" => Some("css".to_string()),
262      "md" | "markdown" => Some("markdown".to_string()),
263      "sql" => Some("sql".to_string()),
264      "lua" => Some("lua".to_string()),
265      "rb" => Some("ruby".to_string()),
266      "php" => Some("php".to_string()),
267      "hs" => Some("haskell".to_string()),
268      "ml" | "mli" => Some("ocaml".to_string()),
269      "scala" => Some("scala".to_string()),
270      "swift" => Some("swift".to_string()),
271      "s" | "asm" => Some("asm".to_string()),
272      "diff" | "patch" => Some("diff".to_string()),
273      "ex" | "exs" => Some("elixir".to_string()),
274      "zig" => Some("zig".to_string()),
275      "txt" => Some("text".to_string()),
276      _ => None,
277    }
278  }
279}
280
281/// Create a Syntastica-based syntax manager with default configuration.
282///
283/// Syntastica provides modern tree-sitter based syntax highlighting with
284/// excellent language support including native Nix highlighting.
285///
286/// # Errors
287///
288/// Returns an error if the Syntastica highlighter fails to initialize.
289pub fn create_syntastica_manager() -> SyntaxResult<SyntaxManager> {
290  let highlighter = Box::new(SyntasticaHighlighter::new()?);
291  let config = SyntaxConfig {
292    default_theme: Some("one-dark".to_string()),
293    ..Default::default()
294  };
295  Ok(SyntaxManager::new(highlighter, config))
296}