1use std::collections::HashMap;
6use std::path::Path;
7
8use syntect::easy::HighlightLines;
9use syntect::highlighting::{ThemeSet, Style as SyntectStyle};
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 { self.theme = theme.into(); self }
59
60 pub fn line_numbers(mut self) -> Self { self.line_numbers = true; self }
62
63 pub fn start_line(mut self, n: usize) -> Self { self.start_line = n; self }
65
66 pub fn background(mut self, color: crate::color::Color) -> Self { self.background_color = Some(color); self }
68
69 pub fn from_path(
86 path: impl AsRef<Path>,
87 line_numbers: bool,
88 theme: Option<&str>,
89 ) -> std::io::Result<Self> {
90 let path = path.as_ref();
91 let code = std::fs::read_to_string(path)?;
92 let language = Self::guess_lexer(path).unwrap_or_default();
93 let mut syntax = Syntax::new(code, language);
94 if line_numbers {
95 syntax = syntax.line_numbers();
96 }
97 if let Some(t) = theme {
98 syntax = syntax.theme(t);
99 }
100 Ok(syntax)
101 }
102
103 pub fn guess_lexer(path: impl AsRef<Path>) -> Option<String> {
108 guess_lexer_for_filename(path.as_ref().to_str()?)
109 }
110
111 pub fn stylize_range(mut self, start_line: usize, end_line: usize, style: Style) -> Self {
126 for line in start_line..=end_line {
127 self.line_styles.insert(line, style.clone());
128 }
129 self
130 }
131
132 pub fn get_theme(&self) -> &str {
134 &self.theme
135 }
136
137 pub fn default_lexer() -> &'static str {
139 "text"
140 }
141}
142
143impl Renderable for Syntax {
144 fn render(&self, _options: &ConsoleOptions) -> RenderResult {
145 if !self.highlight || self.language.is_empty() {
146 let mut lines: Vec<Vec<Segment>> = self
148 .code
149 .lines()
150 .map(|line| vec![Segment::new(line), Segment::line()])
151 .collect();
152
153 apply_line_styles(&mut lines, self.start_line, &self.line_styles);
155
156 return RenderResult { lines, items: Vec::new() };
157 }
158
159 let ss = SyntaxSet::load_defaults_newlines();
160 let ts = ThemeSet::load_defaults();
161
162 let syntax = ss
163 .find_syntax_by_name(&self.language)
164 .or_else(|| ss.find_syntax_by_extension(&self.language))
165 .unwrap_or_else(|| ss.find_syntax_plain_text());
166
167 let theme = ts.themes.get(&self.theme).unwrap_or_else(|| {
168 &ts.themes["base16-ocean.dark"]
169 });
170
171 let mut highlighter = HighlightLines::new(syntax, theme);
172
173 let mut lines: Vec<Vec<Segment>> = Vec::new();
174 let line_num_width = if self.line_numbers {
175 (self.code.lines().count().saturating_add(self.start_line))
176 .to_string()
177 .len()
178 } else {
179 0
180 };
181
182 for (i, line) in LinesWithEndings::from(&self.code).enumerate() {
183 let mut line_segments: Vec<Segment> = Vec::new();
184
185 if self.line_numbers {
187 let num = i + self.start_line;
188 let num_str = format!("{:>width$} │ ", num, width = line_num_width);
189 line_segments.push(Segment::new(num_str));
190 }
191
192 match highlighter.highlight_line(line, &ss) {
194 Ok(highlighted) => {
195 for (syntect_style, text) in &highlighted {
196 let style = syntect_to_rich_style(syntect_style);
197 line_segments.push(Segment::styled(
198 text.to_string(),
199 style,
200 ));
201 }
202 }
203 Err(_) => {
204 line_segments.push(Segment::new(line));
205 }
206 }
207
208 lines.push(line_segments);
209 }
210
211 apply_line_styles(&mut lines, self.start_line, &self.line_styles);
213
214 RenderResult { lines, items: Vec::new() }
215 }
216}
217
218fn apply_line_styles(
223 lines: &mut [Vec<Segment>],
224 start_line: usize,
225 line_styles: &HashMap<usize, Style>,
226) {
227 if line_styles.is_empty() {
228 return;
229 }
230 for (i, line) in lines.iter_mut().enumerate() {
231 let line_num = start_line + i;
232 if let Some(style) = line_styles.get(&line_num) {
233 if let Some(bg) = style.bgcolor {
234 for seg in line.iter_mut() {
235 if let Some(ref mut s) = seg.style {
236 s.bgcolor = Some(bg);
237 } else {
238 seg.style = Some(Style::new().bgcolor(bg));
239 }
240 }
241 }
242 }
243 }
244}
245
246fn syntect_to_rich_style(ss: &SyntectStyle) -> Style {
248 let mut style = Style::new();
249 let fg = ss.foreground;
250 style = style.color(crate::color::Color::from_rgb(fg.r, fg.g, fg.b));
251
252 if ss.font_style.contains(syntect::highlighting::FontStyle::BOLD) {
253 style = style.bold(true);
254 }
255 if ss.font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
256 style = style.italic(true);
257 }
258 if ss.font_style.contains(syntect::highlighting::FontStyle::UNDERLINE) {
259 style = style.underline(true);
260 }
261 style
262}
263
264#[derive(Debug, Clone)]
271pub struct ANSISyntaxTheme {
272 pub background: Option<Color>,
274 pub foreground: Option<Color>,
276 pub styles: HashMap<String, Style>,
278}
279
280impl ANSISyntaxTheme {
281 pub fn new() -> Self {
283 Self {
284 background: None,
285 foreground: None,
286 styles: HashMap::new(),
287 }
288 }
289
290 pub fn set(&mut self, token: &str, style: Style) {
295 self.styles.insert(token.to_string(), style);
296 }
297
298 pub fn get(&self, token: &str) -> Option<&Style> {
300 self.styles.get(token)
301 }
302
303 pub fn monokai() -> Self {
308 let mut theme = Self::new();
309 theme.background = Some(Color::from_rgb(39, 40, 34));
310 theme.foreground = Some(Color::from_rgb(248, 248, 242));
311 theme.set("comment", Style::new().color(Color::from_rgb(117, 113, 94)));
312 theme.set("keyword", Style::new().color(Color::from_rgb(249, 38, 114)));
313 theme.set("string", Style::new().color(Color::from_rgb(230, 219, 116)));
314 theme.set("number", Style::new().color(Color::from_rgb(174, 129, 255)));
315 theme.set("type", Style::new().color(Color::from_rgb(102, 217, 239)));
316 theme.set("function", Style::new().color(Color::from_rgb(166, 226, 46)));
317 theme
318 }
319
320 pub fn default_light() -> Self {
325 let mut theme = Self::new();
326 theme.background = Some(Color::from_rgb(255, 255, 255));
327 theme.foreground = Some(Color::from_rgb(0, 0, 0));
328 theme.set("comment", Style::new().color(Color::from_rgb(0, 128, 0)));
329 theme.set("keyword", Style::new().color(Color::from_rgb(0, 0, 255)));
330 theme.set("string", Style::new().color(Color::from_rgb(163, 21, 21)));
331 theme.set("number", Style::new().color(Color::from_rgb(0, 0, 128)));
332 theme.set("type", Style::new().color(Color::from_rgb(128, 128, 0)));
333 theme.set("function", Style::new().color(Color::from_rgb(128, 0, 128)));
334 theme
335 }
336}
337
338impl Default for ANSISyntaxTheme {
339 fn default() -> Self {
340 Self::new()
341 }
342}
343
344pub trait SyntaxTheme {
349 fn get_style(&self, token: &str) -> Option<Style>;
351 fn background_color(&self) -> Option<Color>;
353}
354
355impl SyntaxTheme for ANSISyntaxTheme {
356 fn get_style(&self, token: &str) -> Option<Style> {
357 self.styles.get(token).cloned()
358 }
359
360 fn background_color(&self) -> Option<Color> {
361 self.background
362 }
363}
364
365pub fn get_lexer_by_name(name: &str) -> Option<String> {
383 match name.to_lowercase().as_str() {
384 "py" => Some("python".to_string()),
385 "rs" => Some("rust".to_string()),
386 "js" => Some("javascript".to_string()),
387 "ts" => Some("typescript".to_string()),
388 "cpp" => Some("c++".to_string()),
389 "rb" => Some("ruby".to_string()),
390 "md" => Some("markdown".to_string()),
391 "sh" | "bash" => Some("bash".to_string()),
392 "yml" | "yaml" => Some("yaml".to_string()),
393 _ => Some(name.to_string()),
394 }
395}
396
397pub fn get_style_by_name(name: &str) -> Option<ANSISyntaxTheme> {
403 match name.to_lowercase().as_str() {
404 "monokai" => Some(ANSISyntaxTheme::monokai()),
405 "light" => Some(ANSISyntaxTheme::default_light()),
406 "nord" => {
407 let mut theme = ANSISyntaxTheme::new();
408 theme.background = Some(Color::from_rgb(46, 52, 64));
409 theme.foreground = Some(Color::from_rgb(216, 222, 233));
410 theme.set("comment", Style::new().color(Color::from_rgb(76, 86, 106)));
411 theme.set("keyword", Style::new().color(Color::from_rgb(143, 188, 187)));
412 theme.set("string", Style::new().color(Color::from_rgb(163, 190, 140)));
413 theme.set("number", Style::new().color(Color::from_rgb(208, 135, 112)));
414 theme.set("type", Style::new().color(Color::from_rgb(136, 192, 208)));
415 theme.set("function", Style::new().color(Color::from_rgb(129, 161, 193)));
416 Some(theme)
417 }
418 "dracula" => {
419 let mut theme = ANSISyntaxTheme::new();
420 theme.background = Some(Color::from_rgb(40, 42, 54));
421 theme.foreground = Some(Color::from_rgb(248, 248, 242));
422 theme.set("comment", Style::new().color(Color::from_rgb(98, 114, 164)));
423 theme.set("keyword", Style::new().color(Color::from_rgb(255, 121, 198)));
424 theme.set("string", Style::new().color(Color::from_rgb(241, 250, 140)));
425 theme.set("number", Style::new().color(Color::from_rgb(189, 147, 249)));
426 theme.set("type", Style::new().color(Color::from_rgb(139, 233, 253)));
427 theme.set("function", Style::new().color(Color::from_rgb(80, 250, 123)));
428 Some(theme)
429 }
430 "github" => {
431 let mut theme = ANSISyntaxTheme::new();
432 theme.background = Some(Color::from_rgb(255, 255, 255));
433 theme.foreground = Some(Color::from_rgb(36, 41, 46));
434 theme.set("comment", Style::new().color(Color::from_rgb(106, 115, 125)));
435 theme.set("keyword", Style::new().color(Color::from_rgb(215, 58, 73)));
436 theme.set("string", Style::new().color(Color::from_rgb(3, 47, 98)));
437 theme.set("number", Style::new().color(Color::from_rgb(0, 92, 197)));
438 theme.set("type", Style::new().color(Color::from_rgb(227, 98, 9)));
439 theme.set("function", Style::new().color(Color::from_rgb(111, 66, 193)));
440 Some(theme)
441 }
442 _ => None,
443 }
444}
445
446pub fn guess_lexer_for_filename(filename: &str) -> Option<String> {
479 let name = filename.trim();
480 if name.eq_ignore_ascii_case("Dockerfile") {
482 return Some("dockerfile".to_string());
483 }
484 if name.eq_ignore_ascii_case("Makefile") {
485 return Some("makefile".to_string());
486 }
487 let path = Path::new(name);
489 let ext = path.extension()?.to_str()?;
490 match ext.to_lowercase().as_str() {
491 "rs" => Some("rust".to_string()),
492 "py" => Some("python".to_string()),
493 "js" => Some("javascript".to_string()),
494 "ts" => Some("typescript".to_string()),
495 "java" => Some("java".to_string()),
496 "go" => Some("go".to_string()),
497 "rb" => Some("ruby".to_string()),
498 "php" => Some("php".to_string()),
499 "c" | "h" => Some("c".to_string()),
500 "cpp" | "hpp" | "cxx" | "hxx" => Some("c++".to_string()),
501 "cs" => Some("csharp".to_string()),
502 "html" | "htm" => Some("html".to_string()),
503 "css" => Some("css".to_string()),
504 "scss" | "sass" => Some("scss".to_string()),
505 "json" => Some("json".to_string()),
506 "xml" | "svg" | "xhtml" => Some("xml".to_string()),
507 "yaml" | "yml" => Some("yaml".to_string()),
508 "md" | "markdown" => Some("markdown".to_string()),
509 "sql" => Some("sql".to_string()),
510 "sh" | "bash" | "zsh" | "ksh" => Some("bash".to_string()),
511 "toml" => Some("toml".to_string()),
512 "ini" | "cfg" | "conf" => Some("ini".to_string()),
513 _ => None,
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_syntax_no_highlight() {
523 let s = Syntax::new("fn main() {}", "rust");
524 let opts = ConsoleOptions::default();
525 let result = s.render(&opts);
526 let ansi = result.to_ansi();
527 assert!(ansi.contains("fn main"));
528 }
529
530 #[test]
531 fn test_syntax_line_numbers() {
532 let s = Syntax::new("line1\nline2\nline3", "").line_numbers();
533 let opts = ConsoleOptions::default();
534 let result = s.render(&opts);
535 let ansi = result.to_ansi();
536 assert!(ansi.contains("1"));
537 }
538
539 #[test]
540 fn test_from_path() {
541 use std::io::Write;
542 let path = std::env::temp_dir().join("rusty_rich_test_syntax_from_path.rs");
543 let mut f = std::fs::File::create(&path).unwrap();
544 write!(f, "fn main() {{}}").unwrap();
545 let syntax = Syntax::from_path(&path, false, None).unwrap();
546 assert_eq!(syntax.language, "rust");
547 assert!(!syntax.line_numbers);
548 std::fs::remove_file(&path).unwrap();
549 }
550
551 #[test]
552 fn test_from_path_with_theme() {
553 use std::io::Write;
554 let path = std::env::temp_dir().join("app.py");
555 let mut f = std::fs::File::create(&path).unwrap();
556 write!(f, "print('hello')").unwrap();
557 let syntax = Syntax::from_path(&path, true, Some("monokai")).unwrap();
558 assert_eq!(syntax.language, "python");
559 assert!(syntax.line_numbers);
560 assert_eq!(syntax.theme, "monokai");
561 std::fs::remove_file(&path).unwrap();
562 }
563
564 #[test]
565 fn test_default_lexer() {
566 assert_eq!(Syntax::default_lexer(), "text");
567 }
568
569 #[test]
570 fn test_get_theme() {
571 let s = Syntax::new("test", "rust").theme("monokai");
572 assert_eq!(s.get_theme(), "monokai");
573 }
574
575 #[test]
576 fn test_guess_lexer_for_filename() {
577 assert_eq!(
578 guess_lexer_for_filename("main.rs"),
579 Some("rust".to_string())
580 );
581 assert_eq!(
582 guess_lexer_for_filename("app.py"),
583 Some("python".to_string())
584 );
585 assert_eq!(
586 guess_lexer_for_filename("Dockerfile"),
587 Some("dockerfile".to_string())
588 );
589 assert_eq!(
590 guess_lexer_for_filename("Makefile"),
591 Some("makefile".to_string())
592 );
593 assert_eq!(guess_lexer_for_filename("unknown.xyz"), None);
594 }
595
596 #[test]
597 fn test_guess_lexer_for_filename_edge_cases() {
598 assert_eq!(
599 guess_lexer_for_filename("/path/to/script.sh"),
600 Some("bash".to_string())
601 );
602 assert_eq!(
603 guess_lexer_for_filename("/path/to/config.yaml"),
604 Some("yaml".to_string())
605 );
606 assert_eq!(
607 guess_lexer_for_filename("/path/to/file.cpp"),
608 Some("c++".to_string())
609 );
610 }
611
612 #[test]
613 fn test_get_lexer_by_name() {
614 assert_eq!(
615 get_lexer_by_name("py"),
616 Some("python".to_string())
617 );
618 assert_eq!(
619 get_lexer_by_name("rs"),
620 Some("rust".to_string())
621 );
622 assert_eq!(
623 get_lexer_by_name("js"),
624 Some("javascript".to_string())
625 );
626 assert_eq!(
627 get_lexer_by_name("cpp"),
628 Some("c++".to_string())
629 );
630 }
631
632 #[test]
633 fn test_get_lexer_by_name_passthrough() {
634 assert_eq!(
636 get_lexer_by_name("python"),
637 Some("python".to_string())
638 );
639 assert_eq!(
640 get_lexer_by_name("rust"),
641 Some("rust".to_string())
642 );
643 }
644
645 #[test]
646 fn test_ansi_theme_monokai() {
647 let theme = ANSISyntaxTheme::monokai();
648 assert!(theme.background.is_some());
649 assert!(theme.foreground.is_some());
650 assert!(theme.get("keyword").is_some());
651 assert!(theme.get("string").is_some());
652 assert!(theme.get("comment").is_some());
653 }
654
655 #[test]
656 fn test_ansi_theme_default_light() {
657 let theme = ANSISyntaxTheme::default_light();
658 assert!(theme.background.is_some());
659 assert_eq!(theme.background.unwrap(), Color::from_rgb(255, 255, 255));
660 assert!(theme.get("keyword").is_some());
661 }
662
663 #[test]
664 fn test_stylize_range() {
665 let s = Syntax::new("line1\nline2\nline3", "text")
666 .stylize_range(1, 1, Style::new().bgcolor(Color::from_rgb(255, 0, 0)));
667 assert_eq!(s.line_styles.len(), 1);
668 assert!(s.line_styles.contains_key(&1));
669 }
670
671 #[test]
672 fn test_stylize_range_multi_line() {
673 let s = Syntax::new("line1\nline2\nline3", "text")
674 .stylize_range(1, 2, Style::new().bgcolor(Color::from_rgb(255, 255, 0)));
675 assert_eq!(s.line_styles.len(), 2);
676 assert!(s.line_styles.contains_key(&1));
677 assert!(s.line_styles.contains_key(&2));
678 assert!(!s.line_styles.contains_key(&3));
679 }
680
681 #[test]
682 fn test_stylize_range_renders() {
683 let s = Syntax::new("hello\nworld", "text")
684 .stylize_range(1, 1, Style::new().bgcolor(Color::from_rgb(255, 0, 0)));
685 let opts = ConsoleOptions::default();
686 let result = s.render(&opts);
687 let ansi = result.to_ansi();
688 assert!(ansi.contains("hello"));
689 assert!(ansi.contains("world"));
690 }
691
692 #[test]
693 fn test_guess_lexer_on_syntax() {
694 let path = Path::new("/tmp/test.py");
695 let result = Syntax::guess_lexer(path);
696 assert_eq!(result, Some("python".to_string()));
697 }
698
699 #[test]
700 fn test_get_style_by_name() {
701 let theme = get_style_by_name("monokai");
702 assert!(theme.is_some());
703
704 let theme = get_style_by_name("nord");
705 assert!(theme.is_some());
706
707 let theme = get_style_by_name("dracula");
708 assert!(theme.is_some());
709
710 let theme = get_style_by_name("github");
711 assert!(theme.is_some());
712
713 let theme = get_style_by_name("unknown");
714 assert!(theme.is_none());
715 }
716
717 #[test]
718 fn test_syntax_theme_trait() {
719 let theme = ANSISyntaxTheme::monokai();
720 let trait_obj: &dyn SyntaxTheme = &theme;
721 assert!(trait_obj.get_style("keyword").is_some());
722 assert!(trait_obj.background_color().is_some());
723 }
724
725 #[test]
726 fn test_guess_lexer_for_filename_case_insensitive() {
727 assert_eq!(
728 guess_lexer_for_filename("main.RS"),
729 Some("rust".to_string())
730 );
731 assert_eq!(
732 guess_lexer_for_filename("App.PY"),
733 Some("python".to_string())
734 );
735 assert_eq!(
736 guess_lexer_for_filename("DOCKERFILE"),
737 Some("dockerfile".to_string())
738 );
739 }
740}