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