1use 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
27const DEFAULT_THEME: &str = "base16-ocean.dark";
29
30pub struct Syntax {
32 code: String,
33 language: Option<String>,
34 theme: String,
35}
36
37impl Syntax {
38 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 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
66fn to_color(c: SynColor) -> Color {
68 Color::from_rgb(c.r, c.g, c.b)
69}
70
71fn 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 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 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 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 let out = render("just some text", "nonsense-lang", 20);
194 assert!(out.contains("just some text"));
195 }
196}