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[&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            // Line number
184            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            // Highlight the line
191            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 per-line styles
210        apply_line_styles(&mut lines, self.start_line, &self.line_styles);
211
212        RenderResult { lines, items: Vec::new() }
213    }
214}
215
216/// Apply per-line styles to rendered segment lines.
217///
218/// For each line that has a matching style in `line_styles`, the background
219/// color from that style is applied to every segment on that line.
220fn 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
244/// Convert a syntect `Style` to our `Style`.
245fn 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/// A syntax theme that maps to ANSI colors (lightweight, no Pygments dependency).
263///
264/// Provides a simple token-to-style mapping for common syntax token types
265/// like "keyword", "string", "comment", "number", "type", and "function".
266/// Pre-built themes are available via [`ANSISyntaxTheme::monokai`] and
267/// [`ANSISyntaxTheme::default_light`].
268#[derive(Debug, Clone)]
269pub struct ANSISyntaxTheme {
270    /// Optional background color for the code block.
271    pub background: Option<Color>,
272    /// Optional default foreground color.
273    pub foreground: Option<Color>,
274    /// Token name to style mapping.
275    pub styles: HashMap<String, Style>,
276}
277
278impl ANSISyntaxTheme {
279    /// Create a new empty `ANSISyntaxTheme`.
280    pub fn new() -> Self {
281        Self {
282            background: None,
283            foreground: None,
284            styles: HashMap::new(),
285        }
286    }
287
288    /// Set the style for a token type.
289    ///
290    /// Common token names include: `"comment"`, `"keyword"`, `"string"`,
291    /// `"number"`, `"type"`, `"function"`.
292    pub fn set(&mut self, token: &str, style: Style) {
293        self.styles.insert(token.to_string(), style);
294    }
295
296    /// Get the style for a token type, if one has been set.
297    pub fn get(&self, token: &str) -> Option<&Style> {
298        self.styles.get(token)
299    }
300
301    /// Create a Monokai-inspired theme.
302    ///
303    /// Features a dark background with vibrant foreground colors
304    /// commonly associated with the Monokai color scheme.
305    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    /// Create a default light theme.
319    ///
320    /// Provides a white background with blue keywords, red strings,
321    /// and navy numbers — a familiar light-mode syntax scheme.
322    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
342/// Trait for syntax themes.
343///
344/// Implementors provide token-to-style mappings and an optional
345/// background color for syntax-highlighted code blocks.
346pub trait SyntaxTheme {
347    /// Get the style for a given token type (e.g. `"keyword"`, `"string"`).
348    fn get_style(&self, token: &str) -> Option<Style>;
349    /// Get the optional background color for the entire code block.
350    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
363/// Resolve a lexer name (case-insensitive) to a canonical name.
364///
365/// Supports common short aliases:
366///
367/// | Alias | Canonical |
368/// |-------|-----------|
369/// | `py` | `python` |
370/// | `rs` | `rust` |
371/// | `js` | `javascript` |
372/// | `ts` | `typescript` |
373/// | `cpp` | `cpp` |
374/// | `rb` | `ruby` |
375/// | `md` | `markdown` |
376/// | `sh` / `bash` | `bash` |
377///
378/// If no alias matches, returns the input as-is so that syntect can attempt
379/// to resolve it natively.
380pub 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
395/// Get a pre-built [`ANSISyntaxTheme`] by name.
396///
397/// Supported names: `"monokai"`, `"light"`, `"nord"`, `"dracula"`, `"github"`.
398///
399/// Returns `None` for unrecognized theme names.
400pub 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
444/// Guess the syntax lexer name from a filename or file path.
445///
446/// Maps common file extensions to their corresponding lexer names:
447///
448/// | Extension | Lexer |
449/// |-----------|-------|
450/// | `.rs` | `rust` |
451/// | `.py` | `python` |
452/// | `.js` | `javascript` |
453/// | `.ts` | `typescript` |
454/// | `.java` | `java` |
455/// | `.go` | `go` |
456/// | `.rb` | `ruby` |
457/// | `.php` | `php` |
458/// | `.c`, `.h` | `c` |
459/// | `.cpp`, `.hpp` | `c++` |
460/// | `.cs` | `csharp` |
461/// | `.html` | `html` |
462/// | `.css` | `css` |
463/// | `.scss` | `scss` |
464/// | `.json` | `json` |
465/// | `.xml` | `xml` |
466/// | `.yaml`, `.yml` | `yaml` |
467/// | `.md` | `markdown` |
468/// | `.sql` | `sql` |
469/// | `.sh`, `.bash` | `bash` |
470/// | `.toml` | `toml` |
471/// | `.ini`, `.cfg` | `ini` |
472/// | `Dockerfile` | `dockerfile` |
473/// | `Makefile` | `makefile` |
474///
475/// Returns `None` for unrecognized filenames.
476pub fn guess_lexer_for_filename(filename: &str) -> Option<String> {
477    let name = filename.trim();
478    // Check for well-known filenames without extensions
479    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    // Extract the extension
486    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        // Unknown short names should pass through as-is
633        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}