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::{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/// 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 { self.theme = theme.into(); self }
59
60    /// Builder: enable line numbers in the rendered output.
61    pub fn line_numbers(mut self) -> Self { self.line_numbers = true; self }
62
63    /// Builder: set the starting line number for display (default 1).
64    pub fn start_line(mut self, n: usize) -> Self { self.start_line = n; self }
65
66    /// Builder: set a background color for the code block.
67    pub fn background(mut self, color: crate::color::Color) -> Self { self.background_color = Some(color); self }
68
69    /// Create a Syntax from a file path, auto-detecting the language from the extension.
70    ///
71    /// Reads the file contents and infers the programming language from the
72    /// file extension. Optionally enables line numbers and sets a theme.
73    ///
74    /// # Errors
75    ///
76    /// Returns an IO error if the file cannot be read.
77    ///
78    /// # Example
79    ///
80    /// ```rust,no_run
81    /// use rusty_rich::Syntax;
82    ///
83    /// let syntax = Syntax::from_path("main.rs", true, Some("monokai")).unwrap();
84    /// ```
85    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    /// Guess the syntax lexer name from a file path's extension.
104    ///
105    /// Delegates to [`guess_lexer_for_filename`] by extracting the file stem
106    /// and extension from the provided path.
107    pub fn guess_lexer(path: impl AsRef<Path>) -> Option<String> {
108        guess_lexer_for_filename(path.as_ref().to_str()?)
109    }
110
111    /// Apply a background style to a range of lines (for highlighting).
112    ///
113    /// Returns a new [`Syntax`] with the style applied to the specified lines
114    /// (1-based, inclusive). This is useful for highlighting a specific range
115    /// of lines, e.g. the current line in a debugger.
116    ///
117    /// # Example
118    ///
119    /// ```rust
120    /// use rusty_rich::{Syntax, Style, Color};
121    ///
122    /// let syntax = Syntax::new("line1\nline2", "text")
123    ///     .stylize_range(1, 1, Style::new().bgcolor(Color::from_rgb(255, 255, 200)));
124    /// ```
125    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    /// Get the current theme name.
133    pub fn get_theme(&self) -> &str {
134        &self.theme
135    }
136
137    /// Return the default lexer name (`"text"`).
138    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            // No highlighting — just render as plain text
147            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 per-line styles
154            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            // Line number
186            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            // Highlight the line
193            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 per-line styles
212        apply_line_styles(&mut lines, self.start_line, &self.line_styles);
213
214        RenderResult { lines, items: Vec::new() }
215    }
216}
217
218/// Apply per-line styles to rendered segment lines.
219///
220/// For each line that has a matching style in `line_styles`, the background
221/// color from that style is applied to every segment on that line.
222fn 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
246/// Convert a syntect `Style` to our `Style`.
247fn 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/// A syntax theme that maps to ANSI colors (lightweight, no Pygments dependency).
265///
266/// Provides a simple token-to-style mapping for common syntax token types
267/// like "keyword", "string", "comment", "number", "type", and "function".
268/// Pre-built themes are available via [`ANSISyntaxTheme::monokai`] and
269/// [`ANSISyntaxTheme::default_light`].
270#[derive(Debug, Clone)]
271pub struct ANSISyntaxTheme {
272    /// Optional background color for the code block.
273    pub background: Option<Color>,
274    /// Optional default foreground color.
275    pub foreground: Option<Color>,
276    /// Token name to style mapping.
277    pub styles: HashMap<String, Style>,
278}
279
280impl ANSISyntaxTheme {
281    /// Create a new empty `ANSISyntaxTheme`.
282    pub fn new() -> Self {
283        Self {
284            background: None,
285            foreground: None,
286            styles: HashMap::new(),
287        }
288    }
289
290    /// Set the style for a token type.
291    ///
292    /// Common token names include: `"comment"`, `"keyword"`, `"string"`,
293    /// `"number"`, `"type"`, `"function"`.
294    pub fn set(&mut self, token: &str, style: Style) {
295        self.styles.insert(token.to_string(), style);
296    }
297
298    /// Get the style for a token type, if one has been set.
299    pub fn get(&self, token: &str) -> Option<&Style> {
300        self.styles.get(token)
301    }
302
303    /// Create a Monokai-inspired theme.
304    ///
305    /// Features a dark background with vibrant foreground colors
306    /// commonly associated with the Monokai color scheme.
307    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    /// Create a default light theme.
321    ///
322    /// Provides a white background with blue keywords, red strings,
323    /// and navy numbers — a familiar light-mode syntax scheme.
324    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
344/// Trait for syntax themes.
345///
346/// Implementors provide token-to-style mappings and an optional
347/// background color for syntax-highlighted code blocks.
348pub trait SyntaxTheme {
349    /// Get the style for a given token type (e.g. `"keyword"`, `"string"`).
350    fn get_style(&self, token: &str) -> Option<Style>;
351    /// Get the optional background color for the entire code block.
352    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
365/// Resolve a lexer name (case-insensitive) to a canonical name.
366///
367/// Supports common short aliases:
368///
369/// | Alias | Canonical |
370/// |-------|-----------|
371/// | `py` | `python` |
372/// | `rs` | `rust` |
373/// | `js` | `javascript` |
374/// | `ts` | `typescript` |
375/// | `cpp` | `cpp` |
376/// | `rb` | `ruby` |
377/// | `md` | `markdown` |
378/// | `sh` / `bash` | `bash` |
379///
380/// If no alias matches, returns the input as-is so that syntect can attempt
381/// to resolve it natively.
382pub 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
397/// Get a pre-built [`ANSISyntaxTheme`] by name.
398///
399/// Supported names: `"monokai"`, `"light"`, `"nord"`, `"dracula"`, `"github"`.
400///
401/// Returns `None` for unrecognized theme names.
402pub 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
446/// Guess the syntax lexer name from a filename or file path.
447///
448/// Maps common file extensions to their corresponding lexer names:
449///
450/// | Extension | Lexer |
451/// |-----------|-------|
452/// | `.rs` | `rust` |
453/// | `.py` | `python` |
454/// | `.js` | `javascript` |
455/// | `.ts` | `typescript` |
456/// | `.java` | `java` |
457/// | `.go` | `go` |
458/// | `.rb` | `ruby` |
459/// | `.php` | `php` |
460/// | `.c`, `.h` | `c` |
461/// | `.cpp`, `.hpp` | `c++` |
462/// | `.cs` | `csharp` |
463/// | `.html` | `html` |
464/// | `.css` | `css` |
465/// | `.scss` | `scss` |
466/// | `.json` | `json` |
467/// | `.xml` | `xml` |
468/// | `.yaml`, `.yml` | `yaml` |
469/// | `.md` | `markdown` |
470/// | `.sql` | `sql` |
471/// | `.sh`, `.bash` | `bash` |
472/// | `.toml` | `toml` |
473/// | `.ini`, `.cfg` | `ini` |
474/// | `Dockerfile` | `dockerfile` |
475/// | `Makefile` | `makefile` |
476///
477/// Returns `None` for unrecognized filenames.
478pub fn guess_lexer_for_filename(filename: &str) -> Option<String> {
479    let name = filename.trim();
480    // Check for well-known filenames without extensions
481    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    // Extract the extension
488    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        // Unknown short names should pass through as-is
635        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}