1use std::collections::HashMap;
6use std::path::Path;
7
8use syntect::easy::HighlightLines;
9use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
10use syntect::parsing::SyntaxSet;
11use syntect::util::LinesWithEndings;
12
13use crate::color::Color;
14use crate::console::{ConsoleOptions, RenderResult, Renderable};
15use crate::segment::Segment;
16use crate::style::Style;
17
18#[derive(Debug, Clone)]
20pub struct Syntax {
21 pub code: String,
23 pub language: String,
25 pub theme: String,
27 pub start_line: usize,
29 pub line_numbers: bool,
31 pub highlight: bool,
33 pub background_color: Option<crate::color::Color>,
35 pub tab_size: usize,
37 pub line_styles: HashMap<usize, Style>,
39}
40
41impl Syntax {
42 pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
44 Self {
45 code: code.into(),
46 language: language.into(),
47 theme: "base16-ocean.dark".to_string(),
48 start_line: 1,
49 line_numbers: false,
50 highlight: true,
51 background_color: None,
52 tab_size: 4,
53 line_styles: HashMap::new(),
54 }
55 }
56
57 pub fn theme(mut self, theme: impl Into<String>) -> Self {
59 self.theme = theme.into();
60 self
61 }
62
63 pub fn line_numbers(mut self) -> Self {
65 self.line_numbers = true;
66 self
67 }
68
69 pub fn start_line(mut self, n: usize) -> Self {
71 self.start_line = n;
72 self
73 }
74
75 pub fn background(mut self, color: crate::color::Color) -> Self {
77 self.background_color = Some(color);
78 self
79 }
80
81 pub fn from_path(
98 path: impl AsRef<Path>,
99 line_numbers: bool,
100 theme: Option<&str>,
101 ) -> std::io::Result<Self> {
102 let path = path.as_ref();
103 let code = std::fs::read_to_string(path)?;
104 let language = Self::guess_lexer(path).unwrap_or_default();
105 let mut syntax = Syntax::new(code, language);
106 if line_numbers {
107 syntax = syntax.line_numbers();
108 }
109 if let Some(t) = theme {
110 syntax = syntax.theme(t);
111 }
112 Ok(syntax)
113 }
114
115 pub fn guess_lexer(path: impl AsRef<Path>) -> Option<String> {
120 guess_lexer_for_filename(path.as_ref().to_str()?)
121 }
122
123 pub fn stylize_range(mut self, start_line: usize, end_line: usize, style: Style) -> Self {
138 for line in start_line..=end_line {
139 self.line_styles.insert(line, style.clone());
140 }
141 self
142 }
143
144 pub fn get_theme(&self) -> &str {
146 &self.theme
147 }
148
149 pub fn default_lexer() -> &'static str {
151 "text"
152 }
153}
154
155impl Renderable for Syntax {
156 fn render(&self, _options: &ConsoleOptions) -> RenderResult {
157 if !self.highlight || self.language.is_empty() {
158 let mut lines: Vec<Vec<Segment>> = self
160 .code
161 .lines()
162 .map(|line| vec![Segment::new(line), Segment::line()])
163 .collect();
164
165 apply_line_styles(&mut lines, self.start_line, &self.line_styles);
167
168 return RenderResult {
169 lines,
170 items: Vec::new(),
171 };
172 }
173
174 let ss = SyntaxSet::load_defaults_newlines();
175 let ts = ThemeSet::load_defaults();
176
177 let syntax = ss
178 .find_syntax_by_name(&self.language)
179 .or_else(|| ss.find_syntax_by_extension(&self.language))
180 .unwrap_or_else(|| ss.find_syntax_plain_text());
181
182 let theme = ts
183 .themes
184 .get(&self.theme)
185 .unwrap_or_else(|| &ts.themes["base16-ocean.dark"]);
186
187 let mut highlighter = HighlightLines::new(syntax, theme);
188
189 let mut lines: Vec<Vec<Segment>> = Vec::new();
190 let line_num_width = if self.line_numbers {
191 (self.code.lines().count().saturating_add(self.start_line))
192 .to_string()
193 .len()
194 } else {
195 0
196 };
197
198 for (i, line) in LinesWithEndings::from(&self.code).enumerate() {
199 let mut line_segments: Vec<Segment> = Vec::new();
200
201 if self.line_numbers {
203 let num = i + self.start_line;
204 let num_str = format!("{:>width$} │ ", num, width = line_num_width);
205 line_segments.push(Segment::new(num_str));
206 }
207
208 match highlighter.highlight_line(line, &ss) {
210 Ok(highlighted) => {
211 for (syntect_style, text) in &highlighted {
212 let style = syntect_to_rich_style(syntect_style);
213 line_segments.push(Segment::styled(text.to_string(), style));
214 }
215 }
216 Err(_) => {
217 line_segments.push(Segment::new(line));
218 }
219 }
220
221 lines.push(line_segments);
222 }
223
224 apply_line_styles(&mut lines, self.start_line, &self.line_styles);
226
227 RenderResult {
228 lines,
229 items: Vec::new(),
230 }
231 }
232}
233
234fn apply_line_styles(
239 lines: &mut [Vec<Segment>],
240 start_line: usize,
241 line_styles: &HashMap<usize, Style>,
242) {
243 if line_styles.is_empty() {
244 return;
245 }
246 for (i, line) in lines.iter_mut().enumerate() {
247 let line_num = start_line + i;
248 if let Some(style) = line_styles.get(&line_num) {
249 if let Some(bg) = style.bgcolor {
250 for seg in line.iter_mut() {
251 if let Some(ref mut s) = seg.style {
252 s.bgcolor = Some(bg);
253 } else {
254 seg.style = Some(Style::new().bgcolor(bg));
255 }
256 }
257 }
258 }
259 }
260}
261
262fn syntect_to_rich_style(ss: &SyntectStyle) -> Style {
264 let mut style = Style::new();
265 let fg = ss.foreground;
266 style = style.color(crate::color::Color::from_rgb(fg.r, fg.g, fg.b));
267
268 if ss
269 .font_style
270 .contains(syntect::highlighting::FontStyle::BOLD)
271 {
272 style = style.bold(true);
273 }
274 if ss
275 .font_style
276 .contains(syntect::highlighting::FontStyle::ITALIC)
277 {
278 style = style.italic(true);
279 }
280 if ss
281 .font_style
282 .contains(syntect::highlighting::FontStyle::UNDERLINE)
283 {
284 style = style.underline(true);
285 }
286 style
287}
288
289#[derive(Debug, Clone)]
296pub struct ANSISyntaxTheme {
297 pub background: Option<Color>,
299 pub foreground: Option<Color>,
301 pub styles: HashMap<String, Style>,
303}
304
305impl ANSISyntaxTheme {
306 pub fn new() -> Self {
308 Self {
309 background: None,
310 foreground: None,
311 styles: HashMap::new(),
312 }
313 }
314
315 pub fn set(&mut self, token: &str, style: Style) {
320 self.styles.insert(token.to_string(), style);
321 }
322
323 pub fn get(&self, token: &str) -> Option<&Style> {
325 self.styles.get(token)
326 }
327
328 pub fn monokai() -> Self {
333 let mut theme = Self::new();
334 theme.background = Some(Color::from_rgb(39, 40, 34));
335 theme.foreground = Some(Color::from_rgb(248, 248, 242));
336 theme.set("comment", Style::new().color(Color::from_rgb(117, 113, 94)));
337 theme.set("keyword", Style::new().color(Color::from_rgb(249, 38, 114)));
338 theme.set("string", Style::new().color(Color::from_rgb(230, 219, 116)));
339 theme.set("number", Style::new().color(Color::from_rgb(174, 129, 255)));
340 theme.set("type", Style::new().color(Color::from_rgb(102, 217, 239)));
341 theme.set(
342 "function",
343 Style::new().color(Color::from_rgb(166, 226, 46)),
344 );
345 theme
346 }
347
348 pub fn default_light() -> Self {
353 let mut theme = Self::new();
354 theme.background = Some(Color::from_rgb(255, 255, 255));
355 theme.foreground = Some(Color::from_rgb(0, 0, 0));
356 theme.set("comment", Style::new().color(Color::from_rgb(0, 128, 0)));
357 theme.set("keyword", Style::new().color(Color::from_rgb(0, 0, 255)));
358 theme.set("string", Style::new().color(Color::from_rgb(163, 21, 21)));
359 theme.set("number", Style::new().color(Color::from_rgb(0, 0, 128)));
360 theme.set("type", Style::new().color(Color::from_rgb(128, 128, 0)));
361 theme.set("function", Style::new().color(Color::from_rgb(128, 0, 128)));
362 theme
363 }
364}
365
366impl Default for ANSISyntaxTheme {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372pub trait SyntaxTheme {
377 fn get_style(&self, token: &str) -> Option<Style>;
379 fn background_color(&self) -> Option<Color>;
381}
382
383impl SyntaxTheme for ANSISyntaxTheme {
384 fn get_style(&self, token: &str) -> Option<Style> {
385 self.styles.get(token).cloned()
386 }
387
388 fn background_color(&self) -> Option<Color> {
389 self.background
390 }
391}
392
393pub fn get_lexer_by_name(name: &str) -> Option<String> {
411 match name.to_lowercase().as_str() {
412 "py" => Some("python".to_string()),
413 "rs" => Some("rust".to_string()),
414 "js" => Some("javascript".to_string()),
415 "ts" => Some("typescript".to_string()),
416 "cpp" => Some("c++".to_string()),
417 "rb" => Some("ruby".to_string()),
418 "md" => Some("markdown".to_string()),
419 "sh" | "bash" => Some("bash".to_string()),
420 "yml" | "yaml" => Some("yaml".to_string()),
421 _ => Some(name.to_string()),
422 }
423}
424
425pub fn get_style_by_name(name: &str) -> Option<ANSISyntaxTheme> {
431 match name.to_lowercase().as_str() {
432 "monokai" => Some(ANSISyntaxTheme::monokai()),
433 "light" => Some(ANSISyntaxTheme::default_light()),
434 "nord" => {
435 let mut theme = ANSISyntaxTheme::new();
436 theme.background = Some(Color::from_rgb(46, 52, 64));
437 theme.foreground = Some(Color::from_rgb(216, 222, 233));
438 theme.set("comment", Style::new().color(Color::from_rgb(76, 86, 106)));
439 theme.set(
440 "keyword",
441 Style::new().color(Color::from_rgb(143, 188, 187)),
442 );
443 theme.set("string", Style::new().color(Color::from_rgb(163, 190, 140)));
444 theme.set("number", Style::new().color(Color::from_rgb(208, 135, 112)));
445 theme.set("type", Style::new().color(Color::from_rgb(136, 192, 208)));
446 theme.set(
447 "function",
448 Style::new().color(Color::from_rgb(129, 161, 193)),
449 );
450 Some(theme)
451 }
452 "dracula" => {
453 let mut theme = ANSISyntaxTheme::new();
454 theme.background = Some(Color::from_rgb(40, 42, 54));
455 theme.foreground = Some(Color::from_rgb(248, 248, 242));
456 theme.set("comment", Style::new().color(Color::from_rgb(98, 114, 164)));
457 theme.set(
458 "keyword",
459 Style::new().color(Color::from_rgb(255, 121, 198)),
460 );
461 theme.set("string", Style::new().color(Color::from_rgb(241, 250, 140)));
462 theme.set("number", Style::new().color(Color::from_rgb(189, 147, 249)));
463 theme.set("type", Style::new().color(Color::from_rgb(139, 233, 253)));
464 theme.set(
465 "function",
466 Style::new().color(Color::from_rgb(80, 250, 123)),
467 );
468 Some(theme)
469 }
470 "github" => {
471 let mut theme = ANSISyntaxTheme::new();
472 theme.background = Some(Color::from_rgb(255, 255, 255));
473 theme.foreground = Some(Color::from_rgb(36, 41, 46));
474 theme.set(
475 "comment",
476 Style::new().color(Color::from_rgb(106, 115, 125)),
477 );
478 theme.set("keyword", Style::new().color(Color::from_rgb(215, 58, 73)));
479 theme.set("string", Style::new().color(Color::from_rgb(3, 47, 98)));
480 theme.set("number", Style::new().color(Color::from_rgb(0, 92, 197)));
481 theme.set("type", Style::new().color(Color::from_rgb(227, 98, 9)));
482 theme.set(
483 "function",
484 Style::new().color(Color::from_rgb(111, 66, 193)),
485 );
486 Some(theme)
487 }
488 _ => None,
489 }
490}
491
492pub fn guess_lexer_for_filename(filename: &str) -> Option<String> {
525 let name = filename.trim();
526 if name.eq_ignore_ascii_case("Dockerfile") {
528 return Some("dockerfile".to_string());
529 }
530 if name.eq_ignore_ascii_case("Makefile") {
531 return Some("makefile".to_string());
532 }
533 let path = Path::new(name);
535 let ext = path.extension()?.to_str()?;
536 match ext.to_lowercase().as_str() {
537 "rs" => Some("rust".to_string()),
538 "py" => Some("python".to_string()),
539 "js" => Some("javascript".to_string()),
540 "ts" => Some("typescript".to_string()),
541 "java" => Some("java".to_string()),
542 "go" => Some("go".to_string()),
543 "rb" => Some("ruby".to_string()),
544 "php" => Some("php".to_string()),
545 "c" | "h" => Some("c".to_string()),
546 "cpp" | "hpp" | "cxx" | "hxx" => Some("c++".to_string()),
547 "cs" => Some("csharp".to_string()),
548 "html" | "htm" => Some("html".to_string()),
549 "css" => Some("css".to_string()),
550 "scss" | "sass" => Some("scss".to_string()),
551 "json" => Some("json".to_string()),
552 "xml" | "svg" | "xhtml" => Some("xml".to_string()),
553 "yaml" | "yml" => Some("yaml".to_string()),
554 "md" | "markdown" => Some("markdown".to_string()),
555 "sql" => Some("sql".to_string()),
556 "sh" | "bash" | "zsh" | "ksh" => Some("bash".to_string()),
557 "toml" => Some("toml".to_string()),
558 "ini" | "cfg" | "conf" => Some("ini".to_string()),
559 _ => None,
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn test_syntax_no_highlight() {
569 let s = Syntax::new("fn main() {}", "rust");
570 let opts = ConsoleOptions::default();
571 let result = s.render(&opts);
572 let ansi = result.to_ansi();
573 assert!(ansi.contains("fn main"));
574 }
575
576 #[test]
577 fn test_syntax_line_numbers() {
578 let s = Syntax::new("line1\nline2\nline3", "").line_numbers();
579 let opts = ConsoleOptions::default();
580 let result = s.render(&opts);
581 let ansi = result.to_ansi();
582 assert!(ansi.contains("1"));
583 }
584
585 #[test]
586 fn test_from_path() {
587 use std::io::Write;
588 let path = std::env::temp_dir().join("rusty_rich_test_syntax_from_path.rs");
589 let mut f = std::fs::File::create(&path).unwrap();
590 write!(f, "fn main() {{}}").unwrap();
591 let syntax = Syntax::from_path(&path, false, None).unwrap();
592 assert_eq!(syntax.language, "rust");
593 assert!(!syntax.line_numbers);
594 std::fs::remove_file(&path).unwrap();
595 }
596
597 #[test]
598 fn test_from_path_with_theme() {
599 use std::io::Write;
600 let path = std::env::temp_dir().join("app.py");
601 let mut f = std::fs::File::create(&path).unwrap();
602 write!(f, "print('hello')").unwrap();
603 let syntax = Syntax::from_path(&path, true, Some("monokai")).unwrap();
604 assert_eq!(syntax.language, "python");
605 assert!(syntax.line_numbers);
606 assert_eq!(syntax.theme, "monokai");
607 std::fs::remove_file(&path).unwrap();
608 }
609
610 #[test]
611 fn test_default_lexer() {
612 assert_eq!(Syntax::default_lexer(), "text");
613 }
614
615 #[test]
616 fn test_get_theme() {
617 let s = Syntax::new("test", "rust").theme("monokai");
618 assert_eq!(s.get_theme(), "monokai");
619 }
620
621 #[test]
622 fn test_guess_lexer_for_filename() {
623 assert_eq!(
624 guess_lexer_for_filename("main.rs"),
625 Some("rust".to_string())
626 );
627 assert_eq!(
628 guess_lexer_for_filename("app.py"),
629 Some("python".to_string())
630 );
631 assert_eq!(
632 guess_lexer_for_filename("Dockerfile"),
633 Some("dockerfile".to_string())
634 );
635 assert_eq!(
636 guess_lexer_for_filename("Makefile"),
637 Some("makefile".to_string())
638 );
639 assert_eq!(guess_lexer_for_filename("unknown.xyz"), None);
640 }
641
642 #[test]
643 fn test_guess_lexer_for_filename_edge_cases() {
644 assert_eq!(
645 guess_lexer_for_filename("/path/to/script.sh"),
646 Some("bash".to_string())
647 );
648 assert_eq!(
649 guess_lexer_for_filename("/path/to/config.yaml"),
650 Some("yaml".to_string())
651 );
652 assert_eq!(
653 guess_lexer_for_filename("/path/to/file.cpp"),
654 Some("c++".to_string())
655 );
656 }
657
658 #[test]
659 fn test_get_lexer_by_name() {
660 assert_eq!(get_lexer_by_name("py"), Some("python".to_string()));
661 assert_eq!(get_lexer_by_name("rs"), Some("rust".to_string()));
662 assert_eq!(get_lexer_by_name("js"), Some("javascript".to_string()));
663 assert_eq!(get_lexer_by_name("cpp"), Some("c++".to_string()));
664 }
665
666 #[test]
667 fn test_get_lexer_by_name_passthrough() {
668 assert_eq!(get_lexer_by_name("python"), Some("python".to_string()));
670 assert_eq!(get_lexer_by_name("rust"), Some("rust".to_string()));
671 }
672
673 #[test]
674 fn test_ansi_theme_monokai() {
675 let theme = ANSISyntaxTheme::monokai();
676 assert!(theme.background.is_some());
677 assert!(theme.foreground.is_some());
678 assert!(theme.get("keyword").is_some());
679 assert!(theme.get("string").is_some());
680 assert!(theme.get("comment").is_some());
681 }
682
683 #[test]
684 fn test_ansi_theme_default_light() {
685 let theme = ANSISyntaxTheme::default_light();
686 assert!(theme.background.is_some());
687 assert_eq!(theme.background.unwrap(), Color::from_rgb(255, 255, 255));
688 assert!(theme.get("keyword").is_some());
689 }
690
691 #[test]
692 fn test_stylize_range() {
693 let s = Syntax::new("line1\nline2\nline3", "text").stylize_range(
694 1,
695 1,
696 Style::new().bgcolor(Color::from_rgb(255, 0, 0)),
697 );
698 assert_eq!(s.line_styles.len(), 1);
699 assert!(s.line_styles.contains_key(&1));
700 }
701
702 #[test]
703 fn test_stylize_range_multi_line() {
704 let s = Syntax::new("line1\nline2\nline3", "text").stylize_range(
705 1,
706 2,
707 Style::new().bgcolor(Color::from_rgb(255, 255, 0)),
708 );
709 assert_eq!(s.line_styles.len(), 2);
710 assert!(s.line_styles.contains_key(&1));
711 assert!(s.line_styles.contains_key(&2));
712 assert!(!s.line_styles.contains_key(&3));
713 }
714
715 #[test]
716 fn test_stylize_range_renders() {
717 let s = Syntax::new("hello\nworld", "text").stylize_range(
718 1,
719 1,
720 Style::new().bgcolor(Color::from_rgb(255, 0, 0)),
721 );
722 let opts = ConsoleOptions::default();
723 let result = s.render(&opts);
724 let ansi = result.to_ansi();
725 assert!(ansi.contains("hello"));
726 assert!(ansi.contains("world"));
727 }
728
729 #[test]
730 fn test_guess_lexer_on_syntax() {
731 let path = Path::new("/tmp/test.py");
732 let result = Syntax::guess_lexer(path);
733 assert_eq!(result, Some("python".to_string()));
734 }
735
736 #[test]
737 fn test_get_style_by_name() {
738 let theme = get_style_by_name("monokai");
739 assert!(theme.is_some());
740
741 let theme = get_style_by_name("nord");
742 assert!(theme.is_some());
743
744 let theme = get_style_by_name("dracula");
745 assert!(theme.is_some());
746
747 let theme = get_style_by_name("github");
748 assert!(theme.is_some());
749
750 let theme = get_style_by_name("unknown");
751 assert!(theme.is_none());
752 }
753
754 #[test]
755 fn test_syntax_theme_trait() {
756 let theme = ANSISyntaxTheme::monokai();
757 let trait_obj: &dyn SyntaxTheme = &theme;
758 assert!(trait_obj.get_style("keyword").is_some());
759 assert!(trait_obj.background_color().is_some());
760 }
761
762 #[test]
763 fn test_guess_lexer_for_filename_case_insensitive() {
764 assert_eq!(
765 guess_lexer_for_filename("main.RS"),
766 Some("rust".to_string())
767 );
768 assert_eq!(
769 guess_lexer_for_filename("App.PY"),
770 Some("python".to_string())
771 );
772 assert_eq!(
773 guess_lexer_for_filename("DOCKERFILE"),
774 Some("dockerfile".to_string())
775 );
776 }
777}