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}