Skip to main content

rich/
syntax.rs

1//! Syntax highlighting.
2//!
3//! Port of `rich/syntax.py`'s renderable surface, powered by the `syntect`
4//! crate. A [`Syntax`] highlights a block of source code for a given language
5//! and theme, producing colored [`Segment`]s (a solid block: each line is padded
6//! to the render width with the theme background).
7//!
8//! **Divergence:** upstream uses Pygments; we use `syntect`, which ships
9//! different grammars and themes. So the *coloring is functional, not
10//! byte-identical* to Python rich — see docs/DIVERGENCES.md. Everything else
11//! (the renderable protocol, width handling) matches the port's conventions.
12
13use std::sync::OnceLock;
14
15use syntect::highlighting::{Color as SynColor, FontStyle, Style as SynStyle, Theme, ThemeSet};
16use syntect::parsing::SyntaxSet;
17use syntect::util::LinesWithEndings;
18
19#[cfg(not(feature = "syntax-cache"))]
20use syntect::easy::HighlightLines;
21#[cfg(feature = "syntax-cache")]
22#[path = "syntax_cache.rs"]
23mod cache;
24
25use crate::cells::cell_len;
26use crate::color::Color;
27use crate::console::{Console, ConsoleOptions};
28use crate::measure::Measurement;
29use crate::protocol::Renderable;
30use crate::segment::Segment;
31use crate::style::Style;
32use crate::text::is_control_code;
33
34/// The default theme (a dark base16 palette shipped with `syntect`).
35const DEFAULT_THEME: &str = "base16-ocean.dark";
36
37/// Upstream's `Syntax(tab_size=4)`.
38const DEFAULT_TAB_SIZE: usize = 4;
39
40/// A block of syntax-highlighted source code. Mirrors `rich.syntax.Syntax`.
41pub struct Syntax {
42    code: String,
43    language: Option<String>,
44    theme: String,
45    word_wrap: bool,
46    padding: usize,
47    tab_size: usize,
48}
49
50/// Port of Python's `str.expandtabs(tab_size)`, which `Syntax._process_code`
51/// runs over the source before highlighting it.
52///
53/// A tab advances to the next multiple of `tab_size` **counted in characters,
54/// not cells** (CPython's `unicode_expandtabs` walks code points), and the
55/// column resets at `\n` and `\r`. `tab_size == 0` deletes the tab, matching
56/// CPython's `tabsize <= 0` branch.
57///
58/// Without this the raw U+0009 reached the terminal, where it jumps to the next
59/// 8-cell stop while we had measured it as one cell: a block asked to be 30
60/// wide rendered 31-32 cells and tore the background panel.
61fn expand_tabs(code: &str, tab_size: usize) -> String {
62    if !code.contains('\t') {
63        return code.to_string();
64    }
65    let mut out = String::with_capacity(code.len());
66    let mut column = 0usize;
67    for ch in code.chars() {
68        match ch {
69            '\t' => {
70                if tab_size > 0 {
71                    let advance = tab_size - (column % tab_size);
72                    out.extend(std::iter::repeat_n(' ', advance));
73                    column += advance;
74                }
75            }
76            '\n' | '\r' => {
77                out.push(ch);
78                column = 0;
79            }
80            _ => {
81                out.push(ch);
82                column += 1;
83            }
84        }
85    }
86    out
87}
88
89impl Syntax {
90    /// Wrap lines wider than the render width instead of cropping them.
91    ///
92    /// Off by default, matching upstream's `Syntax(word_wrap=False)`: a long
93    /// line is cut at the width. Upstream's **CLI** turns this on, which is why
94    /// `rich --syntax` does too — cropping a source file silently loses code.
95    pub fn word_wrap(mut self, wrap: bool) -> Self {
96        self.word_wrap = wrap;
97        self
98    }
99
100    /// Highlight `code` as `language` (a name or file extension, e.g. `"rust"`
101    /// or `"rs"`). Pass an empty/unknown language to render as plain text.
102    pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
103        Syntax {
104            word_wrap: false,
105            padding: 0,
106            tab_size: DEFAULT_TAB_SIZE,
107            code: code.into(),
108            language: Some(language.into()).filter(|l| !l.is_empty()),
109            theme: DEFAULT_THEME.to_string(),
110        }
111    }
112
113    /// How far a tab advances the column, in characters. Upstream's
114    /// `Syntax(tab_size=…)`, default 4.
115    ///
116    /// Tabs are *expanded* to spaces before highlighting (upstream's
117    /// `code.expandtabs(self.tab_size)`), so this is the only tab handling in
118    /// play — the rendered code contains no U+0009 at all.
119    pub fn tab_size(mut self, tab_size: usize) -> Self {
120        self.tab_size = tab_size;
121        self
122    }
123
124    /// Surround the code with `padding` cells of background on every side.
125    ///
126    /// Upstream's Markdown renders a fenced block as `Syntax(..., padding=1)`,
127    /// which is what gives a code block its blank inset row above and below and
128    /// its one-column gutter. Without it the code sat flush against the
129    /// surrounding text and every document containing a fence diverged.
130    pub fn padding(mut self, padding: usize) -> Self {
131        self.padding = padding;
132        self
133    }
134
135    /// Choose the highlighting theme (a `syntect` theme name). Unknown names fall
136    /// back to the default.
137    pub fn theme(mut self, theme: impl Into<String>) -> Self {
138        self.theme = theme.into();
139        self
140    }
141}
142
143impl Syntax {
144    /// Highlight the code into a [`Text`](crate::text::Text) rather than a padded block. Port of
145    /// `Syntax.highlight`: the theme background is the text's base style and
146    /// every token carries its own style. Tabs are expanded first, as
147    /// `_process_code` does. Used by `Markdown(inline_code_lexer=…)`.
148    pub fn highlight(&self) -> crate::text::Text {
149        let syntaxes = syntax_set();
150        let themes = theme_set();
151        let theme = self.theme_ref(themes);
152        let syntax = self
153            .language
154            .as_deref()
155            .and_then(|lang| {
156                syntaxes
157                    .find_syntax_by_token(lang)
158                    .or_else(|| syntaxes.find_syntax_by_extension(lang))
159            })
160            .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
161        let mut text = crate::text::Text::new("");
162        if let Some(background) = theme.settings.background.map(to_color) {
163            text.set_base_style(Style::new().with_bgcolor(background));
164        }
165        #[cfg(not(feature = "syntax-cache"))]
166        let mut highlighter = HighlightLines::new(syntax, theme);
167        #[cfg(feature = "syntax-cache")]
168        let mut highlighter = cache::CachedHighlighter::new(syntax, theme);
169        let code = expand_tabs(&self.code, self.tab_size);
170        for line in LinesWithEndings::from(&code) {
171            for (syn_style, token) in highlighter
172                .highlight_line(line, syntaxes)
173                .unwrap_or_default()
174            {
175                text.append(token, Some(to_style(syn_style).into()));
176            }
177        }
178        text
179    }
180}
181
182fn syntax_set() -> &'static SyntaxSet {
183    static SET: OnceLock<SyntaxSet> = OnceLock::new();
184    SET.get_or_init(SyntaxSet::load_defaults_newlines)
185}
186
187fn theme_set() -> &'static ThemeSet {
188    static SET: OnceLock<ThemeSet> = OnceLock::new();
189    SET.get_or_init(ThemeSet::load_defaults)
190}
191
192/// Convert a `syntect` RGBA color to a truecolor [`Color`] (alpha dropped).
193fn to_color(c: SynColor) -> Color {
194    Color::from_rgb(c.r, c.g, c.b)
195}
196
197/// Convert a `syntect` style (fg/bg + font flags) to a rich [`Style`].
198fn to_style(s: SynStyle) -> Style {
199    let mut style = Style::new()
200        .with_color(to_color(s.foreground))
201        .with_bgcolor(to_color(s.background));
202    if s.font_style.contains(FontStyle::BOLD) {
203        style = style.combine(&Style::parse("bold").expect("valid style"));
204    }
205    if s.font_style.contains(FontStyle::ITALIC) {
206        style = style.combine(&Style::parse("italic").expect("valid style"));
207    }
208    if s.font_style.contains(FontStyle::UNDERLINE) {
209        style = style.combine(&Style::parse("underline").expect("valid style"));
210    }
211    style
212}
213
214impl Syntax {
215    fn theme_ref<'a>(&self, themes: &'a ThemeSet) -> &'a Theme {
216        themes
217            .themes
218            .get(&self.theme)
219            .or_else(|| themes.themes.get(DEFAULT_THEME))
220            .expect("default theme present")
221    }
222}
223
224/// Port of Python's `str.splitlines()`: every Unicode line boundary ends a
225/// line, `\r\n` counts once, and a trailing boundary adds no empty line.
226fn python_splitlines(text: &str) -> Vec<&str> {
227    let mut lines = Vec::new();
228    let mut start = 0;
229    let mut chars = text.char_indices().peekable();
230    while let Some((i, c)) = chars.next() {
231        if matches!(
232            c,
233            '\n' | '\r'
234                | '\x0b'
235                | '\x0c'
236                | '\x1c'
237                | '\x1d'
238                | '\x1e'
239                | '\u{85}'
240                | '\u{2028}'
241                | '\u{2029}'
242        ) {
243            lines.push(&text[start..i]);
244            start = i + c.len_utf8();
245            if c == '\r' && chars.peek().map(|&(_, n)| n) == Some('\n') {
246                chars.next();
247                start += 1;
248            }
249        }
250    }
251    if start < text.len() {
252        lines.push(&text[start..]);
253    }
254    lines
255}
256
257impl Renderable for Syntax {
258    /// Port of `Syntax.__rich_measure__` (no line numbers or `code_width` in
259    /// this port, so the numbers column is zero wide). Like upstream it
260    /// measures the raw source, where a tab counts as zero cells.
261    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
262        let widest = python_splitlines(&self.code)
263            .into_iter()
264            .map(cell_len)
265            .max()
266            .unwrap_or(0);
267        Measurement::new(0, self.padding * 2 + widest)
268    }
269
270    fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
271        let syntaxes = syntax_set();
272        let themes = theme_set();
273        let theme = self.theme_ref(themes);
274        let background = theme.settings.background.map(to_color);
275
276        // Resolve the language by token (name) or extension; else plain text.
277        let syntax = self
278            .language
279            .as_deref()
280            .and_then(|lang| {
281                syntaxes
282                    .find_syntax_by_token(lang)
283                    .or_else(|| syntaxes.find_syntax_by_extension(lang))
284            })
285            .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
286
287        #[cfg(not(feature = "syntax-cache"))]
288        let mut highlighter = HighlightLines::new(syntax, theme);
289        #[cfg(feature = "syntax-cache")]
290        let mut highlighter = cache::CachedHighlighter::new(syntax, theme);
291        // The gutter eats into the space the code itself may occupy.
292        let width = options.max_width;
293        let code_width = width.saturating_sub(self.padding * 2);
294
295        // `Syntax._process_code`: the source is tab-expanded before it reaches
296        // the highlighter, so no U+0009 ever survives into a segment.
297        let code = expand_tabs(&self.code, self.tab_size);
298
299        let mut lines: Vec<Vec<Segment>> = Vec::new();
300        for line in LinesWithEndings::from(&code) {
301            let ranges = highlighter
302                .highlight_line(line, syntaxes)
303                .unwrap_or_default();
304            let mut row: Vec<Segment> = Vec::new();
305            let mut used = 0usize;
306            for (syn_style, text) in ranges {
307                let text = text.strip_suffix('\n').unwrap_or(text);
308                if text.is_empty() {
309                    continue;
310                }
311                // Upstream's Syntax builds a `Text`, so `strip_control_codes`
312                // runs on every token. We emit segments directly, which let BEL,
313                // backspace, vertical tab and form feed through to the terminal
314                // — a backspace run rewrites what the reader sees.
315                let text: String = text.chars().filter(|c| !is_control_code(*c)).collect();
316                if text.is_empty() {
317                    continue;
318                }
319                used += cell_len(&text);
320                row.push(Segment::new(text, Some(to_style(syn_style))));
321            }
322            let _ = used;
323            lines.push(row);
324        }
325
326        // Upstream splits the source with Python's `str.split("\n")`, which keeps
327        // the empty element after a trailing newline — so a file ending in `\n`
328        // gets one final padded blank row. `LinesWithEndings` yields no such
329        // element, so every source (i.e. nearly every real file) rendered one row
330        // short of upstream. An empty source splits to `[""]`, one row, too.
331        if code.is_empty() || code.ends_with('\n') {
332            lines.push(Vec::new());
333        }
334
335        // Wrapping happens before padding, so every *visual* row gets the same
336        // background treatment rather than only the first.
337        if self.word_wrap {
338            lines = lines
339                .into_iter()
340                .flat_map(|row| {
341                    // A blank source line has no segments at all, and folding an
342                    // empty row yields *zero* rows rather than one empty one — so
343                    // wrapping silently deleted every blank line in the file.
344                    // `rich -x` on a 2698-line source dropped all 386 of them, and
345                    // the loss was baked into HTML exports too.
346                    if row.is_empty() {
347                        vec![Vec::new()]
348                    } else {
349                        Segment::split_lines(&Segment::fold_lines_words(&row, code_width))
350                    }
351                })
352                .collect();
353        }
354
355        // Left gutter, then the blank inset rows, both in the block background.
356        let pad_style = {
357            let mut style = Style::new();
358            if let Some(bg) = &background {
359                style = style.with_bgcolor(bg.clone());
360            }
361            style
362        };
363        if self.padding > 0 {
364            for row in &mut lines {
365                row.insert(
366                    0,
367                    Segment::new(" ".repeat(self.padding), Some(pad_style.clone())),
368                );
369            }
370            let blank = vec![Segment::new(" ".repeat(width), Some(pad_style.clone()))];
371            for _ in 0..self.padding {
372                lines.insert(0, blank.clone());
373                lines.push(blank.clone());
374            }
375        }
376
377        // Pad each line to the full width with the theme background, so the
378        // block reads as a solid panel of code.
379        for row in &mut lines {
380            let used: usize = row.iter().map(Segment::cell_length).sum();
381            if width > used {
382                let mut pad = Style::new();
383                if let Some(bg) = &background {
384                    pad = pad.with_bgcolor(bg.clone());
385                }
386                row.push(Segment::new(" ".repeat(width - used), Some(pad)));
387            }
388        }
389
390        let mut segments = Vec::new();
391        let last = lines.len().saturating_sub(1);
392        for (index, line) in lines.into_iter().enumerate() {
393            segments.extend(line);
394            if index != last {
395                segments.push(Segment::line());
396            }
397        }
398        segments
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::color::ColorSystem;
406
407    fn render(code: &str, lang: &str, width: usize) -> String {
408        Console::builder()
409            .force_terminal(true)
410            .color_system(Some(ColorSystem::Truecolor))
411            .width(width)
412            .no_color(false)
413            .build()
414            .render_to_string(&Syntax::new(code, lang))
415    }
416
417    #[test]
418    fn measured_syntax_still_prints_at_full_width() {
419        // Upstream renders a printed Syntax at the console width (its background
420        // pads every row); only str/Text shrink to their measurement.
421        let console = Console::builder().width(30).color_system(None).build();
422        let syntax = Syntax::new("x = 1", "python");
423        assert_eq!(syntax.measure(&console, &console.options()).maximum, 5);
424        let out = console.render_to_string(&syntax);
425        assert!(!out.contains('\x1b'), "{out:?}");
426        assert_eq!(cell_len(out.lines().next().unwrap()), 30, "{out:?}");
427    }
428
429    #[test]
430    fn splitlines_matches_python() {
431        assert_eq!(
432            python_splitlines("a\r\nb\rc\u{2028}d\n"),
433            ["a", "b", "c", "d"]
434        );
435        assert_eq!(python_splitlines("\n\n"), ["", ""]);
436        assert!(python_splitlines("").is_empty());
437    }
438
439    #[test]
440    fn highlights_rust_keyword() {
441        // Functional (not byte-parity): assert the code text survives and the
442        // output is colored (contains SGR sequences).
443        let out = render("fn main() {}", "rust", 20);
444        assert!(out.contains("fn"));
445        assert!(out.contains("main"));
446        assert!(out.contains('\x1b'), "expected ANSI color codes");
447    }
448
449    #[test]
450    fn multiple_lines_are_separated() {
451        let out = render("let x = 1;\nlet y = 2;", "rust", 20);
452        assert_eq!(out.matches('\n').count(), 1);
453        assert!(out.contains("let"));
454    }
455
456    #[test]
457    fn unknown_language_renders_plain() {
458        // No panic, code preserved, still padded/colored to a block.
459        let out = render("just some text", "nonsense-lang", 20);
460        assert!(out.contains("just some text"));
461    }
462
463    #[test]
464    fn word_wrap_is_off_by_default_matching_upstream() {
465        // Measured against upstream: Syntax(word_wrap=False) at width 80 keeps
466        // 80 of 300 characters. The default must not diverge from that.
467        let code = "A".repeat(300);
468        let out = render(&code, "python", 80);
469        assert_eq!(out.matches('A').count(), 80, "default should crop");
470    }
471
472    #[test]
473    fn word_wrap_keeps_every_character() {
474        let code = "A".repeat(300);
475        let console = Console::builder().width(80).color_system(None).build();
476        let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
477        assert_eq!(
478            out.matches('A').count(),
479            300,
480            "wrapping must not lose characters:
481{out}"
482        );
483    }
484
485    /// Syntax emits segments directly rather than going through `Text`, so the
486    /// shared `strip_control_codes` never ran and `rich -x` leaked backspaces
487    /// and BELs that `rich -m` did not.
488    #[test]
489    fn control_codes_are_stripped_from_highlighted_code() {
490        let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
491        for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
492            assert!(
493                !out.contains(code),
494                "control code {code:?} reached the output"
495            );
496        }
497        assert!(out.contains("let"), "content lost with the control codes");
498    }
499
500    /// A blank source line has no segments, and folding an empty row yielded
501    /// zero rows rather than one empty one — so wrapping silently deleted every
502    /// blank line in the file, and the loss was baked into exports.
503    #[test]
504    fn word_wrap_keeps_blank_lines() {
505        let console = Console::builder().width(20).color_system(None).build();
506        let out =
507            console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
508        let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
509        // Four rows, not three: upstream splits with Python's `str.split("\n")`,
510        // so the trailing newline contributes a final empty row —
511        // `"a = 1\n\nb = 2\n".split("\n") == ["a = 1", "", "b = 2", ""]`, and
512        // rich 15.0.0 prints four padded rows for it. This assertion previously
513        // said three, pinning our own missing-row bug as the expectation.
514        assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
515        assert!(
516            rows[1].trim().is_empty(),
517            "middle row should be blank: {rows:?}"
518        );
519        assert!(
520            rows[3].trim().is_empty(),
521            "trailing row should be blank: {rows:?}"
522        );
523    }
524
525    /// `Syntax._process_code` runs `code.expandtabs(self.tab_size)` before
526    /// anything is highlighted. We emitted the raw U+0009 and measured it as one
527    /// cell, so a tabbed line reached the terminal 31-32 cells wide against a
528    /// requested 30 and tore the background block.
529    ///
530    /// Both expectations captured verbatim from real rich 15.0.0.
531    #[test]
532    fn tabs_are_expanded_before_highlighting() {
533        let console = Console::builder().width(30).color_system(None).build();
534        let out = console.render_to_string(&Syntax::new(
535            "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
536            "python",
537        ));
538        assert_eq!(
539            out.split('\n').collect::<Vec<_>>(),
540            [
541                "def f():                      ",
542                "    if x:                     ",
543                "        return 1              ",
544                "    return 0                  ",
545            ]
546        );
547        assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
548    }
549
550    /// A tab advances to the next multiple of the tab size, so it is *not* a
551    /// fixed run of spaces — the width of the text before it decides.
552    #[test]
553    fn a_tab_advances_to_the_next_tab_stop() {
554        let console = Console::builder().width(20).color_system(None).build();
555        let out = console.render_to_string(&Syntax::new(
556            "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
557            "python",
558        ));
559        assert_eq!(
560            out.split('\n').collect::<Vec<_>>(),
561            [
562                "a   b   c           ",
563                "ab  cd  ef          ",
564                "abcd    efgh    ijkl",
565            ]
566        );
567    }
568
569    /// Every row must occupy exactly the requested width *on screen*.
570    ///
571    /// Measuring against [`cell_len`] cannot catch this: it counted a raw tab as
572    /// one cell and the padding was computed the same way, so the row looked
573    /// exactly `width` wide to us while the terminal advanced the tab to the
574    /// next 8-cell stop and the block overran by seven.
575    #[test]
576    fn a_tabbed_line_measures_the_requested_width() {
577        /// Width as the *terminal* renders it: a tab jumps to the next 8-cell
578        /// stop, which is the only measure that reveals the defect.
579        fn screen_width(row: &str) -> usize {
580            let mut column = 0usize;
581            for ch in row.chars() {
582                column += if ch == '\t' {
583                    8 - (column % 8)
584                } else {
585                    cell_len(ch.encode_utf8(&mut [0u8; 4]))
586                };
587            }
588            column
589        }
590
591        for width in [10usize, 20, 30, 40] {
592            let console = Console::builder().width(width).color_system(None).build();
593            let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
594            for row in out.split('\n') {
595                assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
596            }
597        }
598    }
599
600    /// `str.expandtabs` counts *characters*, not cells, and resets its column at
601    /// `\n` and `\r`.
602    #[test]
603    fn expand_tabs_matches_pythons_str_expandtabs() {
604        // Left column verified against CPython's `str.expandtabs(4)`.
605        for (input, expected) in [
606            ("a\tb", "a   b"),
607            ("ab\tb", "ab  b"),
608            ("abc\tb", "abc b"),
609            ("abcd\tb", "abcd    b"),
610            ("\t", "    "),
611            ("a\nbb\tc", "a\nbb  c"),
612            ("a\rbb\tc", "a\rbb  c"),
613            // A wide char counts as one column, exactly as in Python.
614            ("\u{4e2d}\tx", "\u{4e2d}   x"),
615        ] {
616            assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
617        }
618        // `tabsize <= 0` deletes the tab (CPython's own branch).
619        assert_eq!(expand_tabs("a\tb", 0), "ab");
620    }
621
622    /// Upstream's word_wrap breaks at word boundaries; we folded wherever the
623    /// row filled up, splitting identifiers mid-word.
624    #[test]
625    fn word_wrap_breaks_between_words() {
626        let console = Console::builder().width(30).color_system(None).build();
627        // This exact line is the one character-folding splits as `z` / `eta`,
628        // which is what makes the assertion discriminating.
629        let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
630        let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
631        // Every identifier must survive on a single row. Folding mid-word split
632        // `epsilon` across the break as `e` / `psilon`.
633        for word in [
634            "compute_total",
635            "alpha",
636            "gamma",
637            "epsilon",
638            "zeta",
639            "theta",
640        ] {
641            assert!(
642                out.split('\n').any(|row| row.contains(word)),
643                "{word:?} was split across rows: {out:?}"
644            );
645        }
646    }
647}