Skip to main content

rusty_rich/
syntax.rs

1//! Syntax highlighting — equivalent to Rich's `syntax.py`.
2//!
3//! Uses `syntect` for syntax highlighting (Rust equivalent of Pygments).
4
5use 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/// A syntax-highlighted source code renderable.
19#[derive(Debug, Clone)]
20pub struct Syntax {
21    /// The source code.
22    pub code: String,
23    /// The language name (e.g. "rust", "python", "javascript").
24    pub language: String,
25    /// Optional theme name.
26    pub theme: String,
27    /// Starting line number (for line numbers).
28    pub start_line: usize,
29    /// If true, show line numbers.
30    pub line_numbers: bool,
31    /// If true, highlight the code.
32    pub highlight: bool,
33    /// Optional background color.
34    pub background_color: Option<crate::color::Color>,
35    /// Tab size.
36    pub tab_size: usize,
37    /// Per-line styles for line range highlighting (used by `stylize_range`).
38    pub line_styles: HashMap<usize, Style>,
39}
40
41impl Syntax {
42    /// Create a new Syntax renderable for the given code and language.
43    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    /// Builder: set the syntect theme name (e.g. `"base16-ocean.dark"`, `"monokai"`).
58    pub fn theme(mut self, theme: impl Into<String>) -> Self {
59        self.theme = theme.into();
60        self
61    }
62
63    /// Builder: enable line numbers in the rendered output.
64    pub fn line_numbers(mut self) -> Self {
65        self.line_numbers = true;
66        self
67    }
68
69    /// Builder: set the starting line number for display (default 1).
70    pub fn start_line(mut self, n: usize) -> Self {
71        self.start_line = n;
72        self
73    }
74
75    /// Builder: set a background color for the code block.
76    pub fn background(mut self, color: crate::color::Color) -> Self {
77        self.background_color = Some(color);
78        self
79    }
80
81    /// Create a Syntax from a file path, auto-detecting the language from the extension.
82    ///
83    /// Reads the file contents and infers the programming language from the
84    /// file extension. Optionally enables line numbers and sets a theme.
85    ///
86    /// # Errors
87    ///
88    /// Returns an IO error if the file cannot be read.
89    ///
90    /// # Example
91    ///
92    /// ```rust,no_run
93    /// use rusty_rich::Syntax;
94    ///
95    /// let syntax = Syntax::from_path("main.rs", true, Some("monokai")).unwrap();
96    /// ```
97    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    /// Guess the syntax lexer name from a file path's extension.
116    ///
117    /// Delegates to [`guess_lexer_for_filename`] by extracting the file stem
118    /// and extension from the provided path.
119    pub fn guess_lexer(path: impl AsRef<Path>) -> Option<String> {
120        guess_lexer_for_filename(path.as_ref().to_str()?)
121    }
122
123    /// Apply a background style to a range of lines (for highlighting).
124    ///
125    /// Returns a new [`Syntax`] with the style applied to the specified lines
126    /// (1-based, inclusive). This is useful for highlighting a specific range
127    /// of lines, e.g. the current line in a debugger.
128    ///
129    /// # Example
130    ///
131    /// ```rust
132    /// use rusty_rich::{Syntax, Style, Color};
133    ///
134    /// let syntax = Syntax::new("line1\nline2", "text")
135    ///     .stylize_range(1, 1, Style::new().bgcolor(Color::from_rgb(255, 255, 200)));
136    /// ```
137    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    /// Get the current theme name.
145    pub fn get_theme(&self) -> &str {
146        &self.theme
147    }
148
149    /// Return the default lexer name (`"text"`).
150    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            // No highlighting — just render as plain text
159            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 per-line styles
166            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            // Line number
202            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            // Highlight the line
209            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 per-line styles
225        apply_line_styles(&mut lines, self.start_line, &self.line_styles);
226
227        RenderResult {
228            lines,
229            items: Vec::new(),
230        }
231    }
232}
233
234/// Apply per-line styles to rendered segment lines.
235///
236/// For each line that has a matching style in `line_styles`, the background
237/// color from that style is applied to every segment on that line.
238fn 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
262/// Convert a syntect `Style` to our `Style`.
263fn 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/// A syntax theme that maps to ANSI colors (lightweight, no Pygments dependency).
290///
291/// Provides a simple token-to-style mapping for common syntax token types
292/// like "keyword", "string", "comment", "number", "type", and "function".
293/// Pre-built themes are available via [`ANSISyntaxTheme::monokai`] and
294/// [`ANSISyntaxTheme::default_light`].
295#[derive(Debug, Clone)]
296pub struct ANSISyntaxTheme {
297    /// Optional background color for the code block.
298    pub background: Option<Color>,
299    /// Optional default foreground color.
300    pub foreground: Option<Color>,
301    /// Token name to style mapping.
302    pub styles: HashMap<String, Style>,
303}
304
305impl ANSISyntaxTheme {
306    /// Create a new empty `ANSISyntaxTheme`.
307    pub fn new() -> Self {
308        Self {
309            background: None,
310            foreground: None,
311            styles: HashMap::new(),
312        }
313    }
314
315    /// Set the style for a token type.
316    ///
317    /// Common token names include: `"comment"`, `"keyword"`, `"string"`,
318    /// `"number"`, `"type"`, `"function"`.
319    pub fn set(&mut self, token: &str, style: Style) {
320        self.styles.insert(token.to_string(), style);
321    }
322
323    /// Get the style for a token type, if one has been set.
324    pub fn get(&self, token: &str) -> Option<&Style> {
325        self.styles.get(token)
326    }
327
328    /// Create a Monokai-inspired theme.
329    ///
330    /// Features a dark background with vibrant foreground colors
331    /// commonly associated with the Monokai color scheme.
332    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    /// Create a default light theme.
349    ///
350    /// Provides a white background with blue keywords, red strings,
351    /// and navy numbers — a familiar light-mode syntax scheme.
352    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
372/// Trait for syntax themes.
373///
374/// Implementors provide token-to-style mappings and an optional
375/// background color for syntax-highlighted code blocks.
376pub trait SyntaxTheme {
377    /// Get the style for a given token type (e.g. `"keyword"`, `"string"`).
378    fn get_style(&self, token: &str) -> Option<Style>;
379    /// Get the optional background color for the entire code block.
380    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
393/// Resolve a lexer name (case-insensitive) to a canonical name.
394///
395/// Supports common short aliases:
396///
397/// | Alias | Canonical |
398/// |-------|-----------|
399/// | `py` | `python` |
400/// | `rs` | `rust` |
401/// | `js` | `javascript` |
402/// | `ts` | `typescript` |
403/// | `cpp` | `cpp` |
404/// | `rb` | `ruby` |
405/// | `md` | `markdown` |
406/// | `sh` / `bash` | `bash` |
407///
408/// If no alias matches, returns the input as-is so that syntect can attempt
409/// to resolve it natively.
410pub 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
425/// Get a pre-built [`ANSISyntaxTheme`] by name.
426///
427/// Supported names: `"monokai"`, `"light"`, `"nord"`, `"dracula"`, `"github"`.
428///
429/// Returns `None` for unrecognized theme names.
430pub 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
492/// Guess the syntax lexer name from a filename or file path.
493///
494/// Maps common file extensions to their corresponding lexer names:
495///
496/// | Extension | Lexer |
497/// |-----------|-------|
498/// | `.rs` | `rust` |
499/// | `.py` | `python` |
500/// | `.js` | `javascript` |
501/// | `.ts` | `typescript` |
502/// | `.java` | `java` |
503/// | `.go` | `go` |
504/// | `.rb` | `ruby` |
505/// | `.php` | `php` |
506/// | `.c`, `.h` | `c` |
507/// | `.cpp`, `.hpp` | `c++` |
508/// | `.cs` | `csharp` |
509/// | `.html` | `html` |
510/// | `.css` | `css` |
511/// | `.scss` | `scss` |
512/// | `.json` | `json` |
513/// | `.xml` | `xml` |
514/// | `.yaml`, `.yml` | `yaml` |
515/// | `.md` | `markdown` |
516/// | `.sql` | `sql` |
517/// | `.sh`, `.bash` | `bash` |
518/// | `.toml` | `toml` |
519/// | `.ini`, `.cfg` | `ini` |
520/// | `Dockerfile` | `dockerfile` |
521/// | `Makefile` | `makefile` |
522///
523/// Returns `None` for unrecognized filenames.
524pub fn guess_lexer_for_filename(filename: &str) -> Option<String> {
525    let name = filename.trim();
526    // Check for well-known filenames without extensions
527    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    // Extract the extension
534    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        // Unknown short names should pass through as-is
669        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}