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