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::easy::HighlightLines;
16use syntect::highlighting::{Color as SynColor, FontStyle, Style as SynStyle, Theme, ThemeSet};
17use syntect::parsing::SyntaxSet;
18use syntect::util::LinesWithEndings;
19
20use crate::cells::cell_len;
21use crate::color::Color;
22use crate::console::{Console, ConsoleOptions};
23use crate::protocol::Renderable;
24use crate::segment::Segment;
25use crate::style::Style;
26
27/// The default theme (a dark base16 palette shipped with `syntect`).
28const DEFAULT_THEME: &str = "base16-ocean.dark";
29
30/// A block of syntax-highlighted source code. Mirrors `rich.syntax.Syntax`.
31pub struct Syntax {
32    code: String,
33    language: Option<String>,
34    theme: String,
35}
36
37impl Syntax {
38    /// Highlight `code` as `language` (a name or file extension, e.g. `"rust"`
39    /// or `"rs"`). Pass an empty/unknown language to render as plain text.
40    pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
41        Syntax {
42            code: code.into(),
43            language: Some(language.into()).filter(|l| !l.is_empty()),
44            theme: DEFAULT_THEME.to_string(),
45        }
46    }
47
48    /// Choose the highlighting theme (a `syntect` theme name). Unknown names fall
49    /// back to the default.
50    pub fn theme(mut self, theme: impl Into<String>) -> Self {
51        self.theme = theme.into();
52        self
53    }
54}
55
56fn syntax_set() -> &'static SyntaxSet {
57    static SET: OnceLock<SyntaxSet> = OnceLock::new();
58    SET.get_or_init(SyntaxSet::load_defaults_newlines)
59}
60
61fn theme_set() -> &'static ThemeSet {
62    static SET: OnceLock<ThemeSet> = OnceLock::new();
63    SET.get_or_init(ThemeSet::load_defaults)
64}
65
66/// Convert a `syntect` RGBA color to a truecolor [`Color`] (alpha dropped).
67fn to_color(c: SynColor) -> Color {
68    Color::from_rgb(c.r, c.g, c.b)
69}
70
71/// Convert a `syntect` style (fg/bg + font flags) to a rich [`Style`].
72fn to_style(s: SynStyle) -> Style {
73    let mut style = Style::new()
74        .with_color(to_color(s.foreground))
75        .with_bgcolor(to_color(s.background));
76    if s.font_style.contains(FontStyle::BOLD) {
77        style = style.combine(&Style::parse("bold").expect("valid style"));
78    }
79    if s.font_style.contains(FontStyle::ITALIC) {
80        style = style.combine(&Style::parse("italic").expect("valid style"));
81    }
82    if s.font_style.contains(FontStyle::UNDERLINE) {
83        style = style.combine(&Style::parse("underline").expect("valid style"));
84    }
85    style
86}
87
88impl Syntax {
89    fn theme_ref<'a>(&self, themes: &'a ThemeSet) -> &'a Theme {
90        themes
91            .themes
92            .get(&self.theme)
93            .or_else(|| themes.themes.get(DEFAULT_THEME))
94            .expect("default theme present")
95    }
96}
97
98impl Renderable for Syntax {
99    fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
100        let syntaxes = syntax_set();
101        let themes = theme_set();
102        let theme = self.theme_ref(themes);
103        let background = theme.settings.background.map(to_color);
104
105        // Resolve the language by token (name) or extension; else plain text.
106        let syntax = self
107            .language
108            .as_deref()
109            .and_then(|lang| {
110                syntaxes
111                    .find_syntax_by_token(lang)
112                    .or_else(|| syntaxes.find_syntax_by_extension(lang))
113            })
114            .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
115
116        let mut highlighter = HighlightLines::new(syntax, theme);
117        let width = options.max_width;
118
119        let mut lines: Vec<Vec<Segment>> = Vec::new();
120        for line in LinesWithEndings::from(&self.code) {
121            let ranges = highlighter
122                .highlight_line(line, syntaxes)
123                .unwrap_or_default();
124            let mut row: Vec<Segment> = Vec::new();
125            let mut used = 0usize;
126            for (syn_style, text) in ranges {
127                let text = text.strip_suffix('\n').unwrap_or(text);
128                if text.is_empty() {
129                    continue;
130                }
131                used += cell_len(text);
132                row.push(Segment::new(text, Some(to_style(syn_style))));
133            }
134            // Pad the line to the full width with the theme background, so the
135            // block reads as a solid panel of code.
136            if width > used {
137                let mut pad = Style::new();
138                if let Some(bg) = &background {
139                    pad = pad.with_bgcolor(bg.clone());
140                }
141                row.push(Segment::new(" ".repeat(width - used), Some(pad)));
142            }
143            lines.push(row);
144        }
145
146        let mut segments = Vec::new();
147        let last = lines.len().saturating_sub(1);
148        for (index, line) in lines.into_iter().enumerate() {
149            segments.extend(line);
150            if index != last {
151                segments.push(Segment::line());
152            }
153        }
154        segments
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::color::ColorSystem;
162
163    fn render(code: &str, lang: &str, width: usize) -> String {
164        Console::builder()
165            .force_terminal(true)
166            .color_system(Some(ColorSystem::Truecolor))
167            .width(width)
168            .no_color(false)
169            .build()
170            .render_to_string(&Syntax::new(code, lang))
171    }
172
173    #[test]
174    fn highlights_rust_keyword() {
175        // Functional (not byte-parity): assert the code text survives and the
176        // output is colored (contains SGR sequences).
177        let out = render("fn main() {}", "rust", 20);
178        assert!(out.contains("fn"));
179        assert!(out.contains("main"));
180        assert!(out.contains('\x1b'), "expected ANSI color codes");
181    }
182
183    #[test]
184    fn multiple_lines_are_separated() {
185        let out = render("let x = 1;\nlet y = 2;", "rust", 20);
186        assert_eq!(out.matches('\n').count(), 1);
187        assert!(out.contains("let"));
188    }
189
190    #[test]
191    fn unknown_language_renders_plain() {
192        // No panic, code preserved, still padded/colored to a block.
193        let out = render("just some text", "nonsense-lang", 20);
194        assert!(out.contains("just some text"));
195    }
196}