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