ndg_commonmark/syntax/
types.rs1use std::sync::Arc;
4
5use rustc_hash::FxHashMap;
6
7use super::error::{SyntaxError, SyntaxResult};
8
9pub trait SyntaxHighlighter: Send + Sync {
15 fn name(&self) -> &'static str;
17
18 fn supported_languages(&self) -> Vec<String>;
20
21 fn available_themes(&self) -> Vec<String>;
23
24 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 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 fn highlight(
56 &self,
57 code: &str,
58 language: &str,
59 theme: Option<&str>,
60 ) -> SyntaxResult<String>;
61
62 fn language_from_extension(&self, extension: &str) -> Option<String>;
64
65 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#[derive(Debug, Clone)]
76pub struct SyntaxConfig {
77 pub default_theme: Option<String>,
79
80 pub language_aliases: FxHashMap<String, String>,
82
83 pub fallback_to_plain: bool,
85}
86
87impl Default for SyntaxConfig {
88 fn default() -> Self {
89 let mut language_aliases = FxHashMap::default();
90
91 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#[derive(Clone)]
118pub struct SyntaxManager {
119 highlighter: Arc<dyn SyntaxHighlighter>,
120 config: SyntaxConfig,
121}
122
123impl SyntaxManager {
124 #[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 #[must_use]
138 pub fn with_highlighter(highlighter: Box<dyn SyntaxHighlighter>) -> Self {
139 Self::new(highlighter, SyntaxConfig::default())
140 }
141
142 #[must_use]
144 pub fn highlighter(&self) -> &dyn SyntaxHighlighter {
145 self.highlighter.as_ref()
146 }
147
148 #[must_use]
150 pub const fn config(&self) -> &SyntaxConfig {
151 &self.config
152 }
153
154 pub fn set_config(&mut self, config: SyntaxConfig) {
156 self.config = config;
157 }
158
159 #[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 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 if self.highlighter.supports_language(&resolved_language) {
186 return self.highlighter.highlight(code, &resolved_language, theme);
187 }
188
189 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 #[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}