Skip to main content

ndg_commonmark/syntax/
types.rs

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