ndg_commonmark/syntax/
types.rs

1//! Core types and traits for syntax highlighting.
2
3use std::{collections::HashMap, sync::Arc};
4
5use super::error::{SyntaxError, SyntaxResult};
6
7/// Trait for syntax highlighting backends.
8///
9/// Allows different syntax highlighting implementations to be used
10/// interchangeably. Implementations should handle language detection, theme
11/// management, and the actual highlighting process.
12pub trait SyntaxHighlighter: Send + Sync {
13  /// Get the name of this highlighter backend
14  fn name(&self) -> &'static str;
15
16  /// Get a list of supported languages
17  fn supported_languages(&self) -> Vec<String>;
18
19  /// Get a list of available themes
20  fn available_themes(&self) -> Vec<String>;
21
22  /// Check if a language is supported
23  fn supports_language(&self, language: &str) -> bool {
24    self
25      .supported_languages()
26      .iter()
27      .any(|lang| lang.eq_ignore_ascii_case(language))
28  }
29
30  /// Check if a theme is available
31  fn has_theme(&self, theme: &str) -> bool {
32    self
33      .available_themes()
34      .iter()
35      .any(|t| t.eq_ignore_ascii_case(theme))
36  }
37
38  /// Highlight code with the specified language and theme.
39  ///
40  /// # Arguments
41  ///
42  /// * `code` - The source code to highlight
43  /// * `language` - The programming language (case-insensitive)
44  /// * `theme` - The theme name (case-insensitive, optional)
45  ///
46  /// # Returns
47  ///
48  /// Highlighted HTML string on success
49  ///
50  /// # Errors
51  ///
52  /// Returns an error if the language or theme is unsupported.
53  fn highlight(
54    &self,
55    code: &str,
56    language: &str,
57    theme: Option<&str>,
58  ) -> SyntaxResult<String>;
59
60  /// Detect language from a file extension
61  fn language_from_extension(&self, extension: &str) -> Option<String>;
62
63  /// Detect language from a filename
64  fn language_from_filename(&self, filename: &str) -> Option<String> {
65    std::path::Path::new(filename)
66      .extension()
67      .and_then(|ext| ext.to_str())
68      .and_then(|ext| self.language_from_extension(ext))
69  }
70}
71
72/// Configuration for syntax highlighting
73#[derive(Debug, Clone)]
74pub struct SyntaxConfig {
75  /// Default theme to use when none is specified
76  pub default_theme: Option<String>,
77
78  /// Language aliases for mapping common names to supported languages
79  pub language_aliases: HashMap<String, String>,
80
81  /// Whether to fall back to plain text for unsupported languages
82  pub fallback_to_plain: bool,
83}
84
85impl Default for SyntaxConfig {
86  fn default() -> Self {
87    let mut language_aliases = HashMap::new();
88
89    // Common aliases
90    language_aliases.insert("js".to_string(), "javascript".to_string());
91    language_aliases.insert("ts".to_string(), "typescript".to_string());
92    language_aliases.insert("py".to_string(), "python".to_string());
93    language_aliases.insert("rb".to_string(), "ruby".to_string());
94    language_aliases.insert("sh".to_string(), "bash".to_string());
95    language_aliases.insert("shell".to_string(), "bash".to_string());
96    language_aliases.insert("yml".to_string(), "yaml".to_string());
97    language_aliases.insert("nixos".to_string(), "nix".to_string());
98    language_aliases.insert("md".to_string(), "markdown".to_string());
99
100    Self {
101      default_theme: None,
102      language_aliases,
103      fallback_to_plain: true,
104    }
105  }
106}
107
108/// High-level syntax highlighting manager.
109///
110/// Manages a syntax highlighting backend and provides a convenient
111/// interface for highlighting code with configuration options.
112///
113/// Uses `Arc` internally to allow cheap cloning, which is useful for
114/// sharing the expensive highlighter across multiple processor instances.
115#[derive(Clone)]
116pub struct SyntaxManager {
117  highlighter: Arc<dyn SyntaxHighlighter>,
118  config:      SyntaxConfig,
119}
120
121impl SyntaxManager {
122  /// Create a new syntax manager with the given highlighter and config
123  #[must_use]
124  pub fn new(
125    highlighter: Box<dyn SyntaxHighlighter>,
126    config: SyntaxConfig,
127  ) -> Self {
128    Self {
129      highlighter: Arc::from(highlighter),
130      config,
131    }
132  }
133
134  /// Create a new syntax manager with the default configuration
135  #[must_use]
136  pub fn with_highlighter(highlighter: Box<dyn SyntaxHighlighter>) -> Self {
137    Self::new(highlighter, SyntaxConfig::default())
138  }
139
140  /// Get the underlying highlighter
141  #[must_use]
142  pub fn highlighter(&self) -> &dyn SyntaxHighlighter {
143    self.highlighter.as_ref()
144  }
145
146  /// Get the configuration
147  #[must_use]
148  pub const fn config(&self) -> &SyntaxConfig {
149    &self.config
150  }
151
152  /// Update the configuration
153  pub fn set_config(&mut self, config: SyntaxConfig) {
154    self.config = config;
155  }
156
157  /// Resolve a language name using aliases
158  #[must_use]
159  pub fn resolve_language(&self, language: &str) -> String {
160    self
161      .config
162      .language_aliases
163      .get(language)
164      .cloned()
165      .unwrap_or_else(|| language.to_string())
166  }
167
168  /// Highlight code with automatic language resolution and fallback
169  ///
170  /// # Errors
171  ///
172  /// Returns an error if the language is unsupported and fallback is disabled.
173  pub fn highlight_code(
174    &self,
175    code: &str,
176    language: &str,
177    theme: Option<&str>,
178  ) -> SyntaxResult<String> {
179    let resolved_language = self.resolve_language(language);
180    let theme = theme.or(self.config.default_theme.as_deref());
181
182    // Try to highlight with the resolved language
183    if self.highlighter.supports_language(&resolved_language) {
184      return self.highlighter.highlight(code, &resolved_language, theme);
185    }
186
187    // If language is not supported and fallback is enabled, try plain text
188    if self.config.fallback_to_plain {
189      if self.highlighter.supports_language("text") {
190        return self.highlighter.highlight(code, "text", theme);
191      }
192      if self.highlighter.supports_language("plain") {
193        return self.highlighter.highlight(code, "plain", theme);
194      }
195    }
196
197    Err(SyntaxError::UnsupportedLanguage(resolved_language))
198  }
199
200  /// Highlight code from a filename
201  ///
202  /// # Errors
203  ///
204  /// Returns an error if the language cannot be determined from the filename
205  /// and fallback is disabled.
206  #[allow(
207    clippy::option_if_let_else,
208    reason = "Clearer with explicit fallback logic"
209  )]
210  pub fn highlight_from_filename(
211    &self,
212    code: &str,
213    filename: &str,
214    theme: Option<&str>,
215  ) -> SyntaxResult<String> {
216    if let Some(language) = self.highlighter.language_from_filename(filename) {
217      self.highlight_code(code, &language, theme)
218    } else if self.config.fallback_to_plain {
219      self.highlight_code(code, "text", theme)
220    } else {
221      Err(SyntaxError::UnsupportedLanguage(format!(
222        "from filename: {filename}"
223      )))
224    }
225  }
226}