miette_arborium/
lib.rs

1//! Arborium-powered syntax highlighter for miette diagnostics.
2//!
3//! This crate provides a [`MietteHighlighter`] that integrates arborium's tree-sitter
4//! based syntax highlighting into miette's error reporting output.
5//!
6//! # Quick Start
7//!
8//! Install the highlighter globally and miette will automatically use it:
9//!
10//! ```rust,ignore
11//! fn main() {
12//!     // Install the highlighter (call once at startup)
13//!     miette_arborium::install_global().ok();
14//!
15//!     // Now all miette errors will have syntax highlighting
16//!     // ... your code ...
17//! }
18//! ```
19//!
20//! # Features
21//!
22//! - **Language detection**: Automatically detects language from file extension or accepts
23//!   an explicit language name
24//! - **Full arborium language support**: Supports all languages enabled via Cargo features
25//!   (passthrough to arborium)
26//! - **Minimal dependencies**: Uses arborium's tree-sitter-based highlighter
27//! - **ANSI terminal output**: Renders highlighted code with terminal colors
28//!
29//! # Example
30//!
31//! ```rust,ignore
32//! use miette::{Diagnostic, NamedSource, SourceSpan};
33//! use thiserror::Error;
34//!
35//! #[derive(Error, Debug, Diagnostic)]
36//! #[error("syntax error")]
37//! struct SyntaxError {
38//!     #[source_code]
39//!     src: NamedSource<String>,
40//!     #[label("unexpected token here")]
41//!     span: SourceSpan,
42//! }
43//!
44//! fn main() -> miette::Result<()> {
45//!     miette_arborium::install_global().ok();
46//!
47//!     let source = r#"fn main() {
48//!     let x = 42
49//!     println!("{}", x);
50//! }"#;
51//!
52//!     Err(SyntaxError {
53//!         src: NamedSource::new("example.rs", source.to_string()),
54//!         span: (32..33).into(),
55//!     })?
56//! }
57//! ```
58
59use std::sync::RwLock;
60
61use arborium::Highlighter;
62use arborium_highlight::{ThemedSpan, spans_to_themed};
63use arborium_theme::{Style as ThemeStyle, Theme};
64use miette::highlighters::Highlighter as MietteHighlighterTrait;
65use owo_colors::Style;
66
67/// A syntax highlighter for miette that uses arborium for tree-sitter based highlighting.
68///
69/// This highlighter can be installed globally using [`install_global`] or used directly
70/// by setting it on miette's `GraphicalReportHandler`.
71pub struct MietteHighlighter {
72    inner: RwLock<Highlighter>,
73    theme: Theme,
74}
75
76impl MietteHighlighter {
77    /// Create a new miette highlighter with the default theme.
78    pub fn new() -> Self {
79        Self::with_theme(arborium_theme::builtin::catppuccin_mocha().clone())
80    }
81
82    /// Create a new miette highlighter with a custom theme.
83    pub fn with_theme(theme: Theme) -> Self {
84        Self {
85            inner: RwLock::new(Highlighter::new()),
86            theme,
87        }
88    }
89
90    /// Returns whether a language is supported by this highlighter.
91    pub fn is_supported(&self, language: &str) -> bool {
92        // Check if arborium has this language available
93        let mut inner = self.inner.write().unwrap();
94        inner.highlight_spans(language, "").is_ok()
95    }
96
97    /// Detect language from a source name (file path or extension).
98    ///
99    /// This delegates to [`arborium::detect_language`] which is generated from
100    /// the grammar registry, ensuring extensions stay in sync with supported languages.
101    pub fn detect_language(source_name: &str) -> Option<&'static str> {
102        arborium::detect_language(source_name)
103    }
104
105    /// Get a reference to the current theme.
106    pub fn theme(&self) -> &Theme {
107        &self.theme
108    }
109
110    /// Set a new theme.
111    pub fn set_theme(&mut self, theme: Theme) {
112        self.theme = theme;
113    }
114}
115
116impl Default for MietteHighlighter {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl MietteHighlighterTrait for MietteHighlighter {
123    fn start_highlighter_state<'h>(
124        &'h self,
125        source: &dyn miette::SpanContents<'_>,
126    ) -> Box<dyn miette::highlighters::HighlighterState + 'h> {
127        // Try to detect language from source name
128        let language = source.name().and_then(Self::detect_language);
129
130        // Check if we support the language
131        let language = match language {
132            Some(lang) if self.is_supported(lang) => Some(lang),
133            _ => None,
134        };
135
136        // Get the full source text
137        let source_text = std::str::from_utf8(source.data()).unwrap_or("").to_string();
138
139        // Highlight the entire source once to get themed spans
140        let themed_spans = if let Some(lang) = language {
141            let mut inner = self.inner.write().unwrap();
142            if let Ok(spans) = inner.highlight_spans(lang, &source_text) {
143                spans_to_themed(spans)
144            } else {
145                Vec::new()
146            }
147        } else {
148            Vec::new()
149        };
150
151        Box::new(MietteHighlighterState {
152            highlighter: self,
153            themed_spans,
154            line_start: 0,
155        })
156    }
157}
158
159struct MietteHighlighterState<'h> {
160    highlighter: &'h MietteHighlighter,
161    themed_spans: Vec<ThemedSpan>,
162    line_start: usize,
163}
164
165impl miette::highlighters::HighlighterState for MietteHighlighterState<'_> {
166    fn highlight_line<'s>(&mut self, line: &'s str) -> Vec<owo_colors::Styled<&'s str>> {
167        // Handle empty lines
168        if line.is_empty() {
169            self.line_start += 1;
170            return vec![Style::new().style(line)];
171        }
172
173        // If no themed spans, return unhighlighted
174        if self.themed_spans.is_empty() {
175            self.line_start += line.len() + 1;
176            return vec![Style::new().style(line)];
177        }
178
179        // Find where this line ends in the full source
180        let line_end = self.line_start + line.len();
181
182        // Collect spans that overlap with this line
183        let mut line_spans: Vec<&ThemedSpan> = self
184            .themed_spans
185            .iter()
186            .filter(|span| {
187                let span_start = span.start as usize;
188                let span_end = span.end as usize;
189                // Span overlaps if it starts before line ends and ends after line starts
190                span_start < line_end && span_end > self.line_start
191            })
192            .collect();
193
194        // Sort by start position
195        line_spans.sort_by_key(|span| span.start);
196
197        // Build styled spans for this line
198        let mut result = Vec::new();
199        let mut current_pos = 0;
200
201        for span in line_spans {
202            let span_start = span.start as usize;
203            let span_end = span.end as usize;
204
205            // Calculate positions relative to this line
206            let rel_start = span_start.saturating_sub(self.line_start);
207            let rel_end = (span_end - self.line_start).min(line.len());
208
209            // Skip spans we've already passed
210            if rel_end <= current_pos {
211                continue;
212            }
213
214            // Clip span start to current position (handle overlapping spans)
215            let rel_start = rel_start.max(current_pos);
216
217            // Add unhighlighted text before this span
218            if current_pos < rel_start {
219                result.push(Style::new().style(&line[current_pos..rel_start]));
220                current_pos = rel_start;
221            }
222
223            // Add the highlighted span
224            if rel_start < rel_end && rel_end <= line.len() {
225                // Get style from theme
226                let style =
227                    if let Some(theme_style) = self.highlighter.theme.style(span.theme_index) {
228                        convert_theme_style_to_owo(theme_style)
229                    } else {
230                        Style::new()
231                    };
232
233                result.push(style.style(&line[rel_start..rel_end]));
234                current_pos = rel_end;
235            }
236        }
237
238        // Add any remaining unhighlighted text
239        if current_pos < line.len() {
240            result.push(Style::new().style(&line[current_pos..]));
241        }
242
243        // Update line_start for next line (account for newline character)
244        self.line_start = line_end + 1;
245
246        // Return unhighlighted if we didn't produce any spans
247        if result.is_empty() {
248            vec![Style::new().style(line)]
249        } else {
250            result
251        }
252    }
253}
254
255/// Convert arborium's `ThemeStyle` to `owo_colors::Style`.
256fn convert_theme_style_to_owo(theme_style: &ThemeStyle) -> Style {
257    let mut style = Style::new();
258
259    // Apply foreground color if present
260    if let Some(fg) = theme_style.fg {
261        style = style.truecolor(fg.r, fg.g, fg.b);
262    }
263
264    // Apply background color if present
265    if let Some(bg) = theme_style.bg {
266        style = style.on_truecolor(bg.r, bg.g, bg.b);
267    }
268
269    // Apply modifiers
270    if theme_style.modifiers.bold {
271        style = style.bold();
272    }
273    if theme_style.modifiers.italic {
274        style = style.italic();
275    }
276    if theme_style.modifiers.underline {
277        style = style.underline();
278    }
279    if theme_style.modifiers.strikethrough {
280        style = style.strikethrough();
281    }
282
283    style
284}
285
286/// Install the arborium highlighter as miette's global highlighter.
287///
288/// This should be called once at the start of your program.
289///
290/// # Example
291///
292/// ```rust,ignore
293/// fn main() {
294///     miette_arborium::install_global().ok();
295///     // ... rest of your program ...
296/// }
297/// ```
298pub fn install_global() -> Result<(), miette::InstallError> {
299    miette::set_hook(Box::new(|_| {
300        Box::new(
301            miette::MietteHandlerOpts::new()
302                .with_syntax_highlighting(MietteHighlighter::new())
303                .build(),
304        )
305    }))
306}
307
308/// Install a custom themed highlighter as miette's global highlighter.
309///
310/// # Example
311///
312/// ```rust,ignore
313/// fn main() {
314///     let theme = arborium_theme::builtin::github_light().clone();
315///     miette_arborium::install_global_with_theme(theme).ok();
316///     // ... rest of your program ...
317/// }
318/// ```
319pub fn install_global_with_theme(theme: Theme) -> Result<(), miette::InstallError> {
320    miette::set_hook(Box::new(move |_| {
321        Box::new(
322            miette::MietteHandlerOpts::new()
323                .with_syntax_highlighting(MietteHighlighter::with_theme(theme.clone()))
324                .build(),
325        )
326    }))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_language_detection() {
335        assert_eq!(MietteHighlighter::detect_language("foo.rs"), Some("rust"));
336        assert_eq!(
337            MietteHighlighter::detect_language("/path/to/file.py"),
338            Some("python")
339        );
340        assert_eq!(
341            MietteHighlighter::detect_language("script.js"),
342            Some("javascript")
343        );
344        assert_eq!(MietteHighlighter::detect_language("no_extension"), None);
345    }
346
347    #[test]
348    fn test_theme_style_conversion() {
349        use arborium_theme::Color;
350
351        let theme_style = ThemeStyle::new().fg(Color::new(255, 0, 0)).bold().italic();
352
353        let owo_style = convert_theme_style_to_owo(&theme_style);
354
355        // We can't directly test the style, but we can verify it doesn't panic
356        let _styled = owo_style.style("test");
357    }
358}