use std::{collections::HashMap, sync::Arc};
use super::error::{SyntaxError, SyntaxResult};
pub trait SyntaxHighlighter: Send + Sync {
fn name(&self) -> &'static str;
fn supported_languages(&self) -> Vec<String>;
fn available_themes(&self) -> Vec<String>;
fn supports_language(&self, language: &str) -> bool {
self
.supported_languages()
.iter()
.any(|lang| lang.eq_ignore_ascii_case(language))
}
fn has_theme(&self, theme: &str) -> bool {
self
.available_themes()
.iter()
.any(|t| t.eq_ignore_ascii_case(theme))
}
fn highlight(
&self,
code: &str,
language: &str,
theme: Option<&str>,
) -> SyntaxResult<String>;
fn language_from_extension(&self, extension: &str) -> Option<String>;
fn language_from_filename(&self, filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|ext| ext.to_str())
.and_then(|ext| self.language_from_extension(ext))
}
}
#[derive(Debug, Clone)]
pub struct SyntaxConfig {
pub default_theme: Option<String>,
pub language_aliases: HashMap<String, String>,
pub fallback_to_plain: bool,
}
impl Default for SyntaxConfig {
fn default() -> Self {
let mut language_aliases = HashMap::new();
language_aliases.insert("js".to_string(), "javascript".to_string());
language_aliases.insert("ts".to_string(), "typescript".to_string());
language_aliases.insert("py".to_string(), "python".to_string());
language_aliases.insert("rb".to_string(), "ruby".to_string());
language_aliases.insert("sh".to_string(), "bash".to_string());
language_aliases.insert("shell".to_string(), "bash".to_string());
language_aliases.insert("yml".to_string(), "yaml".to_string());
language_aliases.insert("nixos".to_string(), "nix".to_string());
language_aliases.insert("md".to_string(), "markdown".to_string());
Self {
default_theme: None,
language_aliases,
fallback_to_plain: true,
}
}
}
#[derive(Clone)]
pub struct SyntaxManager {
highlighter: Arc<dyn SyntaxHighlighter>,
config: SyntaxConfig,
}
impl SyntaxManager {
#[must_use]
pub fn new(
highlighter: Box<dyn SyntaxHighlighter>,
config: SyntaxConfig,
) -> Self {
Self {
highlighter: Arc::from(highlighter),
config,
}
}
#[must_use]
pub fn with_highlighter(highlighter: Box<dyn SyntaxHighlighter>) -> Self {
Self::new(highlighter, SyntaxConfig::default())
}
#[must_use]
pub fn highlighter(&self) -> &dyn SyntaxHighlighter {
self.highlighter.as_ref()
}
#[must_use]
pub const fn config(&self) -> &SyntaxConfig {
&self.config
}
pub fn set_config(&mut self, config: SyntaxConfig) {
self.config = config;
}
#[must_use]
pub fn resolve_language(&self, language: &str) -> String {
self
.config
.language_aliases
.get(language)
.cloned()
.unwrap_or_else(|| language.to_string())
}
pub fn highlight_code(
&self,
code: &str,
language: &str,
theme: Option<&str>,
) -> SyntaxResult<String> {
let resolved_language = self.resolve_language(language);
let theme = theme.or(self.config.default_theme.as_deref());
if self.highlighter.supports_language(&resolved_language) {
return self.highlighter.highlight(code, &resolved_language, theme);
}
if self.config.fallback_to_plain {
if self.highlighter.supports_language("text") {
return self.highlighter.highlight(code, "text", theme);
}
if self.highlighter.supports_language("plain") {
return self.highlighter.highlight(code, "plain", theme);
}
}
Err(SyntaxError::UnsupportedLanguage(resolved_language))
}
#[allow(
clippy::option_if_let_else,
reason = "Clearer with explicit fallback logic"
)]
pub fn highlight_from_filename(
&self,
code: &str,
filename: &str,
theme: Option<&str>,
) -> SyntaxResult<String> {
if let Some(language) = self.highlighter.language_from_filename(filename) {
self.highlight_code(code, &language, theme)
} else if self.config.fallback_to_plain {
self.highlight_code(code, "text", theme)
} else {
Err(SyntaxError::UnsupportedLanguage(format!(
"from filename: {filename}"
)))
}
}
}