Skip to main content

rich_rs/
syntax.rs

1//! Syntax: syntax-highlighted code rendering.
2//!
3//! This module provides syntax highlighting for source code using the `syntect` crate.
4//! It supports various programming languages and color themes.
5//!
6//! # Example
7//!
8//! ```
9//! use rich_rs::Syntax;
10//!
11//! let code = r#"fn main() {
12//!     println!("Hello, World!");
13//! }"#;
14//!
15//! let syntax = Syntax::new(code, "rust")
16//!     .with_theme("monokai")
17//!     .with_line_numbers(true);
18//! ```
19
20use std::collections::HashSet;
21use std::io::Stdout;
22use std::path::Path;
23
24use once_cell::sync::Lazy;
25use syntect::easy::HighlightLines;
26use syntect::highlighting::{Style as SyntectStyle, Theme, ThemeSet};
27use syntect::parsing::SyntaxSet;
28use syntect::util::LinesWithEndings;
29
30use crate::Renderable;
31use crate::cells::cell_len;
32use crate::color::SimpleColor as Color;
33use crate::console::{Console, ConsoleOptions, OverflowMethod};
34use crate::measure::Measurement;
35use crate::padding::PaddingDimensions;
36use crate::segment::{Segment, Segments};
37use crate::style::Style;
38use crate::text::Text;
39
40// ============================================================================
41// Static syntax and theme sets
42// ============================================================================
43
44/// Global syntax set loaded once at startup.
45static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
46
47/// Global theme set loaded once at startup.
48static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
49
50/// Embedded Monokai theme (matches Pygments Monokai exactly).
51const MONOKAI_THEME_DATA: &[u8] = include_bytes!("themes/monokai.tmTheme");
52
53/// Embedded Monokai Plus theme (enhanced with more semantic highlighting).
54const MONOKAI_PLUS_THEME_DATA: &[u8] = include_bytes!("themes/monokai-plus.tmTheme");
55
56/// Embedded Dracula theme (imported from Pygments).
57const DRACULA_THEME_DATA: &[u8] = include_bytes!("themes/dracula.tmTheme");
58
59/// Embedded Gruvbox Dark theme (imported from Pygments).
60const GRUVBOX_DARK_THEME_DATA: &[u8] = include_bytes!("themes/gruvbox-dark.tmTheme");
61
62/// Embedded Nord theme (imported from Pygments).
63const NORD_THEME_DATA: &[u8] = include_bytes!("themes/nord.tmTheme");
64
65/// Monokai theme loaded from embedded data.
66static MONOKAI_THEME: Lazy<Option<Theme>> = Lazy::new(|| {
67    use std::io::Cursor;
68    let mut cursor = Cursor::new(MONOKAI_THEME_DATA);
69    ThemeSet::load_from_reader(&mut cursor).ok()
70});
71
72/// Monokai Plus theme loaded from embedded data.
73static MONOKAI_PLUS_THEME: Lazy<Option<Theme>> = Lazy::new(|| {
74    use std::io::Cursor;
75    let mut cursor = Cursor::new(MONOKAI_PLUS_THEME_DATA);
76    ThemeSet::load_from_reader(&mut cursor).ok()
77});
78
79/// Dracula theme loaded from embedded data.
80static DRACULA_THEME: Lazy<Option<Theme>> = Lazy::new(|| {
81    use std::io::Cursor;
82    let mut cursor = Cursor::new(DRACULA_THEME_DATA);
83    ThemeSet::load_from_reader(&mut cursor).ok()
84});
85
86/// Gruvbox Dark theme loaded from embedded data.
87static GRUVBOX_DARK_THEME: Lazy<Option<Theme>> = Lazy::new(|| {
88    use std::io::Cursor;
89    let mut cursor = Cursor::new(GRUVBOX_DARK_THEME_DATA);
90    ThemeSet::load_from_reader(&mut cursor).ok()
91});
92
93/// Nord theme loaded from embedded data.
94static NORD_THEME: Lazy<Option<Theme>> = Lazy::new(|| {
95    use std::io::Cursor;
96    let mut cursor = Cursor::new(NORD_THEME_DATA);
97    ThemeSet::load_from_reader(&mut cursor).ok()
98});
99
100/// Default theme name (matches Python Rich's default "monokai").
101/// We map "monokai" to "base16-mocha.dark" which is the closest available theme.
102pub const DEFAULT_THEME: &str = "monokai";
103
104/// Fallback syntect theme name for ANSI theme highlighting.
105const FALLBACK_SYNTECT_THEME: &str = "base16-mocha.dark";
106
107/// Default padding for line numbers column.
108/// Format: "  N │ " = 2 (pointer) + digits + 3 (separator " │ ")
109// Match Python Rich `rich.syntax.NUMBERS_COLUMN_DEFAULT_PADDING`.
110pub const NUMBERS_COLUMN_DEFAULT_PADDING: usize = 2;
111
112// ============================================================================
113// ANSI Theme Styles (for terminal-friendly themes)
114// ============================================================================
115
116/// ANSI-friendly style mappings for light terminals.
117pub mod ansi_light {
118    use crate::color::SimpleColor as Color;
119    use crate::style::Style;
120
121    pub fn comment() -> Style {
122        Style::new().with_dim(true)
123    }
124    pub fn comment_preproc() -> Style {
125        Style::new().with_color(Color::Standard(6)) // cyan
126    }
127    pub fn keyword() -> Style {
128        Style::new().with_color(Color::Standard(4)) // blue
129    }
130    pub fn keyword_type() -> Style {
131        Style::new().with_color(Color::Standard(6)) // cyan
132    }
133    pub fn operator_word() -> Style {
134        Style::new().with_color(Color::Standard(5)) // magenta
135    }
136    pub fn name_builtin() -> Style {
137        Style::new().with_color(Color::Standard(6)) // cyan
138    }
139    pub fn name_function() -> Style {
140        Style::new().with_color(Color::Standard(2)) // green
141    }
142    pub fn name_namespace() -> Style {
143        Style::new()
144            .with_color(Color::Standard(6))
145            .with_underline(true) // cyan underlined
146    }
147    pub fn name_class() -> Style {
148        Style::new()
149            .with_color(Color::Standard(2))
150            .with_underline(true) // green underlined
151    }
152    pub fn name_decorator() -> Style {
153        Style::new().with_color(Color::Standard(5)).with_bold(true) // magenta bold
154    }
155    pub fn name_variable() -> Style {
156        Style::new().with_color(Color::Standard(1)) // red
157    }
158    pub fn name_attribute() -> Style {
159        Style::new().with_color(Color::Standard(6)) // cyan
160    }
161    pub fn name_tag() -> Style {
162        Style::new().with_color(Color::Standard(12)) // bright blue
163    }
164    pub fn string() -> Style {
165        Style::new().with_color(Color::Standard(3)) // yellow
166    }
167    pub fn number() -> Style {
168        Style::new().with_color(Color::Standard(4)) // blue
169    }
170    pub fn error() -> Style {
171        Style::new()
172            .with_color(Color::Standard(1))
173            .with_underline(true) // red underlined
174    }
175}
176
177/// ANSI-friendly style mappings for dark terminals.
178pub mod ansi_dark {
179    use crate::color::SimpleColor as Color;
180    use crate::style::Style;
181
182    pub fn comment() -> Style {
183        Style::new().with_dim(true)
184    }
185    pub fn comment_preproc() -> Style {
186        Style::new().with_color(Color::Standard(14)) // bright cyan
187    }
188    pub fn keyword() -> Style {
189        Style::new().with_color(Color::Standard(12)) // bright blue
190    }
191    pub fn keyword_type() -> Style {
192        Style::new().with_color(Color::Standard(14)) // bright cyan
193    }
194    pub fn operator_word() -> Style {
195        Style::new().with_color(Color::Standard(13)) // bright magenta
196    }
197    pub fn name_builtin() -> Style {
198        Style::new().with_color(Color::Standard(14)) // bright cyan
199    }
200    pub fn name_function() -> Style {
201        Style::new().with_color(Color::Standard(10)) // bright green
202    }
203    pub fn name_namespace() -> Style {
204        Style::new()
205            .with_color(Color::Standard(14))
206            .with_underline(true) // bright cyan underlined
207    }
208    pub fn name_class() -> Style {
209        Style::new()
210            .with_color(Color::Standard(10))
211            .with_underline(true) // bright green underlined
212    }
213    pub fn name_decorator() -> Style {
214        Style::new().with_color(Color::Standard(13)).with_bold(true) // bright magenta bold
215    }
216    pub fn name_variable() -> Style {
217        Style::new().with_color(Color::Standard(9)) // bright red
218    }
219    pub fn name_attribute() -> Style {
220        Style::new().with_color(Color::Standard(14)) // bright cyan
221    }
222    pub fn name_tag() -> Style {
223        Style::new().with_color(Color::Standard(12)) // bright blue
224    }
225    pub fn string() -> Style {
226        Style::new().with_color(Color::Standard(3)) // yellow
227    }
228    pub fn number() -> Style {
229        Style::new().with_color(Color::Standard(12)) // bright blue
230    }
231    pub fn error() -> Style {
232        Style::new()
233            .with_color(Color::Standard(1))
234            .with_underline(true) // red underlined
235    }
236}
237
238// ============================================================================
239// SyntaxTheme trait
240// ============================================================================
241
242/// Trait for syntax themes.
243///
244/// Abstracts the theme system to support both syntect themes and ANSI themes.
245pub trait SyntaxTheme: Send + Sync {
246    /// Get the style for a token (foreground, background).
247    fn get_style(&self, style: &SyntectStyle) -> Style;
248
249    /// Get the background style for the theme.
250    fn get_background_style(&self) -> Style;
251
252    /// Get the underlying syntect Theme, if available.
253    fn syntect_theme(&self) -> Option<&Theme>;
254}
255
256/// A syntax theme backed by syntect's Theme.
257pub struct SyntectTheme {
258    theme: Theme,
259    background_style: Style,
260}
261
262impl SyntectTheme {
263    /// Create a new syntect-based theme.
264    pub fn new(theme: Theme) -> Self {
265        let bg_color = theme.settings.background.map(|c| Color::Rgb {
266            r: c.r,
267            g: c.g,
268            b: c.b,
269        });
270        let background_style = match bg_color {
271            Some(c) => Style::new().with_bgcolor(c),
272            None => Style::new(),
273        };
274        Self {
275            theme,
276            background_style,
277        }
278    }
279
280    /// Load a theme by name.
281    pub fn from_name(name: &str) -> Option<Self> {
282        THEME_SET.themes.get(name).map(|t| Self::new(t.clone()))
283    }
284}
285
286impl SyntaxTheme for SyntectTheme {
287    fn get_style(&self, style: &SyntectStyle) -> Style {
288        let fg = style.foreground;
289        let bg = style.background;
290
291        let mut result = Style::new();
292
293        // Foreground color
294        result = result.with_color(Color::Rgb {
295            r: fg.r,
296            g: fg.g,
297            b: fg.b,
298        });
299
300        // Background color (only if different from theme background)
301        if let Some(theme_bg) = self.theme.settings.background {
302            if bg.r != theme_bg.r || bg.g != theme_bg.g || bg.b != theme_bg.b {
303                result = result.with_bgcolor(Color::Rgb {
304                    r: bg.r,
305                    g: bg.g,
306                    b: bg.b,
307                });
308            }
309        }
310
311        // Font style
312        let font_style = style.font_style;
313        if font_style.contains(syntect::highlighting::FontStyle::BOLD) {
314            result = result.with_bold(true);
315        }
316        if font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
317            result = result.with_italic(true);
318        }
319        if font_style.contains(syntect::highlighting::FontStyle::UNDERLINE) {
320            result = result.with_underline(true);
321        }
322
323        result
324    }
325
326    fn get_background_style(&self) -> Style {
327        self.background_style
328    }
329
330    fn syntect_theme(&self) -> Option<&Theme> {
331        Some(&self.theme)
332    }
333}
334
335/// An ANSI-compatible theme that uses standard terminal colors.
336pub struct AnsiTheme {
337    dark: bool,
338}
339
340impl AnsiTheme {
341    /// Create a new ANSI theme for dark terminals.
342    pub fn dark() -> Self {
343        Self { dark: true }
344    }
345
346    /// Create a new ANSI theme for light terminals.
347    pub fn light() -> Self {
348        Self { dark: false }
349    }
350}
351
352impl SyntaxTheme for AnsiTheme {
353    fn get_style(&self, style: &SyntectStyle) -> Style {
354        // For ANSI themes, we map the color to the nearest standard color
355        let fg = style.foreground;
356
357        // Simple heuristic: map based on the dominant component
358        let (r, g, b) = (fg.r as u16, fg.g as u16, fg.b as u16);
359
360        // Calculate luminance-like value
361        let brightness = (r + g + b) / 3;
362
363        // Choose a standard color based on the RGB values
364        let color = if brightness < 50 {
365            // Very dark - use default or black
366            Color::Standard(0) // black
367        } else if r > 200 && g < 100 && b < 100 {
368            // Red-ish
369            if self.dark {
370                Color::Standard(9)
371            } else {
372                Color::Standard(1)
373            }
374        } else if g > 200 && r < 100 && b < 100 {
375            // Green-ish
376            if self.dark {
377                Color::Standard(10)
378            } else {
379                Color::Standard(2)
380            }
381        } else if b > 200 && r < 100 && g < 100 {
382            // Blue-ish
383            if self.dark {
384                Color::Standard(12)
385            } else {
386                Color::Standard(4)
387            }
388        } else if r > 200 && g > 200 && b < 100 {
389            // Yellow-ish
390            Color::Standard(3)
391        } else if r > 200 && b > 200 && g < 100 {
392            // Magenta-ish
393            if self.dark {
394                Color::Standard(13)
395            } else {
396                Color::Standard(5)
397            }
398        } else if g > 200 && b > 200 && r < 100 {
399            // Cyan-ish
400            if self.dark {
401                Color::Standard(14)
402            } else {
403                Color::Standard(6)
404            }
405        } else if brightness > 200 {
406            // Bright - use white
407            Color::Standard(7)
408        } else {
409            // Default - use the theme's choice
410            Color::Rgb {
411                r: fg.r,
412                g: fg.g,
413                b: fg.b,
414            }
415        };
416
417        let mut result = Style::new().with_color(color);
418
419        let font_style = style.font_style;
420        if font_style.contains(syntect::highlighting::FontStyle::BOLD) {
421            result = result.with_bold(true);
422        }
423        if font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
424            result = result.with_italic(true);
425        }
426        if font_style.contains(syntect::highlighting::FontStyle::UNDERLINE) {
427            result = result.with_underline(true);
428        }
429
430        result
431    }
432
433    fn get_background_style(&self) -> Style {
434        Style::new()
435    }
436
437    fn syntect_theme(&self) -> Option<&Theme> {
438        None
439    }
440}
441
442// ============================================================================
443// Syntax struct
444// ============================================================================
445
446/// A renderable for syntax-highlighted code.
447///
448/// `Syntax` renders source code with syntax highlighting, optional line numbers,
449/// and various display options.
450///
451/// # Example
452///
453/// ```
454/// use rich_rs::Syntax;
455///
456/// let code = "fn main() { println!(\"Hello!\"); }";
457/// let syntax = Syntax::new(code, "rust")
458///     .with_line_numbers(true)
459///     .with_theme("monokai");
460/// ```
461/// A range to apply a custom style to in the syntax output.
462///
463/// Positions are `(line, column)` where line is 1-based and column is 0-based.
464#[derive(Debug, Clone)]
465struct SyntaxHighlightRange {
466    style: Style,
467    start: (usize, usize),
468    end: (usize, usize),
469}
470
471pub struct Syntax {
472    /// The source code to highlight.
473    code: String,
474    /// The language/lexer name.
475    lexer: String,
476    /// The theme to use.
477    theme: Box<dyn SyntaxTheme>,
478    /// Whether to dedent the code.
479    dedent: bool,
480    /// Whether to show line numbers.
481    line_numbers: bool,
482    /// Starting line number.
483    start_line: usize,
484    /// Optional line range to display (start, end).
485    line_range: Option<(Option<usize>, Option<usize>)>,
486    /// Lines to highlight.
487    highlight_lines: HashSet<usize>,
488    /// Fixed width for the code area.
489    code_width: Option<usize>,
490    /// Tab size for expansion.
491    tab_size: usize,
492    /// Whether to word wrap long lines.
493    /// NOTE: Not yet implemented - stored for future use.
494    #[allow(dead_code)]
495    word_wrap: bool,
496    /// Optional background color override.
497    background_color: Option<Color>,
498    /// Whether to show indent guides.
499    /// NOTE: Not yet implemented - stored for future use.
500    #[allow(dead_code)]
501    indent_guides: bool,
502    /// Padding around the syntax block.
503    padding: (usize, usize, usize, usize),
504    /// Whether an explicit theme was set (if false, use Console's theme).
505    explicit_theme: bool,
506    /// Custom style ranges applied on top of syntax highlighting.
507    stylized_ranges: Vec<SyntaxHighlightRange>,
508}
509
510impl std::fmt::Debug for Syntax {
511    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512        f.debug_struct("Syntax")
513            .field("code_len", &self.code.len())
514            .field("lexer", &self.lexer)
515            .field("dedent", &self.dedent)
516            .field("line_numbers", &self.line_numbers)
517            .field("start_line", &self.start_line)
518            .field("line_range", &self.line_range)
519            .field("highlight_lines", &self.highlight_lines)
520            .field("code_width", &self.code_width)
521            .field("tab_size", &self.tab_size)
522            .field("word_wrap", &self.word_wrap)
523            .field("indent_guides", &self.indent_guides)
524            .field("padding", &self.padding)
525            .field("explicit_theme", &self.explicit_theme)
526            .finish_non_exhaustive()
527    }
528}
529
530impl Syntax {
531    /// Create a new Syntax object for the given code and language.
532    ///
533    /// # Arguments
534    ///
535    /// * `code` - The source code to highlight.
536    /// * `lexer` - The language name (e.g., "rust", "python", "javascript").
537    ///
538    /// # Example
539    ///
540    /// ```
541    /// use rich_rs::Syntax;
542    ///
543    /// let syntax = Syntax::new("print('hello')", "python");
544    /// ```
545    pub fn new(code: impl Into<String>, lexer: impl Into<String>) -> Self {
546        let theme = Self::get_theme(DEFAULT_THEME);
547        Self {
548            code: code.into(),
549            lexer: lexer.into(),
550            theme,
551            dedent: false,
552            line_numbers: false,
553            start_line: 1,
554            line_range: None,
555            highlight_lines: HashSet::new(),
556            code_width: None,
557            tab_size: 4,
558            word_wrap: false,
559            background_color: None,
560            indent_guides: false,
561            padding: (0, 0, 0, 0),
562            explicit_theme: false,
563            stylized_ranges: Vec::new(),
564        }
565    }
566
567    /// Create a Syntax object from a file path.
568    ///
569    /// The language is auto-detected from the file extension.
570    ///
571    /// # Arguments
572    ///
573    /// * `path` - Path to the source file.
574    ///
575    /// # Returns
576    ///
577    /// `Ok(Syntax)` if the file was read successfully, `Err` otherwise.
578    ///
579    /// # Example
580    ///
581    /// ```ignore
582    /// let syntax = Syntax::from_path("src/main.rs")?;
583    /// ```
584    pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
585        let path = path.as_ref();
586        let code = std::fs::read_to_string(path)?;
587        let lexer = Self::guess_lexer(path, Some(&code));
588        Ok(Self::new(code, lexer))
589    }
590
591    /// Guess the language/lexer for a file path.
592    ///
593    /// # Arguments
594    ///
595    /// * `path` - Path to examine.
596    /// * `code` - Optional code content for better detection.
597    ///
598    /// # Returns
599    ///
600    /// The best-guess language name.
601    pub fn guess_lexer(path: impl AsRef<Path>, code: Option<&str>) -> String {
602        let path = path.as_ref();
603
604        // Try to find syntax by extension
605        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
606            if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) {
607                return syntax.name.to_lowercase();
608            }
609        }
610
611        // Try by filename
612        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
613            // Check for specific filenames
614            match filename.to_lowercase().as_str() {
615                "makefile" | "gnumakefile" => return "makefile".to_string(),
616                "dockerfile" => return "dockerfile".to_string(),
617                "cmakelists.txt" => return "cmake".to_string(),
618                _ => {}
619            }
620        }
621
622        // Try to detect from content
623        if let Some(code) = code {
624            // Simple heuristics
625            if code.starts_with("#!/usr/bin/env python") || code.starts_with("#!/usr/bin/python") {
626                return "python".to_string();
627            }
628            if code.starts_with("#!/bin/bash") || code.starts_with("#!/usr/bin/env bash") {
629                return "bash".to_string();
630            }
631            if code.starts_with("#!/usr/bin/env node") {
632                return "javascript".to_string();
633            }
634            if code.starts_with("#!/usr/bin/env ruby") {
635                return "ruby".to_string();
636            }
637
638            // Try syntect's first-line detection
639            if let Some(syntax) = SYNTAX_SET.find_syntax_by_first_line(code) {
640                return syntax.name.to_lowercase();
641            }
642        }
643
644        // Default to plain text
645        "text".to_string()
646    }
647
648    /// Get a theme by name.
649    ///
650    /// # Arguments
651    ///
652    /// * `name` - Theme name (e.g., "monokai", "dracula", "github-dark").
653    ///
654    /// # Returns
655    ///
656    /// A boxed SyntaxTheme. Falls back to Monokai if the theme is not found.
657    pub fn get_theme(name: &str) -> Box<dyn SyntaxTheme> {
658        // Check for ANSI themes
659        match name.to_lowercase().as_str() {
660            "ansi_dark" | "ansi-dark" => return Box::new(AnsiTheme::dark()),
661            "ansi_light" | "ansi-light" => return Box::new(AnsiTheme::light()),
662            _ => {}
663        }
664
665        // Check for our embedded themes (imported from Pygments)
666        match name.to_lowercase().as_str() {
667            "monokai" => {
668                if let Some(ref theme) = *MONOKAI_THEME {
669                    return Box::new(SyntectTheme::new(theme.clone()));
670                }
671            }
672            "monokai-plus" | "monokai_plus" => {
673                if let Some(ref theme) = *MONOKAI_PLUS_THEME {
674                    return Box::new(SyntectTheme::new(theme.clone()));
675                }
676            }
677            "dracula" => {
678                if let Some(ref theme) = *DRACULA_THEME {
679                    return Box::new(SyntectTheme::new(theme.clone()));
680                }
681            }
682            "gruvbox-dark" | "gruvbox_dark" | "gruvbox" => {
683                if let Some(ref theme) = *GRUVBOX_DARK_THEME {
684                    return Box::new(SyntectTheme::new(theme.clone()));
685                }
686            }
687            "nord" => {
688                if let Some(ref theme) = *NORD_THEME {
689                    return Box::new(SyntectTheme::new(theme.clone()));
690                }
691            }
692            _ => {}
693        }
694
695        // Map common theme names to syntect equivalents
696        let theme_name = match name.to_lowercase().as_str() {
697            "one-dark" | "onedark" => "base16-ocean.dark",
698            "one-light" | "onelight" => "base16-ocean.light",
699            "github-dark" => "base16-ocean.dark",
700            "github-light" => "base16-ocean.light",
701            "solarized-dark" => "Solarized (dark)",
702            "solarized-light" => "Solarized (light)",
703            _ => name,
704        };
705
706        SyntectTheme::from_name(theme_name)
707            .map(|t| Box::new(t) as Box<dyn SyntaxTheme>)
708            .unwrap_or_else(|| {
709                // Fall back to embedded Monokai or ANSI theme
710                if let Some(ref theme) = *MONOKAI_THEME {
711                    Box::new(SyntectTheme::new(theme.clone()))
712                } else {
713                    Box::new(AnsiTheme::dark())
714                }
715            })
716    }
717
718    /// List available theme names.
719    pub fn available_themes() -> Vec<&'static str> {
720        let mut themes: Vec<&str> = THEME_SET.themes.keys().map(|s| s.as_str()).collect();
721        // Add ANSI themes and embedded themes
722        themes.extend([
723            "ansi_dark",
724            "ansi_light",
725            "monokai",
726            "monokai-plus",
727            "dracula",
728            "gruvbox-dark",
729            "nord",
730        ]);
731        themes.sort();
732        themes
733    }
734
735    /// List available language/lexer names.
736    pub fn available_languages() -> Vec<String> {
737        SYNTAX_SET
738            .syntaxes()
739            .iter()
740            .map(|s| s.name.to_lowercase())
741            .collect()
742    }
743
744    // ========================================================================
745    // Builder methods
746    // ========================================================================
747
748    /// Set the theme by name.
749    ///
750    /// This overrides the Console's theme for this renderable.
751    pub fn with_theme(mut self, theme: impl AsRef<str>) -> Self {
752        self.theme = Self::get_theme(theme.as_ref());
753        self.explicit_theme = true;
754        self
755    }
756
757    /// Set a custom theme.
758    ///
759    /// This overrides the Console's theme for this renderable.
760    pub fn with_custom_theme(mut self, theme: Box<dyn SyntaxTheme>) -> Self {
761        self.theme = theme;
762        self.explicit_theme = true;
763        self
764    }
765
766    /// Enable or disable code dedenting.
767    pub fn with_dedent(mut self, dedent: bool) -> Self {
768        self.dedent = dedent;
769        self
770    }
771
772    /// Enable or disable line numbers.
773    pub fn with_line_numbers(mut self, line_numbers: bool) -> Self {
774        self.line_numbers = line_numbers;
775        self
776    }
777
778    /// Set the starting line number.
779    pub fn with_start_line(mut self, start_line: usize) -> Self {
780        self.start_line = start_line;
781        self
782    }
783
784    /// Set the line range to display.
785    ///
786    /// # Arguments
787    ///
788    /// * `start` - Optional start line (1-based, inclusive).
789    /// * `end` - Optional end line (1-based, inclusive).
790    pub fn with_line_range(mut self, start: Option<usize>, end: Option<usize>) -> Self {
791        self.line_range = Some((start, end));
792        self
793    }
794
795    /// Set lines to highlight.
796    pub fn with_highlight_lines(mut self, lines: impl IntoIterator<Item = usize>) -> Self {
797        self.highlight_lines = lines.into_iter().collect();
798        self
799    }
800
801    /// Set a fixed code width.
802    pub fn with_code_width(mut self, width: usize) -> Self {
803        self.code_width = Some(width);
804        self
805    }
806
807    /// Set the tab size.
808    pub fn with_tab_size(mut self, tab_size: usize) -> Self {
809        self.tab_size = tab_size;
810        self
811    }
812
813    /// Enable or disable word wrapping.
814    ///
815    /// NOTE: Not yet implemented - option is stored for future use.
816    pub fn with_word_wrap(mut self, word_wrap: bool) -> Self {
817        self.word_wrap = word_wrap;
818        self
819    }
820
821    /// Set a background color override.
822    pub fn with_background_color(mut self, color: Color) -> Self {
823        self.background_color = Some(color);
824        self
825    }
826
827    /// Enable or disable indent guides.
828    ///
829    /// NOTE: Not yet implemented - option is stored for future use.
830    pub fn with_indent_guides(mut self, indent_guides: bool) -> Self {
831        self.indent_guides = indent_guides;
832        self
833    }
834
835    /// Set padding around the syntax block.
836    pub fn with_padding(mut self, padding: impl Into<PaddingDimensions>) -> Self {
837        self.padding = padding.into().unpack();
838        self
839    }
840
841    /// Add a custom style range to apply on top of syntax highlighting.
842    ///
843    /// Positions are `(line, column)` where line is 1-based and column is 0-based.
844    ///
845    /// # Arguments
846    ///
847    /// * `style` - Style to apply to the range.
848    /// * `start` - Start position as (line, column).
849    /// * `end` - End position as (line, column).
850    pub fn stylize_range(&mut self, style: Style, start: (usize, usize), end: (usize, usize)) {
851        self.stylized_ranges
852            .push(SyntaxHighlightRange { style, start, end });
853    }
854
855    /// Builder method to add a highlight range.
856    ///
857    /// Positions are `(line, column)` where line is 1-based and column is 0-based.
858    pub fn with_highlight_range(
859        mut self,
860        style: Style,
861        start: (usize, usize),
862        end: (usize, usize),
863    ) -> Self {
864        self.stylized_ranges
865            .push(SyntaxHighlightRange { style, start, end });
866        self
867    }
868
869    // ========================================================================
870    // Getters
871    // ========================================================================
872
873    /// Get the source code.
874    pub fn code(&self) -> &str {
875        &self.code
876    }
877
878    /// Get the lexer/language name.
879    pub fn lexer(&self) -> &str {
880        &self.lexer
881    }
882
883    /// Check if line numbers are enabled.
884    pub fn line_numbers(&self) -> bool {
885        self.line_numbers
886    }
887
888    /// Get the tab size.
889    pub fn tab_size(&self) -> usize {
890        self.tab_size
891    }
892
893    // ========================================================================
894    // Highlighting
895    // ========================================================================
896
897    /// Highlight the code and return a Text object.
898    ///
899    /// This converts syntect-highlighted code into a rich-rs Text object
900    /// with styled spans.
901    pub fn highlight(&self) -> Text {
902        self.highlight_with_theme(&*self.theme)
903    }
904
905    /// Highlight the code with a specific theme.
906    fn highlight_with_theme(&self, theme: &dyn SyntaxTheme) -> Text {
907        let (ends_on_nl, processed_code) = self.process_code();
908
909        // Normalize common lexer name aliases
910        let lexer_lower = self.lexer.to_lowercase();
911        let lexer_normalized = match lexer_lower.as_str() {
912            "python3" | "py3" | "py" => "python",
913            "javascript" | "js" => "javascript",
914            "typescript" | "ts" => "typescript",
915            "rust" | "rs" => "rust",
916            "cpp" | "c++" => "c++",
917            "csharp" | "cs" => "c#",
918            _ => lexer_lower.as_str(),
919        };
920
921        // Find the syntax
922        let syntax = SYNTAX_SET
923            .find_syntax_by_token(lexer_normalized)
924            .or_else(|| SYNTAX_SET.find_syntax_by_extension(lexer_normalized))
925            .or_else(|| SYNTAX_SET.find_syntax_by_name(lexer_normalized))
926            // Also try original if normalized didn't match
927            .or_else(|| SYNTAX_SET.find_syntax_by_token(&self.lexer))
928            .or_else(|| SYNTAX_SET.find_syntax_by_extension(&self.lexer))
929            .or_else(|| SYNTAX_SET.find_syntax_by_name(&self.lexer))
930            .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
931
932        let base_style = self.get_base_style_with_theme(theme);
933        let mut text = Text::new();
934        text.set_base_style(Some(base_style));
935
936        // Highlight the code
937        if let Some(syntect_theme) = theme.syntect_theme() {
938            let mut highlighter = HighlightLines::new(syntax, syntect_theme);
939
940            for line in LinesWithEndings::from(&processed_code) {
941                match highlighter.highlight_line(line, &SYNTAX_SET) {
942                    Ok(ranges) => {
943                        for (style, token) in ranges {
944                            let rich_style = theme.get_style(&style);
945                            text.append(token, Some(rich_style));
946                        }
947                    }
948                    Err(_) => {
949                        // Fall back to unstyled text
950                        text.append(line, None);
951                    }
952                }
953            }
954        } else {
955            // For ANSI themes without syntect theme, use plain highlighting
956            let mut highlighter =
957                HighlightLines::new(syntax, &THEME_SET.themes[FALLBACK_SYNTECT_THEME]);
958
959            for line in LinesWithEndings::from(&processed_code) {
960                match highlighter.highlight_line(line, &SYNTAX_SET) {
961                    Ok(ranges) => {
962                        for (style, token) in ranges {
963                            let rich_style = theme.get_style(&style);
964                            text.append(token, Some(rich_style));
965                        }
966                    }
967                    Err(_) => {
968                        text.append(line, None);
969                    }
970                }
971            }
972        }
973
974        // Apply custom stylized ranges on top of syntax highlighting.
975        if !self.stylized_ranges.is_empty() {
976            self.apply_stylized_ranges(&mut text);
977        }
978
979        // Remove trailing newline if the original didn't have one
980        if !ends_on_nl && text.plain_text().ends_with('\n') {
981            let plain = text.plain_text();
982            let new_plain = plain.trim_end_matches('\n');
983            if plain != new_plain {
984                // Reconstruct text without trailing newline
985                let mut new_text = Text::new();
986                new_text.set_base_style(text.base_style());
987                new_text.append(new_plain, None);
988                // Copy spans but adjust for new length
989                for span in text.spans() {
990                    if span.start < new_plain.chars().count() {
991                        new_text.stylize(
992                            span.start,
993                            span.end.min(new_plain.chars().count()),
994                            span.style,
995                        );
996                    }
997                }
998                return new_text;
999            }
1000        }
1001
1002        text
1003    }
1004
1005    /// Apply custom stylized ranges to the highlighted text.
1006    ///
1007    /// Converts (line, column) positions to character offsets and applies styles.
1008    fn apply_stylized_ranges(&self, text: &mut Text) {
1009        let plain = text.plain_text();
1010
1011        // Build newline offset table: newlines_offsets[i] = char offset of line i+1 start
1012        // Line 1 starts at offset 0.
1013        let mut newlines_offsets: Vec<usize> = vec![0];
1014        for (i, c) in plain.chars().enumerate() {
1015            if c == '\n' {
1016                newlines_offsets.push(i + 1);
1017            }
1018        }
1019        // Sentinel at the end
1020        newlines_offsets.push(plain.chars().count() + 1);
1021
1022        for range in &self.stylized_ranges {
1023            let (start_line, start_col) = range.start;
1024            let (end_line, end_col) = range.end;
1025
1026            // Convert 1-based line to 0-based index
1027            let start_line_idx = start_line.saturating_sub(1);
1028            let end_line_idx = end_line.saturating_sub(1);
1029
1030            let start_offset =
1031                newlines_offsets.get(start_line_idx).copied().unwrap_or(0) + start_col;
1032
1033            let end_offset = newlines_offsets.get(end_line_idx).copied().unwrap_or(0) + end_col;
1034
1035            if start_offset < end_offset {
1036                text.stylize(start_offset, end_offset, range.style);
1037            }
1038        }
1039    }
1040
1041    /// Get the base style for the syntax block.
1042    fn get_base_style(&self) -> Style {
1043        self.get_base_style_with_theme(&*self.theme)
1044    }
1045
1046    /// Get the base style with a specific theme.
1047    fn get_base_style_with_theme(&self, theme: &dyn SyntaxTheme) -> Style {
1048        let mut style = theme.get_background_style();
1049        if let Some(bg) = self.background_color {
1050            style = style.with_bgcolor(bg);
1051        }
1052        style
1053    }
1054
1055    /// Process the code (dedent, normalize newlines, expand tabs).
1056    fn process_code(&self) -> (bool, String) {
1057        let ends_on_nl = self.code.ends_with('\n');
1058        let mut processed = if ends_on_nl {
1059            self.code.clone()
1060        } else {
1061            format!("{}\n", self.code)
1062        };
1063
1064        // Dedent if requested
1065        if self.dedent {
1066            processed = Self::dedent_code(&processed);
1067        }
1068
1069        // Expand tabs
1070        processed = Self::expand_tabs(&processed, self.tab_size);
1071
1072        (ends_on_nl, processed)
1073    }
1074
1075    /// Dedent code by removing common leading whitespace.
1076    fn dedent_code(code: &str) -> String {
1077        let lines: Vec<&str> = code.lines().collect();
1078
1079        // Find minimum indentation (ignoring empty lines)
1080        let min_indent = lines
1081            .iter()
1082            .filter(|line| !line.trim().is_empty())
1083            .map(|line| line.len() - line.trim_start().len())
1084            .min()
1085            .unwrap_or(0);
1086
1087        if min_indent == 0 {
1088            return code.to_string();
1089        }
1090
1091        // Remove min_indent from each line
1092        lines
1093            .iter()
1094            .map(|line| {
1095                if line.len() >= min_indent {
1096                    &line[min_indent..]
1097                } else {
1098                    line.trim_start()
1099                }
1100            })
1101            .collect::<Vec<_>>()
1102            .join("\n")
1103            + if code.ends_with('\n') { "\n" } else { "" }
1104    }
1105
1106    /// Expand tabs to spaces.
1107    fn expand_tabs(code: &str, tab_size: usize) -> String {
1108        if !code.contains('\t') {
1109            return code.to_string();
1110        }
1111
1112        let mut result = String::new();
1113        let mut column = 0;
1114
1115        for c in code.chars() {
1116            match c {
1117                '\t' => {
1118                    let spaces = tab_size - (column % tab_size);
1119                    for _ in 0..spaces {
1120                        result.push(' ');
1121                    }
1122                    column += spaces;
1123                }
1124                '\n' => {
1125                    result.push(c);
1126                    column = 0;
1127                }
1128                _ => {
1129                    result.push(c);
1130                    column += 1;
1131                }
1132            }
1133        }
1134
1135        result
1136    }
1137
1138    /// Get the width of the line numbers column.
1139    fn numbers_column_width(&self) -> usize {
1140        if !self.line_numbers {
1141            return 0;
1142        }
1143        let line_count = self.code.lines().count();
1144        let max_line_no = self.start_line + line_count.saturating_sub(1);
1145        let digits = max_line_no.to_string().len();
1146        digits + NUMBERS_COLUMN_DEFAULT_PADDING
1147    }
1148}
1149
1150impl Renderable for Syntax {
1151    fn render(&self, console: &Console<Stdout>, options: &ConsoleOptions) -> Segments {
1152        let mut result = Segments::new();
1153
1154        let (pad_top, pad_right, pad_bottom, pad_left) = self.padding;
1155        let horizontal_padding = pad_left + pad_right;
1156
1157        // Calculate available width
1158        let numbers_width = self.numbers_column_width();
1159        let code_width = if let Some(w) = self.code_width {
1160            w
1161        } else if self.line_numbers {
1162            options
1163                .max_width
1164                .saturating_sub(numbers_width + 1 + horizontal_padding)
1165        } else {
1166            options.max_width.saturating_sub(horizontal_padding)
1167        };
1168
1169        // Determine which theme to use: explicit theme or Console's theme
1170        let effective_theme: Option<Box<dyn SyntaxTheme>> =
1171            if !self.explicit_theme && options.theme_name != "default" {
1172                Some(Self::get_theme(&options.theme_name))
1173            } else {
1174                None
1175            };
1176
1177        // Get highlighted text (using effective theme or self.theme)
1178        let text = if let Some(ref theme) = effective_theme {
1179            self.highlight_with_theme(&**theme)
1180        } else {
1181            self.highlight()
1182        };
1183
1184        // Split into lines
1185        let lines: Vec<Text> = text.split("\n", false, true);
1186
1187        // Apply line range filter
1188        let (start_idx, end_idx) = if let Some((start, end)) = self.line_range {
1189            let start = start.map(|s| s.saturating_sub(1)).unwrap_or(0);
1190            let end = end.unwrap_or(lines.len());
1191            (start, end)
1192        } else {
1193            (0, lines.len())
1194        };
1195
1196        let filtered_lines: Vec<&Text> = lines
1197            .iter()
1198            .skip(start_idx)
1199            .take(end_idx.saturating_sub(start_idx))
1200            .collect();
1201
1202        // Get base style (using effective theme or self.theme)
1203        let base_style = if let Some(ref theme) = effective_theme {
1204            self.get_base_style_with_theme(&**theme)
1205        } else {
1206            self.get_base_style()
1207        };
1208        let new_line = Segment::line();
1209
1210        // Line number styling - same background as code, grayish foreground
1211        // Color #656660 (RGB 101, 102, 96) matches Python Rich's Monokai line numbers
1212        let number_style = base_style.combine(&Style::new().with_color(Color::Rgb {
1213            r: 101,
1214            g: 102,
1215            b: 96,
1216        }));
1217
1218        // Highlighted line number style - bold with same background
1219        let highlight_number_style = base_style.combine(&Style::new().with_bold(true));
1220
1221        // Indent guide style - dim grayish color matching Python Rich's Monokai
1222        // RGB(149, 144, 119) with dim attribute
1223        let indent_guide_style = base_style.combine(
1224            &Style::new()
1225                .with_color(Color::Rgb {
1226                    r: 149,
1227                    g: 144,
1228                    b: 119,
1229                })
1230                .with_dim(true),
1231        );
1232
1233        // Add top padding
1234        if pad_top > 0 {
1235            let blank = " ".repeat(options.max_width);
1236            for _ in 0..pad_top {
1237                result.push(Segment::styled(blank.clone(), base_style));
1238                result.push(new_line.clone());
1239            }
1240        }
1241
1242        // Render each line
1243        for (idx, line) in filtered_lines.iter().enumerate() {
1244            let line_no = self.start_line + start_idx + idx;
1245            let is_highlighted = self.highlight_lines.contains(&line_no);
1246
1247            // Left padding
1248            if pad_left > 0 {
1249                result.push(Segment::styled(" ".repeat(pad_left), base_style));
1250            }
1251
1252            // Line number (inside the syntax block with background)
1253            if self.line_numbers {
1254                // Match Python Rich: pointer (2 chars), then a right-aligned line number column,
1255                // and a single trailing space before code. No "│" separator.
1256                let pointer = if options.legacy_windows { "> " } else { "❱ " };
1257                let line_num_str = format!(
1258                    "{:>width$} ",
1259                    line_no,
1260                    width = numbers_width.saturating_sub(2)
1261                );
1262
1263                if is_highlighted {
1264                    // Highlighted line: red pointer, bold number
1265                    let pointer_style = base_style
1266                        .combine(&Style::new().with_color(Color::Standard(1)).with_bold(true));
1267                    result.push(Segment::styled(pointer.to_string(), pointer_style));
1268                    result.push(Segment::styled(line_num_str, highlight_number_style));
1269                } else {
1270                    // Normal line: spaces for pointer area, dim number
1271                    result.push(Segment::styled("  ".to_string(), highlight_number_style));
1272                    result.push(Segment::styled(line_num_str, number_style));
1273                }
1274            }
1275
1276            // Calculate indent guides if enabled
1277            let mut guides_width = 0;
1278            let line_to_render;
1279
1280            if self.indent_guides {
1281                let plain = line.plain_text();
1282                let leading_spaces = plain.chars().take_while(|c| *c == ' ').count();
1283                let num_guides = leading_spaces / self.tab_size;
1284
1285                if num_guides > 0 {
1286                    // Each guide is "│" + (tab_size - 1) spaces
1287                    for _ in 0..num_guides {
1288                        result.push(Segment::styled("│".to_string(), indent_guide_style));
1289                        result.push(Segment::styled(
1290                            " ".repeat(self.tab_size - 1),
1291                            indent_guide_style,
1292                        ));
1293                    }
1294                    guides_width = num_guides * self.tab_size;
1295
1296                    // Use divide to extract the portion after the leading whitespace
1297                    let parts = line.divide([guides_width]);
1298                    line_to_render = if parts.len() > 1 {
1299                        parts.into_iter().nth(1).unwrap_or_else(|| (*line).clone())
1300                    } else {
1301                        // No content after guides, just use empty
1302                        Text::plain("")
1303                    };
1304                } else {
1305                    line_to_render = (*line).clone();
1306                }
1307            } else {
1308                line_to_render = (*line).clone();
1309            }
1310
1311            // Render line content without wrapping, then clip/pad to code width.
1312            // Subtract indent guides width from available width
1313            let content_width = code_width.saturating_sub(guides_width);
1314            let mut line_options = options.update_width(content_width);
1315            line_options.no_wrap = true;
1316            line_options.overflow = Some(OverflowMethod::Crop);
1317
1318            let line_segments: Vec<Segment> = line_to_render
1319                .render(console, &line_options)
1320                .into_iter()
1321                .collect();
1322            let adjusted =
1323                Segment::adjust_line_length(&line_segments, content_width, Some(base_style), true);
1324            for seg in adjusted {
1325                result.push(seg);
1326            }
1327
1328            // Right padding
1329            if pad_right > 0 {
1330                result.push(Segment::styled(" ".repeat(pad_right), base_style));
1331            }
1332
1333            result.push(new_line.clone());
1334        }
1335
1336        // Add bottom padding
1337        if pad_bottom > 0 {
1338            let blank = " ".repeat(options.max_width);
1339            for _ in 0..pad_bottom {
1340                result.push(Segment::styled(blank.clone(), base_style));
1341                result.push(new_line.clone());
1342            }
1343        }
1344
1345        result
1346    }
1347
1348    fn measure(&self, _console: &Console<Stdout>, options: &ConsoleOptions) -> Measurement {
1349        let (_, pad_right, _, pad_left) = self.padding;
1350        let horizontal_padding = pad_left + pad_right;
1351
1352        let numbers_width = self.numbers_column_width();
1353
1354        if let Some(code_width) = self.code_width {
1355            let width = code_width + numbers_width + horizontal_padding;
1356            if self.line_numbers {
1357                return Measurement::new(numbers_width, width + 1);
1358            }
1359            return Measurement::new(numbers_width, width);
1360        }
1361
1362        // Calculate from code
1363        let lines: Vec<&str> = self.code.lines().collect();
1364        let max_line_width = lines.iter().map(|l| cell_len(l)).max().unwrap_or(0);
1365
1366        let width = max_line_width + numbers_width + horizontal_padding;
1367        let width = if self.line_numbers { width + 1 } else { width };
1368
1369        Measurement::new(numbers_width.max(1), width.min(options.max_width))
1370    }
1371}
1372
1373// ============================================================================
1374// Tests
1375// ============================================================================
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    #[test]
1382    fn test_syntax_new() {
1383        let syntax = Syntax::new("fn main() {}", "rust");
1384        assert_eq!(syntax.code(), "fn main() {}");
1385        assert_eq!(syntax.lexer(), "rust");
1386    }
1387
1388    #[test]
1389    fn test_syntax_with_line_numbers() {
1390        let syntax = Syntax::new("fn main() {}", "rust").with_line_numbers(true);
1391        assert!(syntax.line_numbers());
1392    }
1393
1394    #[test]
1395    fn test_syntax_with_theme() {
1396        let syntax = Syntax::new("fn main() {}", "rust").with_theme("monokai");
1397        // Theme should be set (we can't easily inspect it, but it shouldn't panic)
1398        assert_eq!(syntax.code(), "fn main() {}");
1399    }
1400
1401    #[test]
1402    fn test_syntax_with_tab_size() {
1403        let syntax = Syntax::new("fn main() {}", "rust").with_tab_size(2);
1404        assert_eq!(syntax.tab_size(), 2);
1405    }
1406
1407    #[test]
1408    fn test_syntax_highlight() {
1409        let syntax = Syntax::new("fn main() {}", "rust");
1410        let text = syntax.highlight();
1411        // The highlighted text should contain the code
1412        assert!(text.plain_text().contains("fn"));
1413        assert!(text.plain_text().contains("main"));
1414    }
1415
1416    #[test]
1417    fn test_syntax_highlight_python() {
1418        let code = r#"def hello():
1419    print("Hello, World!")
1420"#;
1421        let syntax = Syntax::new(code, "python");
1422        let text = syntax.highlight();
1423        assert!(text.plain_text().contains("def"));
1424        assert!(text.plain_text().contains("hello"));
1425    }
1426
1427    #[test]
1428    fn test_syntax_dedent() {
1429        let code = "    fn main() {\n        println!(\"hello\");\n    }";
1430        let syntax = Syntax::new(code, "rust").with_dedent(true);
1431        let text = syntax.highlight();
1432        // After dedenting, the first line should start with "fn"
1433        assert!(text.plain_text().starts_with("fn"));
1434    }
1435
1436    #[test]
1437    fn test_syntax_expand_tabs() {
1438        let code = "fn main() {\n\tprintln!(\"hello\");\n}";
1439        let syntax = Syntax::new(code, "rust").with_tab_size(4);
1440        let text = syntax.highlight();
1441        // Tabs should be expanded to spaces
1442        assert!(!text.plain_text().contains('\t'));
1443    }
1444
1445    #[test]
1446    fn test_guess_lexer_by_extension() {
1447        assert_eq!(Syntax::guess_lexer("test.rs", None), "rust");
1448        assert_eq!(Syntax::guess_lexer("test.py", None), "python");
1449        assert_eq!(Syntax::guess_lexer("test.js", None), "javascript");
1450    }
1451
1452    #[test]
1453    fn test_available_themes() {
1454        let themes = Syntax::available_themes();
1455        assert!(!themes.is_empty());
1456        assert!(themes.contains(&"ansi_dark"));
1457        assert!(themes.contains(&"ansi_light"));
1458        // Check embedded themes are listed
1459        assert!(themes.contains(&"dracula"), "Should contain dracula theme");
1460        assert!(
1461            themes.contains(&"gruvbox-dark"),
1462            "Should contain gruvbox-dark theme"
1463        );
1464        assert!(themes.contains(&"nord"), "Should contain nord theme");
1465        assert!(themes.contains(&"monokai"), "Should contain monokai theme");
1466        assert!(
1467            themes.contains(&"monokai-plus"),
1468            "Should contain monokai-plus theme"
1469        );
1470    }
1471
1472    #[test]
1473    fn test_available_languages() {
1474        let languages = Syntax::available_languages();
1475        assert!(!languages.is_empty());
1476    }
1477
1478    #[test]
1479    fn test_numbers_column_width() {
1480        let code = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10";
1481        let syntax = Syntax::new(code, "text").with_line_numbers(true);
1482        // 10 lines = 2 digits + 2 padding = 4
1483        assert_eq!(syntax.numbers_column_width(), 4);
1484    }
1485
1486    #[test]
1487    fn test_syntax_render() {
1488        let syntax = Syntax::new("fn main() {}", "rust");
1489        let console = Console::new();
1490        let options = ConsoleOptions::default();
1491
1492        let segments = syntax.render(&console, &options);
1493        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1494
1495        assert!(output.contains("fn"));
1496        assert!(output.contains("main"));
1497    }
1498
1499    #[test]
1500    fn test_syntax_render_with_line_numbers() {
1501        let syntax = Syntax::new("line1\nline2", "text").with_line_numbers(true);
1502        let console = Console::new();
1503        let options = ConsoleOptions::default();
1504
1505        let segments = syntax.render(&console, &options);
1506        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1507
1508        // Should contain line numbers
1509        assert!(output.contains('1'));
1510        assert!(output.contains('2'));
1511    }
1512
1513    #[test]
1514    fn test_syntax_measure() {
1515        let syntax = Syntax::new("hello", "text");
1516        let console = Console::new();
1517        let options = ConsoleOptions::default();
1518
1519        let measurement = syntax.measure(&console, &options);
1520        assert!(measurement.maximum >= 5); // At least the length of "hello"
1521    }
1522
1523    #[test]
1524    fn test_syntax_is_send_sync() {
1525        fn assert_send<T: Send>() {}
1526        fn assert_sync<T: Sync>() {}
1527        assert_send::<Syntax>();
1528        assert_sync::<Syntax>();
1529    }
1530
1531    #[test]
1532    fn test_dedent_code() {
1533        let code = "    line1\n    line2\n    line3\n";
1534        let dedented = Syntax::dedent_code(code);
1535        assert_eq!(dedented, "line1\nline2\nline3\n");
1536    }
1537
1538    #[test]
1539    fn test_dedent_code_mixed_indent() {
1540        let code = "    line1\n        line2\n    line3\n";
1541        let dedented = Syntax::dedent_code(code);
1542        assert_eq!(dedented, "line1\n    line2\nline3\n");
1543    }
1544
1545    #[test]
1546    fn test_expand_tabs() {
1547        let code = "a\tb\tc";
1548        let expanded = Syntax::expand_tabs(code, 4);
1549        assert_eq!(expanded, "a   b   c");
1550    }
1551
1552    #[test]
1553    fn test_expand_tabs_preserves_newlines() {
1554        let code = "a\tb\nc\td";
1555        let expanded = Syntax::expand_tabs(code, 4);
1556        assert_eq!(expanded, "a   b\nc   d");
1557    }
1558
1559    #[test]
1560    fn test_ansi_theme() {
1561        let theme = AnsiTheme::dark();
1562        let style = SyntectStyle {
1563            foreground: syntect::highlighting::Color {
1564                r: 255,
1565                g: 0,
1566                b: 0,
1567                a: 255,
1568            },
1569            background: syntect::highlighting::Color {
1570                r: 0,
1571                g: 0,
1572                b: 0,
1573                a: 255,
1574            },
1575            font_style: syntect::highlighting::FontStyle::empty(),
1576        };
1577        let rich_style = theme.get_style(&style);
1578        // Should have a color set
1579        assert!(rich_style.color.is_some());
1580    }
1581
1582    #[test]
1583    fn test_syntect_theme() {
1584        // Test that the fallback syntect theme exists
1585        let theme = SyntectTheme::from_name(FALLBACK_SYNTECT_THEME);
1586        assert!(
1587            theme.is_some(),
1588            "Fallback theme '{}' should exist",
1589            FALLBACK_SYNTECT_THEME
1590        );
1591        let theme = theme.unwrap();
1592        assert!(theme.syntect_theme().is_some());
1593
1594        // Test that DEFAULT_THEME works via get_theme (which handles mapping)
1595        let default_theme = Syntax::get_theme(DEFAULT_THEME);
1596        assert!(default_theme.syntect_theme().is_some());
1597    }
1598
1599    #[test]
1600    fn test_line_range() {
1601        let code = "line1\nline2\nline3\nline4\nline5";
1602        let syntax = Syntax::new(code, "text").with_line_range(Some(2), Some(4));
1603        let console = Console::new();
1604        let options = ConsoleOptions::default();
1605
1606        let segments = syntax.render(&console, &options);
1607        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1608
1609        // Should contain lines 2-4
1610        assert!(output.contains("line2"));
1611        assert!(output.contains("line3"));
1612        assert!(output.contains("line4"));
1613        // Should not contain line1 or line5
1614        assert!(!output.contains("line1"));
1615        assert!(!output.contains("line5"));
1616    }
1617
1618    #[test]
1619    fn test_highlight_lines() {
1620        let code = "line1\nline2\nline3";
1621        let syntax = Syntax::new(code, "text")
1622            .with_line_numbers(true)
1623            .with_highlight_lines([2]);
1624        let console = Console::new();
1625        let options = ConsoleOptions::default();
1626
1627        let segments = syntax.render(&console, &options);
1628        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1629
1630        // Line 2 should be highlighted with pointer (❱ or > depending on legacy_windows)
1631        assert!(output.contains('❱') || output.contains('>'));
1632    }
1633
1634    #[test]
1635    fn test_stylize_range() {
1636        let mut syntax = Syntax::new("line1\nline2\nline3", "text");
1637        let style = Style::new().with_bold(true);
1638        syntax.stylize_range(style, (2, 0), (2, 5)); // "line2"
1639
1640        let text = syntax.highlight();
1641        let plain = text.plain_text();
1642        assert!(plain.contains("line2"));
1643
1644        // Check that spans were applied
1645        let spans = text.spans();
1646        let has_bold_span = spans.iter().any(|s| s.style.bold == Some(true));
1647        assert!(has_bold_span, "Should have a bold span from stylize_range");
1648    }
1649
1650    #[test]
1651    fn test_with_highlight_range_builder() {
1652        let style = Style::new().with_italic(true);
1653        let syntax = Syntax::new("hello world", "text").with_highlight_range(style, (1, 0), (1, 5)); // "hello"
1654
1655        let text = syntax.highlight();
1656        let spans = text.spans();
1657        let has_italic_span = spans.iter().any(|s| s.style.italic == Some(true));
1658        assert!(
1659            has_italic_span,
1660            "Should have an italic span from with_highlight_range"
1661        );
1662    }
1663
1664    #[test]
1665    fn test_embedded_themes() {
1666        // Test that all embedded themes load correctly
1667        let themes = ["dracula", "gruvbox-dark", "nord", "monokai"];
1668        let code = "def hello():\n    print('Hello')";
1669
1670        for theme_name in themes {
1671            let theme = Syntax::get_theme(theme_name);
1672            assert!(
1673                theme.syntect_theme().is_some(),
1674                "Theme '{}' should load a syntect theme",
1675                theme_name
1676            );
1677
1678            // Test that we can render with each theme
1679            let syntax = Syntax::new(code, "python3").with_theme(theme_name);
1680            let console = Console::new();
1681            let options = ConsoleOptions::default();
1682            let segments = syntax.render(&console, &options);
1683
1684            // Should produce non-empty output
1685            assert!(
1686                !segments.is_empty(),
1687                "Theme '{}' should produce output",
1688                theme_name
1689            );
1690        }
1691    }
1692}