Skip to main content

oak_highlight/highlighter/
mod.rs

1use crate::exporters::Exporter;
2use core::range::Range;
3use oak_core::{
4    TokenType,
5    language::{ElementRole, Language, TokenRole, UniversalElementRole, UniversalTokenRole},
6    tree::{RedLeaf, RedNode, RedTree},
7    visitor::Visitor,
8};
9use serde::{Deserialize, Serialize};
10use std::{
11    borrow::Cow,
12    collections::HashMap,
13    string::{String, ToString},
14    vec::Vec,
15};
16
17/// Highlight style configuration for visual text formatting.
18///
19/// This struct defines the visual appearance of highlighted text segments,
20/// including colors, font weight, and text decorations.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct HighlightStyle {
23    /// Foreground text color in hex format (e.g., "#FF0000" for red)
24    pub color: Option<String>,
25    /// Background color in hex format (e.g., "#FFFF00" for yellow)
26    pub background_color: Option<String>,
27    /// Whether text should be displayed in bold
28    pub bold: bool,
29    /// Whether text should be displayed in italic
30    pub italic: bool,
31    /// Whether text should be underlined
32    pub underline: bool,
33}
34
35impl Default for HighlightStyle {
36    fn default() -> Self {
37        Self { color: None, background_color: None, bold: false, italic: false, underline: false }
38    }
39}
40
41/// Highlight theme configuration containing style definitions for different roles.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct HighlightTheme {
44    /// Theme name identifier
45    pub name: String,
46    /// Style mapping for various scopes.
47    /// Scopes are dot-separated strings (e.g., "keyword.control.rust").
48    pub styles: HashMap<String, HighlightStyle>,
49}
50
51impl Default for HighlightTheme {
52    fn default() -> Self {
53        let mut styles = HashMap::new();
54
55        // Token Styles (Standard TextMate-like Scopes)
56        styles.insert("keyword".to_string(), HighlightStyle { color: Some("#0000FF".to_string()), bold: true, ..Default::default() });
57        styles.insert("keyword.operator".to_string(), HighlightStyle { color: Some("#800080".to_string()), ..Default::default() });
58        styles.insert("variable.other".to_string(), HighlightStyle { color: Some("#001080".to_string()), ..Default::default() });
59        styles.insert("constant".to_string(), HighlightStyle { color: Some("#098658".to_string()), ..Default::default() });
60        styles.insert("constant.character.escape".to_string(), HighlightStyle { color: Some("#FF6600".to_string()), ..Default::default() });
61        styles.insert("punctuation".to_string(), HighlightStyle { color: Some("#000080".to_string()), ..Default::default() });
62        styles.insert("comment".to_string(), HighlightStyle { color: Some("#808080".to_string()), italic: true, ..Default::default() });
63        styles.insert("punctuation.whitespace".to_string(), HighlightStyle::default());
64
65        // Element Styles
66        styles.insert("entity.name.function".to_string(), HighlightStyle { color: Some("#795E26".to_string()), bold: true, ..Default::default() });
67        styles.insert("entity.name.type".to_string(), HighlightStyle { color: Some("#267F99".to_string()), ..Default::default() });
68        styles.insert("variable.other.declaration".to_string(), HighlightStyle { color: Some("#795E26".to_string()), ..Default::default() });
69        styles.insert("comment.block.documentation".to_string(), HighlightStyle { color: Some("#008000".to_string()), italic: true, ..Default::default() });
70        styles.insert("meta.preprocessor".to_string(), HighlightStyle { color: Some("#AF00DB".to_string()), ..Default::default() });
71        styles.insert("entity.other.attribute-name".to_string(), HighlightStyle { color: Some("#AF00DB".to_string()), ..Default::default() });
72        styles.insert("entity.other.attribute-name.key".to_string(), HighlightStyle { color: Some("#001080".to_string()), ..Default::default() });
73
74        // Common
75        styles.insert("invalid".to_string(), HighlightStyle { color: Some("#FF0000".to_string()), background_color: Some("#FFCCCC".to_string()), ..Default::default() });
76        styles.insert("none".to_string(), HighlightStyle::default());
77
78        Self { name: "default".to_string(), styles }
79    }
80}
81
82impl HighlightTheme {
83    /// Resolves the style for a given scope, with fallback to parent scopes.
84    /// Example: "keyword.control.rust" -> "keyword.control" -> "keyword" -> None
85    pub fn resolve_style(&self, scope: &str) -> HighlightStyle {
86        let mut current_scope = scope;
87        while !current_scope.is_empty() {
88            if let Some(style) = self.styles.get(current_scope) {
89                return style.clone();
90            }
91            if let Some(pos) = current_scope.rfind('.') {
92                current_scope = &current_scope[..pos];
93            }
94            else {
95                break;
96            }
97        }
98        self.styles.get("none").cloned().unwrap_or_default()
99    }
100
101    /// Resolves the style for multiple scopes, returning the best (most specific) match.
102    /// This follows TextMate's specificity rules where the deepest match across all scopes wins.
103    pub fn resolve_styles(&self, scopes: &[String]) -> HighlightStyle {
104        let mut best_style = None;
105        let mut best_depth = -1;
106
107        for scope in scopes {
108            let mut current_scope = scope.as_str();
109            // Count segments for depth
110            let mut depth = (current_scope.split('.').count()) as i32;
111
112            while !current_scope.is_empty() {
113                if let Some(style) = self.styles.get(current_scope) {
114                    if depth > best_depth {
115                        best_depth = depth;
116                        best_style = Some(style.clone());
117                    }
118                    break; // Found the most specific match for this scope string
119                }
120                if let Some(pos) = current_scope.rfind('.') {
121                    current_scope = &current_scope[..pos];
122                    depth -= 1;
123                }
124                else {
125                    break;
126                }
127            }
128        }
129
130        best_style.unwrap_or_else(|| self.styles.get("none").cloned().unwrap_or_default())
131    }
132
133    pub fn get_token_style(&self, role: oak_core::UniversalTokenRole) -> HighlightStyle {
134        use oak_core::TokenRole;
135        self.resolve_style(role.name())
136    }
137
138    pub fn get_element_style(&self, role: oak_core::UniversalElementRole) -> HighlightStyle {
139        use oak_core::ElementRole;
140        self.resolve_style(role.name())
141    }
142}
143
144/// Helper to get scopes for a token role.
145fn get_token_scopes<R: TokenRole>(role: R, language: &str, category: oak_core::language::LanguageCategory) -> Vec<String> {
146    let specific_name = role.name();
147    let universal_role = role.universal();
148    let universal_name = universal_role.name();
149    let category_prefix = match category {
150        oak_core::language::LanguageCategory::Markup => "markup",
151        oak_core::language::LanguageCategory::Config => "config",
152        oak_core::language::LanguageCategory::Programming => "source",
153        oak_core::language::LanguageCategory::Dsl => "dsl",
154        _ => "source",
155    };
156
157    let mut scopes = Vec::new();
158
159    // 1. Language-specific scope (e.g., "keyword.control.rust")
160    scopes.push(format!("{}.{}", specific_name, language));
161
162    // 2. Base name scope (e.g., "keyword.control")
163    if specific_name != universal_name {
164        scopes.push(specific_name.to_string());
165    }
166
167    // 3. Category + Universal name (e.g., "source.keyword")
168    scopes.push(format!("{}.{}", category_prefix, universal_name));
169
170    // 4. Pure Universal name (e.g., "keyword")
171    scopes.push(universal_name.to_string());
172
173    // 5. Category + Language (e.g., "source.rust")
174    scopes.push(format!("{}.{}", category_prefix, language));
175
176    scopes
177}
178
179/// Helper to get scopes for an element role.
180fn get_element_scopes<R: ElementRole>(role: R, language: &str, category: oak_core::language::LanguageCategory) -> Vec<String> {
181    let specific_name = role.name();
182    let universal_role = role.universal();
183    let universal_name = universal_role.name();
184    let category_prefix = match category {
185        oak_core::language::LanguageCategory::Markup => "markup",
186        oak_core::language::LanguageCategory::Config => "config",
187        oak_core::language::LanguageCategory::Programming => "source",
188        oak_core::language::LanguageCategory::Dsl => "dsl",
189        _ => "source",
190    };
191
192    let mut scopes = Vec::new();
193
194    // 1. Language-specific scope
195    scopes.push(format!("{}.{}", specific_name, language));
196
197    // 2. Base name scope
198    if specific_name != universal_name {
199        scopes.push(specific_name.to_string());
200    }
201
202    // 3. Category + Universal name
203    scopes.push(format!("{}.{}", category_prefix, universal_name));
204
205    // 4. Pure Universal name
206    scopes.push(universal_name.to_string());
207
208    // 5. Category + Language
209    scopes.push(format!("{}.{}", category_prefix, language));
210
211    scopes
212}
213
214/// Trait for providing scopes for highlighting.
215pub trait ScopeProvider {
216    fn scopes(&self, language: &str, category: oak_core::language::LanguageCategory) -> Vec<String>;
217}
218
219impl ScopeProvider for UniversalTokenRole {
220    fn scopes(&self, language: &str, category: oak_core::language::LanguageCategory) -> Vec<String> {
221        get_token_scopes(*self, language, category)
222    }
223}
224
225impl ScopeProvider for UniversalElementRole {
226    fn scopes(&self, language: &str, category: oak_core::language::LanguageCategory) -> Vec<String> {
227        get_element_scopes(*self, language, category)
228    }
229}
230
231/// A serializable span representing a range in the source text.
232#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
233pub struct HighlightSpan {
234    pub start: usize,
235    pub end: usize,
236}
237
238impl From<Range<usize>> for HighlightSpan {
239    fn from(range: Range<usize>) -> Self {
240        Self { start: range.start, end: range.end }
241    }
242}
243
244/// A segment of highlighted text with associated style and content.
245///
246/// Represents a contiguous range of text that shares the same highlighting style.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct HighlightSegment<'a> {
249    /// Byte range in the source text that this segment covers
250    pub span: HighlightSpan,
251    /// Visual style to apply to this text segment
252    pub style: HighlightStyle,
253    /// The actual text content of this segment
254    pub text: Cow<'a, str>,
255}
256
257/// Result of kind highlighting containing styled text segments.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct HighlightResult<'a> {
260    pub segments: Vec<HighlightSegment<'a>>,
261    pub source: Cow<'a, str>,
262}
263
264pub struct HighlightVisitor<'a, 't> {
265    pub theme: &'t HighlightTheme,
266    pub segments: Vec<HighlightSegment<'a>>,
267    pub source: &'a str,
268}
269
270impl<'a, 't, 'tree, L: Language> Visitor<'tree, L> for HighlightVisitor<'a, 't> {
271    fn visit_node(&mut self, node: RedNode<'tree, L>) {
272        // Elements usually don't have direct colors unless they override token styles.
273        // For now, we follow the visitor pattern to traverse children.
274        for child in node.children() {
275            match child {
276                RedTree::Node(n) => <Self as Visitor<L>>::visit_node(self, n),
277                RedTree::Leaf(t) => <Self as Visitor<L>>::visit_token(self, t),
278            }
279        }
280    }
281
282    fn visit_token(&mut self, token: RedLeaf<L>) {
283        // Use scopes for highlighting
284        let scopes = get_token_scopes(token.kind.role(), L::NAME, L::CATEGORY);
285        let style = self.theme.resolve_styles(&scopes);
286
287        let text = &self.source[token.span.start..token.span.end];
288
289        self.segments.push(HighlightSegment { span: HighlightSpan { start: token.span.start, end: token.span.end }, style, text: Cow::Borrowed(text) });
290    }
291}
292
293/// Base trait for kind highlighters.
294///
295/// This trait defines the interface for kind highlighting implementations
296/// that can analyze source code and produce styled text segments.
297pub trait Highlighter {
298    /// Highlight the given source code for a specific language and theme.
299    fn highlight<'a>(&self, source: &'a str, language: &str, theme: crate::themes::Theme) -> oak_core::errors::ParseResult<HighlightResult<'a>>;
300}
301
302impl Highlighter for OakHighlighter {
303    fn highlight<'a>(&self, source: &'a str, language: &str, theme: crate::themes::Theme) -> oak_core::errors::ParseResult<HighlightResult<'a>> {
304        self.highlight(source, language, theme)
305    }
306}
307
308/// The main highlighter implementation that coordinates the highlighting process.
309///
310/// # Example
311///
312/// ```rust
313/// use oak_highlight::{OakHighlighter, Theme};
314///
315/// let highlighter = OakHighlighter::new();
316/// let result = highlighter.highlight("fn main() {}", "rust", Theme::OneDarkPro).unwrap();
317/// assert!(!result.segments.is_empty());
318/// ```
319pub struct OakHighlighter {
320    pub theme: HighlightTheme,
321}
322
323impl Default for OakHighlighter {
324    fn default() -> Self {
325        Self { theme: HighlightTheme::default() }
326    }
327}
328
329impl OakHighlighter {
330    pub fn new() -> Self {
331        Self::default()
332    }
333
334    pub fn with_theme(mut self, theme: HighlightTheme) -> Self {
335        self.theme = theme;
336        self
337    }
338
339    /// Set theme by name using the predefined themes.
340    pub fn theme(mut self, theme: crate::themes::Theme) -> Self {
341        self.theme = theme.get_theme();
342        self
343    }
344
345    /// Main highlight method matching README API.
346    pub fn highlight<'a>(&self, source: &'a str, _language: &str, theme: crate::themes::Theme) -> oak_core::errors::ParseResult<HighlightResult<'a>> {
347        let theme_config = theme.get_theme();
348
349        // Default implementation just treats everything as a single segment for now
350        // if no specific language parser is used.
351        // In a real scenario, we'd look up a parser from a registry.
352        let segments = vec![HighlightSegment { span: Range { start: 0, end: source.len() }.into(), style: theme_config.resolve_style("none"), text: Cow::Borrowed(source) }];
353
354        Ok(HighlightResult { segments, source: Cow::Borrowed(source) })
355    }
356
357    pub fn highlight_with_language<'a, L: Language + Send + Sync + 'static, P: oak_core::parser::Parser<L>, LX: oak_core::Lexer<L>>(
358        &self,
359        source: &'a str,
360        theme: crate::themes::Theme,
361        parser: &P,
362        _lexer: &LX,
363    ) -> oak_core::errors::ParseResult<HighlightResult<'a>> {
364        let theme_config = theme.get_theme();
365        let source_text = oak_core::source::SourceText::new(source.to_string());
366        let mut cache = oak_core::parser::session::ParseSession::<L>::new(1024);
367        let parse_result = parser.parse(&source_text, &[], &mut cache);
368
369        let mut visitor = HighlightVisitor { theme: &theme_config, segments: Vec::new(), source };
370
371        let root_node = parse_result.result.map_err(|e| e)?;
372        let red_root = RedNode::new(root_node, 0);
373
374        <HighlightVisitor<'a, '_> as Visitor<L>>::visit_node(&mut visitor, red_root);
375
376        Ok(HighlightResult { segments: visitor.segments, source: Cow::Borrowed(source) })
377    }
378
379    /// Highlight and format to a string directly.
380    pub fn highlight_format(&self, source: &str, language: &str, theme: crate::themes::Theme, format: crate::exporters::ExportFormat) -> oak_core::errors::ParseResult<String> {
381        let result = self.highlight(source, language, theme)?;
382
383        let content = match format {
384            crate::exporters::ExportFormat::Html => crate::exporters::HtmlExporter::new(true, true).export(&result),
385            crate::exporters::ExportFormat::Json => crate::exporters::JsonExporter { pretty: true }.export(&result),
386            crate::exporters::ExportFormat::Ansi => crate::exporters::AnsiExporter.export(&result),
387            crate::exporters::ExportFormat::Css => crate::exporters::CssExporter.export(&result),
388            _ => {
389                return Err(oak_core::errors::OakError::unsupported_format(format!("{format:?}")));
390            }
391        };
392
393        Ok(content)
394    }
395}