tui_syntax_highlight/
highlighter.rs

1use std::borrow::Cow;
2use std::fmt::{self, Debug, Formatter};
3use std::io::{self, BufRead, BufReader};
4use std::ops::Range;
5use std::sync::Arc;
6
7use ratatui::style::{Color, Style, Stylize};
8use ratatui::text::{Line, Span, Text};
9pub use syntect;
10use syntect::easy::HighlightLines;
11use syntect::highlighting::Theme;
12use syntect::parsing::{SyntaxReference, SyntaxSet};
13#[cfg(feature = "termprofile")]
14use termprofile::TermProfile;
15
16use crate::Converter;
17
18type GutterFn = dyn Fn(usize, Style) -> Vec<Span<'static>> + Send + Sync;
19
20#[derive(Clone)]
21struct GutterTemplate(Arc<GutterFn>);
22
23impl Debug for GutterTemplate {
24    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
25        f.write_str("GutterTemplate(<fn>)")
26    }
27}
28
29/// A syntax highlighter that produces styled [`Text`](ratatui::text::Text) output.
30/// The output style can be changed using the configuration methods provided in this struct.
31#[derive(Clone, Debug)]
32pub struct Highlighter {
33    theme: Theme,
34    override_background: Option<Color>,
35    line_number_style: Option<Style>,
36    line_number_separator_style: Option<Style>,
37    gutter_template: Option<GutterTemplate>,
38    line_numbers: bool,
39    line_number_padding: usize,
40    line_number_separator: String,
41    #[cfg(feature = "termprofile")]
42    profile: TermProfile,
43    highlight_ranges: Vec<Range<usize>>,
44    highlight_style: Style,
45    converter: Converter,
46}
47
48impl Highlighter {
49    /// Creates a new [`Highlighter`] with the given [`Theme`].
50    pub fn new(theme: Theme) -> Self {
51        Self {
52            theme,
53            override_background: None,
54            line_number_style: None,
55            line_number_separator_style: None,
56            gutter_template: None,
57            line_numbers: true,
58            line_number_padding: 4,
59            line_number_separator: "│".to_string(),
60            #[cfg(feature = "termprofile")]
61            profile: TermProfile::TrueColor,
62            highlight_ranges: Vec::new(),
63            highlight_style: Style::new().bg(Color::Yellow),
64            converter: Converter::new(),
65        }
66    }
67
68    /// Creates a new [`Highlighter`] with the given [`Theme`] and [`TermProfile`]. See the
69    /// [termprofile docs](https://crates.io/crates/termprofile) for details on how to load the
70    /// profile.
71    #[cfg(feature = "termprofile")]
72    pub fn with_profile(theme: Theme, profile: TermProfile) -> Self {
73        let mut this = Self::new(theme);
74        this.profile = profile;
75        this.converter = Converter::with_profile(profile);
76        this
77    }
78
79    /// Override the background with a different color.
80    /// Set this to [`Color::Reset`] to disable the background color.
81    pub fn override_background<C>(mut self, background: C) -> Self
82    where
83        C: Into<Color>,
84    {
85        let background = background.into();
86        self.override_background = Some(self.adapt_color(background).unwrap_or(Color::Reset));
87        self
88    }
89
90    /// Enable or disable line numbers in the left gutter.
91    pub fn line_numbers(mut self, line_numbers: bool) -> Self {
92        self.line_numbers = line_numbers;
93        self
94    }
95
96    /// Set the padding between the line number section and the rest of the code.
97    pub fn line_number_padding(mut self, padding: usize) -> Self {
98        self.line_number_padding = padding;
99        self
100    }
101
102    /// Set the [Style] for the line number section.
103    pub fn line_number_style<S>(mut self, style: S) -> Self
104    where
105        S: Into<Style>,
106    {
107        self.line_number_style = Some(self.adapt_style(style.into()));
108        self
109    }
110
111    /// Set the [Style] for the separator between the line number section and the rest of the code.
112    pub fn line_number_separator_style<S>(mut self, style: S) -> Self
113    where
114        S: Into<Style>,
115    {
116        self.line_number_separator_style = Some(self.adapt_style(style.into()));
117        self
118    }
119
120    /// Set the text used for the line number separator. `|` is used by default.
121    pub fn line_number_separator<T>(mut self, separator: T) -> Self
122    where
123        T: Into<String>,
124    {
125        self.line_number_separator = separator.into();
126        self
127    }
128
129    /// Highlight a specific range of code with a different style.
130    pub fn highlight_range(mut self, range: Range<usize>) -> Self {
131        self.highlight_ranges.push(range);
132        self
133    }
134
135    /// Set the style used for [`highlight_range`]. A yellow background is used by default.
136    ///
137    /// [`highlight_range`]: Self::highlight_range
138    pub fn highlight_style(mut self, style: Style) -> Self {
139        self.highlight_style = self.adapt_style(style);
140        self
141    }
142
143    /// Set a template function to configure the gutter section. This is an alternative to using
144    /// [`line_number_style`], [`line_number_separator_style`], and [`line_number_padding`] if you
145    /// need more flexibility.
146    ///
147    /// [`line_number_style`]: Self::line_number_style
148    /// [`line_number_separator_style`]: Self::line_number_separator_style
149    /// [`line_number_padding`]: Self::line_number_padding
150    pub fn gutter_template<F>(mut self, template: F) -> Self
151    where
152        F: Fn(usize, Style) -> Vec<Span<'static>> + Send + Sync + 'static,
153    {
154        self.gutter_template = Some(GutterTemplate(Arc::new(template)));
155        self
156    }
157
158    /// Returns the configured background color, accounting for both the theme and any overrides.
159    /// This is useful if you want to render the code block into a larger section and you need the
160    /// background colors to match.
161    pub fn get_background_color(&self) -> Option<Color> {
162        if let Some(bg) = self.override_background {
163            Some(bg)
164        } else {
165            self.theme
166                .settings
167                .background
168                .and_then(|bg| self.converter.syntect_color_to_tui(bg))
169        }
170    }
171
172    /// Returns the configured line number style, accounting for both the theme and any overrides.
173    pub fn get_line_number_style(&self) -> Style {
174        if let Some(style) = self.line_number_style {
175            return style;
176        }
177        let mut style = Style::new();
178        if let Some(fg) = self
179            .theme
180            .settings
181            .gutter_foreground
182            .and_then(|fg| self.converter.syntect_color_to_tui(fg))
183        {
184            style = style.fg(fg);
185        } else {
186            style = style.dark_gray();
187        }
188        if let Some(bg) = self.get_background_color() {
189            style = style.bg(bg);
190        }
191        self.adapt_style(style)
192    }
193
194    /// Highlights text from any [`io::Read`] source.
195    pub fn highlight_reader<R>(
196        &self,
197        reader: R,
198        syntax: &SyntaxReference,
199        syntaxes: &SyntaxSet,
200    ) -> Result<Text<'static>, crate::Error>
201    where
202        R: io::Read,
203    {
204        let mut reader = BufReader::new(reader);
205        let mut highlighter = HighlightLines::new(syntax, &self.theme);
206        let line_number_style = self.get_line_number_style();
207        let mut line = String::new();
208        let mut formatted = Vec::new();
209        let mut i = 0;
210        while reader.read_line(&mut line).map_err(crate::Error::Read)? > 0 {
211            let highlighted =
212                self.highlight_line(&line, &mut highlighter, i, line_number_style, syntaxes)?;
213            formatted.push(highlighted);
214            line.clear();
215            i += 1;
216        }
217        Ok(Text::from_iter(formatted))
218    }
219
220    /// Highlights text from an iterator.
221    pub fn highlight_lines<'a, T>(
222        &self,
223        source: T,
224        syntax: &SyntaxReference,
225        syntaxes: &SyntaxSet,
226    ) -> Result<Text<'static>, crate::Error>
227    where
228        T: IntoIterator<Item = &'a str>,
229    {
230        let mut highlighter = HighlightLines::new(syntax, &self.theme);
231        let line_number_style = self.get_line_number_style();
232        let formatted: Result<Vec<_>, crate::Error> = source
233            .into_iter()
234            .enumerate()
235            .map(|(i, line)| {
236                self.highlight_line(line, &mut highlighter, i, line_number_style, syntaxes)
237            })
238            .collect();
239        let formatted = formatted?;
240        Ok(Text::from_iter(formatted))
241    }
242
243    /// Highlights a single line.
244    pub fn highlight_line(
245        &self,
246        line: &str,
247        highlighter: &mut HighlightLines,
248        line_number: usize,
249        line_number_style: Style,
250        syntaxes: &SyntaxSet,
251    ) -> Result<Line<'static>, crate::Error> {
252        let line: Cow<_> = if line.ends_with("\n") {
253            line.into()
254        } else {
255            (line.to_string() + "\n").into()
256        };
257        let regions = highlighter
258            .highlight_line(&line, syntaxes)
259            .map_err(crate::Error::Highlight)?;
260        Ok(self.to_line(&regions, line_number, line_number_style))
261    }
262
263    fn get_initial_spans(
264        &self,
265        line_number: usize,
266        line_number_style: Style,
267    ) -> Vec<Span<'static>> {
268        // convert 0-based to 1-based
269        let line_number = line_number + 1;
270        if let Some(template) = &self.gutter_template {
271            return template.0(line_number, line_number_style);
272        }
273
274        if self.line_numbers {
275            let line_number = line_number.to_string();
276            let spaces = self
277                .line_number_padding
278                .saturating_sub(line_number.len())
279                // 2 extra spaces for left/right padding
280                .saturating_sub(2);
281            vec![
282                Span::styled(" ".repeat(spaces), line_number_style),
283                Span::styled(line_number, line_number_style),
284                Span::styled(" ", line_number_style),
285                Span::styled(
286                    self.line_number_separator.clone(),
287                    self.line_number_separator_style
288                        .unwrap_or(line_number_style),
289                ),
290                Span::styled(" ", line_number_style),
291            ]
292        } else {
293            vec![]
294        }
295    }
296
297    fn to_line(
298        &self,
299        v: &[(syntect::highlighting::Style, &str)],
300        line_number: usize,
301        line_number_style: Style,
302    ) -> Line<'static> {
303        let mut spans = self.get_initial_spans(line_number, line_number_style);
304        let highlight_row = self
305            .highlight_ranges
306            .iter()
307            .any(|r| r.contains(&line_number));
308
309        for &(ref style, mut text) in v {
310            let ends_with_newline = text.ends_with('\n');
311            if ends_with_newline {
312                text = &text[..text.len() - 1];
313            }
314
315            let mut tui_style = self.syntect_style_to_tui(*style);
316            if highlight_row {
317                tui_style = tui_style.patch(self.highlight_style);
318            }
319
320            spans.push(Span::styled(text.to_string(), tui_style));
321        }
322
323        let mut line = Line::from_iter(spans);
324        if highlight_row {
325            line = line.patch_style(self.highlight_style);
326        }
327        self.apply_background(line)
328    }
329
330    fn adapt_style(&self, style: Style) -> Style {
331        #[cfg(feature = "termprofile")]
332        return self.profile.adapt_style(style);
333        #[cfg(not(feature = "termprofile"))]
334        return style;
335    }
336
337    fn adapt_color(&self, color: Color) -> Option<Color> {
338        #[cfg(feature = "termprofile")]
339        return self.profile.adapt_color(color);
340
341        #[cfg(not(feature = "termprofile"))]
342        return Some(color);
343    }
344
345    fn apply_background<'a, S>(&self, item: S) -> S
346    where
347        S: Stylize<'a, S>,
348    {
349        if let Some(bg) = self.override_background {
350            return item.bg(bg);
351        };
352        if let Some(bg) = self
353            .theme
354            .settings
355            .background
356            .and_then(|bg| self.converter.syntect_color_to_tui(bg))
357        {
358            return item.bg(bg);
359        }
360        item
361    }
362
363    fn syntect_style_to_tui(&self, style: syntect::highlighting::Style) -> ratatui::style::Style {
364        let mut tui_style = self.converter.syntect_style_to_tui(style);
365
366        if let Some(bg) = self.override_background {
367            tui_style = tui_style.bg(bg);
368        }
369        tui_style
370    }
371}