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    /// Compute the indent-guide foreground colour for a theme.
1139    ///
1140    /// Python Rich draws indent guides with the theme's `Comment` token colour
1141    /// plus the `dim` attribute; Textual's `ANSIToTruecolor` filter then
1142    /// pre-blends `dim` into a concrete colour (`bg + (fg - bg) * 0.66`) and
1143    /// strips the attribute. Derive that final colour here so the guides match
1144    /// the Python output exactly, instead of relying on terminal SGR dim.
1145    ///
1146    /// Returns `None` when the theme has no syntect backing or no usable
1147    /// background colour (callers should fall back to a plain dim style).
1148    fn indent_guide_color(
1149        theme: &dyn SyntaxTheme,
1150        background_override: Option<Color>,
1151    ) -> Option<Color> {
1152        const DIM_FACTOR: f64 = 0.66;
1153
1154        let syntect_theme = theme.syntect_theme()?;
1155        let highlighter = syntect::highlighting::Highlighter::new(syntect_theme);
1156        let comment_scope = syntect::parsing::Scope::new("comment").ok()?;
1157        let fg = highlighter.style_for_stack(&[comment_scope]).foreground;
1158
1159        let (bg_r, bg_g, bg_b) = match background_override {
1160            Some(Color::Rgb { r, g, b }) => (r, g, b),
1161            Some(_) => return None,
1162            None => {
1163                let bg = syntect_theme.settings.background?;
1164                (bg.r, bg.g, bg.b)
1165            }
1166        };
1167
1168        // Match Python's truncation (rich `Color.from_rgb` goes through `int()`).
1169        let blend =
1170            |bg: u8, fg: u8| (f64::from(bg) + (f64::from(fg) - f64::from(bg)) * DIM_FACTOR) as u8;
1171        Some(Color::Rgb {
1172            r: blend(bg_r, fg.r),
1173            g: blend(bg_g, fg.g),
1174            b: blend(bg_b, fg.b),
1175        })
1176    }
1177
1178    /// Get the width of the line numbers column.
1179    fn numbers_column_width(&self) -> usize {
1180        if !self.line_numbers {
1181            return 0;
1182        }
1183        let line_count = self.code.lines().count();
1184        let max_line_no = self.start_line + line_count.saturating_sub(1);
1185        let digits = max_line_no.to_string().len();
1186        digits + NUMBERS_COLUMN_DEFAULT_PADDING
1187    }
1188}
1189
1190impl Renderable for Syntax {
1191    fn render(&self, console: &Console<Stdout>, options: &ConsoleOptions) -> Segments {
1192        let mut result = Segments::new();
1193
1194        let (pad_top, pad_right, pad_bottom, pad_left) = self.padding;
1195        let horizontal_padding = pad_left + pad_right;
1196
1197        // Calculate available width
1198        let numbers_width = self.numbers_column_width();
1199        let code_width = if let Some(w) = self.code_width {
1200            w
1201        } else if self.line_numbers {
1202            options
1203                .max_width
1204                .saturating_sub(numbers_width + 1 + horizontal_padding)
1205        } else {
1206            options.max_width.saturating_sub(horizontal_padding)
1207        };
1208
1209        // Determine which theme to use: explicit theme or Console's theme
1210        let effective_theme: Option<Box<dyn SyntaxTheme>> =
1211            if !self.explicit_theme && options.theme_name != "default" {
1212                Some(Self::get_theme(&options.theme_name))
1213            } else {
1214                None
1215            };
1216
1217        // Get highlighted text (using effective theme or self.theme)
1218        let text = if let Some(ref theme) = effective_theme {
1219            self.highlight_with_theme(&**theme)
1220        } else {
1221            self.highlight()
1222        };
1223
1224        // Split into lines
1225        let lines: Vec<Text> = text.split("\n", false, true);
1226
1227        // Apply line range filter
1228        let (start_idx, end_idx) = if let Some((start, end)) = self.line_range {
1229            let start = start.map(|s| s.saturating_sub(1)).unwrap_or(0);
1230            let end = end.unwrap_or(lines.len());
1231            (start, end)
1232        } else {
1233            (0, lines.len())
1234        };
1235
1236        let filtered_lines: Vec<&Text> = lines
1237            .iter()
1238            .skip(start_idx)
1239            .take(end_idx.saturating_sub(start_idx))
1240            .collect();
1241
1242        // Get base style (using effective theme or self.theme)
1243        let base_style = if let Some(ref theme) = effective_theme {
1244            self.get_base_style_with_theme(&**theme)
1245        } else {
1246            self.get_base_style()
1247        };
1248        let new_line = Segment::line();
1249
1250        // Line number styling - same background as code, grayish foreground
1251        // Color #656660 (RGB 101, 102, 96) matches Python Rich's Monokai line numbers
1252        let number_style = base_style.combine(&Style::new().with_color(Color::Rgb {
1253            r: 101,
1254            g: 102,
1255            b: 96,
1256        }));
1257
1258        // Highlighted line number style - bold with same background
1259        let highlight_number_style = base_style.combine(&Style::new().with_bold(true));
1260
1261        // Indent guide style - the theme's comment colour, dim pre-blended over
1262        // the background (see `indent_guide_color`). Falls back to an SGR dim
1263        // attribute when the colour can't be derived (e.g. ANSI themes).
1264        let render_theme: &dyn SyntaxTheme = effective_theme.as_deref().unwrap_or(&*self.theme);
1265        let indent_guide_style = base_style.combine(
1266            &Self::indent_guide_color(render_theme, self.background_color)
1267                .map(|color| Style::new().with_color(color))
1268                .unwrap_or_else(|| Style::new().with_dim(true)),
1269        );
1270
1271        // Add top padding
1272        if pad_top > 0 {
1273            let blank = " ".repeat(options.max_width);
1274            for _ in 0..pad_top {
1275                result.push(Segment::styled(blank.clone(), base_style));
1276                result.push(new_line.clone());
1277            }
1278        }
1279
1280        // Render each line
1281        for (idx, line) in filtered_lines.iter().enumerate() {
1282            let line_no = self.start_line + start_idx + idx;
1283            let is_highlighted = self.highlight_lines.contains(&line_no);
1284
1285            // Left padding
1286            if pad_left > 0 {
1287                result.push(Segment::styled(" ".repeat(pad_left), base_style));
1288            }
1289
1290            // Line number (inside the syntax block with background)
1291            if self.line_numbers {
1292                // Match Python Rich: pointer (2 chars), then a right-aligned line number column,
1293                // and a single trailing space before code. No "│" separator.
1294                let pointer = if options.legacy_windows { "> " } else { "❱ " };
1295                let line_num_str = format!(
1296                    "{:>width$} ",
1297                    line_no,
1298                    width = numbers_width.saturating_sub(2)
1299                );
1300
1301                if is_highlighted {
1302                    // Highlighted line: red pointer, bold number
1303                    let pointer_style = base_style
1304                        .combine(&Style::new().with_color(Color::Standard(1)).with_bold(true));
1305                    result.push(Segment::styled(pointer.to_string(), pointer_style));
1306                    result.push(Segment::styled(line_num_str, highlight_number_style));
1307                } else {
1308                    // Normal line: spaces for pointer area, dim number
1309                    result.push(Segment::styled("  ".to_string(), highlight_number_style));
1310                    result.push(Segment::styled(line_num_str, number_style));
1311                }
1312            }
1313
1314            // Calculate indent guides if enabled
1315            let mut guides_width = 0;
1316            let line_to_render;
1317
1318            if self.indent_guides {
1319                let plain = line.plain_text();
1320                let leading_spaces = plain.chars().take_while(|c| *c == ' ').count();
1321                let num_guides = leading_spaces / self.tab_size;
1322
1323                if num_guides > 0 {
1324                    // Each guide is "│" + (tab_size - 1) spaces
1325                    for _ in 0..num_guides {
1326                        result.push(Segment::styled("│".to_string(), indent_guide_style));
1327                        result.push(Segment::styled(
1328                            " ".repeat(self.tab_size - 1),
1329                            indent_guide_style,
1330                        ));
1331                    }
1332                    guides_width = num_guides * self.tab_size;
1333
1334                    // Use divide to extract the portion after the leading whitespace
1335                    let parts = line.divide([guides_width]);
1336                    line_to_render = if parts.len() > 1 {
1337                        parts.into_iter().nth(1).unwrap_or_else(|| (*line).clone())
1338                    } else {
1339                        // No content after guides, just use empty
1340                        Text::plain("")
1341                    };
1342                } else {
1343                    line_to_render = (*line).clone();
1344                }
1345            } else {
1346                line_to_render = (*line).clone();
1347            }
1348
1349            // Render line content without wrapping, then clip/pad to code width.
1350            // Subtract indent guides width from available width
1351            let content_width = code_width.saturating_sub(guides_width);
1352            let mut line_options = options.update_width(content_width);
1353            line_options.no_wrap = true;
1354            line_options.overflow = Some(OverflowMethod::Crop);
1355
1356            let line_segments: Vec<Segment> = line_to_render
1357                .render(console, &line_options)
1358                .into_iter()
1359                .collect();
1360            let adjusted =
1361                Segment::adjust_line_length(&line_segments, content_width, Some(base_style), true);
1362            for seg in adjusted {
1363                result.push(seg);
1364            }
1365
1366            // Right padding
1367            if pad_right > 0 {
1368                result.push(Segment::styled(" ".repeat(pad_right), base_style));
1369            }
1370
1371            result.push(new_line.clone());
1372        }
1373
1374        // Add bottom padding
1375        if pad_bottom > 0 {
1376            let blank = " ".repeat(options.max_width);
1377            for _ in 0..pad_bottom {
1378                result.push(Segment::styled(blank.clone(), base_style));
1379                result.push(new_line.clone());
1380            }
1381        }
1382
1383        result
1384    }
1385
1386    fn measure(&self, _console: &Console<Stdout>, options: &ConsoleOptions) -> Measurement {
1387        let (_, pad_right, _, pad_left) = self.padding;
1388        let horizontal_padding = pad_left + pad_right;
1389
1390        let numbers_width = self.numbers_column_width();
1391
1392        if let Some(code_width) = self.code_width {
1393            let width = code_width + numbers_width + horizontal_padding;
1394            if self.line_numbers {
1395                return Measurement::new(numbers_width, width + 1);
1396            }
1397            return Measurement::new(numbers_width, width);
1398        }
1399
1400        // Calculate from code
1401        let lines: Vec<&str> = self.code.lines().collect();
1402        let max_line_width = lines.iter().map(|l| cell_len(l)).max().unwrap_or(0);
1403
1404        let width = max_line_width + numbers_width + horizontal_padding;
1405        let width = if self.line_numbers { width + 1 } else { width };
1406
1407        Measurement::new(numbers_width.max(1), width.min(options.max_width))
1408    }
1409}
1410
1411// ============================================================================
1412// Tests
1413// ============================================================================
1414
1415#[cfg(test)]
1416mod tests {
1417    use super::*;
1418
1419    #[test]
1420    fn test_syntax_new() {
1421        let syntax = Syntax::new("fn main() {}", "rust");
1422        assert_eq!(syntax.code(), "fn main() {}");
1423        assert_eq!(syntax.lexer(), "rust");
1424    }
1425
1426    #[test]
1427    fn test_syntax_with_line_numbers() {
1428        let syntax = Syntax::new("fn main() {}", "rust").with_line_numbers(true);
1429        assert!(syntax.line_numbers());
1430    }
1431
1432    #[test]
1433    fn test_syntax_with_theme() {
1434        let syntax = Syntax::new("fn main() {}", "rust").with_theme("monokai");
1435        // Theme should be set (we can't easily inspect it, but it shouldn't panic)
1436        assert_eq!(syntax.code(), "fn main() {}");
1437    }
1438
1439    #[test]
1440    fn test_syntax_with_tab_size() {
1441        let syntax = Syntax::new("fn main() {}", "rust").with_tab_size(2);
1442        assert_eq!(syntax.tab_size(), 2);
1443    }
1444
1445    #[test]
1446    fn test_syntax_highlight() {
1447        let syntax = Syntax::new("fn main() {}", "rust");
1448        let text = syntax.highlight();
1449        // The highlighted text should contain the code
1450        assert!(text.plain_text().contains("fn"));
1451        assert!(text.plain_text().contains("main"));
1452    }
1453
1454    #[test]
1455    fn test_syntax_highlight_python() {
1456        let code = r#"def hello():
1457    print("Hello, World!")
1458"#;
1459        let syntax = Syntax::new(code, "python");
1460        let text = syntax.highlight();
1461        assert!(text.plain_text().contains("def"));
1462        assert!(text.plain_text().contains("hello"));
1463    }
1464
1465    #[test]
1466    fn test_syntax_dedent() {
1467        let code = "    fn main() {\n        println!(\"hello\");\n    }";
1468        let syntax = Syntax::new(code, "rust").with_dedent(true);
1469        let text = syntax.highlight();
1470        // After dedenting, the first line should start with "fn"
1471        assert!(text.plain_text().starts_with("fn"));
1472    }
1473
1474    #[test]
1475    fn test_syntax_expand_tabs() {
1476        let code = "fn main() {\n\tprintln!(\"hello\");\n}";
1477        let syntax = Syntax::new(code, "rust").with_tab_size(4);
1478        let text = syntax.highlight();
1479        // Tabs should be expanded to spaces
1480        assert!(!text.plain_text().contains('\t'));
1481    }
1482
1483    #[test]
1484    fn test_guess_lexer_by_extension() {
1485        assert_eq!(Syntax::guess_lexer("test.rs", None), "rust");
1486        assert_eq!(Syntax::guess_lexer("test.py", None), "python");
1487        assert_eq!(Syntax::guess_lexer("test.js", None), "javascript");
1488    }
1489
1490    #[test]
1491    fn test_available_themes() {
1492        let themes = Syntax::available_themes();
1493        assert!(!themes.is_empty());
1494        assert!(themes.contains(&"ansi_dark"));
1495        assert!(themes.contains(&"ansi_light"));
1496        // Check embedded themes are listed
1497        assert!(themes.contains(&"dracula"), "Should contain dracula theme");
1498        assert!(
1499            themes.contains(&"gruvbox-dark"),
1500            "Should contain gruvbox-dark theme"
1501        );
1502        assert!(themes.contains(&"nord"), "Should contain nord theme");
1503        assert!(themes.contains(&"monokai"), "Should contain monokai theme");
1504        assert!(
1505            themes.contains(&"monokai-plus"),
1506            "Should contain monokai-plus theme"
1507        );
1508    }
1509
1510    #[test]
1511    fn test_available_languages() {
1512        let languages = Syntax::available_languages();
1513        assert!(!languages.is_empty());
1514    }
1515
1516    #[test]
1517    fn test_numbers_column_width() {
1518        let code = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10";
1519        let syntax = Syntax::new(code, "text").with_line_numbers(true);
1520        // 10 lines = 2 digits + 2 padding = 4
1521        assert_eq!(syntax.numbers_column_width(), 4);
1522    }
1523
1524    #[test]
1525    fn test_syntax_render() {
1526        let syntax = Syntax::new("fn main() {}", "rust");
1527        let console = Console::new();
1528        let options = ConsoleOptions::default();
1529
1530        let segments = syntax.render(&console, &options);
1531        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1532
1533        assert!(output.contains("fn"));
1534        assert!(output.contains("main"));
1535    }
1536
1537    #[test]
1538    fn test_syntax_render_with_line_numbers() {
1539        let syntax = Syntax::new("line1\nline2", "text").with_line_numbers(true);
1540        let console = Console::new();
1541        let options = ConsoleOptions::default();
1542
1543        let segments = syntax.render(&console, &options);
1544        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1545
1546        // Should contain line numbers
1547        assert!(output.contains('1'));
1548        assert!(output.contains('2'));
1549    }
1550
1551    #[test]
1552    fn test_syntax_measure() {
1553        let syntax = Syntax::new("hello", "text");
1554        let console = Console::new();
1555        let options = ConsoleOptions::default();
1556
1557        let measurement = syntax.measure(&console, &options);
1558        assert!(measurement.maximum >= 5); // At least the length of "hello"
1559    }
1560
1561    #[test]
1562    fn test_syntax_is_send_sync() {
1563        fn assert_send<T: Send>() {}
1564        fn assert_sync<T: Sync>() {}
1565        assert_send::<Syntax>();
1566        assert_sync::<Syntax>();
1567    }
1568
1569    #[test]
1570    fn test_dedent_code() {
1571        let code = "    line1\n    line2\n    line3\n";
1572        let dedented = Syntax::dedent_code(code);
1573        assert_eq!(dedented, "line1\nline2\nline3\n");
1574    }
1575
1576    #[test]
1577    fn test_dedent_code_mixed_indent() {
1578        let code = "    line1\n        line2\n    line3\n";
1579        let dedented = Syntax::dedent_code(code);
1580        assert_eq!(dedented, "line1\n    line2\nline3\n");
1581    }
1582
1583    #[test]
1584    fn test_expand_tabs() {
1585        let code = "a\tb\tc";
1586        let expanded = Syntax::expand_tabs(code, 4);
1587        assert_eq!(expanded, "a   b   c");
1588    }
1589
1590    #[test]
1591    fn test_expand_tabs_preserves_newlines() {
1592        let code = "a\tb\nc\td";
1593        let expanded = Syntax::expand_tabs(code, 4);
1594        assert_eq!(expanded, "a   b\nc   d");
1595    }
1596
1597    #[test]
1598    fn test_ansi_theme() {
1599        let theme = AnsiTheme::dark();
1600        let style = SyntectStyle {
1601            foreground: syntect::highlighting::Color {
1602                r: 255,
1603                g: 0,
1604                b: 0,
1605                a: 255,
1606            },
1607            background: syntect::highlighting::Color {
1608                r: 0,
1609                g: 0,
1610                b: 0,
1611                a: 255,
1612            },
1613            font_style: syntect::highlighting::FontStyle::empty(),
1614        };
1615        let rich_style = theme.get_style(&style);
1616        // Should have a color set
1617        assert!(rich_style.color.is_some());
1618    }
1619
1620    #[test]
1621    fn test_syntect_theme() {
1622        // Test that the fallback syntect theme exists
1623        let theme = SyntectTheme::from_name(FALLBACK_SYNTECT_THEME);
1624        assert!(
1625            theme.is_some(),
1626            "Fallback theme '{}' should exist",
1627            FALLBACK_SYNTECT_THEME
1628        );
1629        let theme = theme.unwrap();
1630        assert!(theme.syntect_theme().is_some());
1631
1632        // Test that DEFAULT_THEME works via get_theme (which handles mapping)
1633        let default_theme = Syntax::get_theme(DEFAULT_THEME);
1634        assert!(default_theme.syntect_theme().is_some());
1635    }
1636
1637    #[test]
1638    fn test_line_range() {
1639        let code = "line1\nline2\nline3\nline4\nline5";
1640        let syntax = Syntax::new(code, "text").with_line_range(Some(2), Some(4));
1641        let console = Console::new();
1642        let options = ConsoleOptions::default();
1643
1644        let segments = syntax.render(&console, &options);
1645        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1646
1647        // Should contain lines 2-4
1648        assert!(output.contains("line2"));
1649        assert!(output.contains("line3"));
1650        assert!(output.contains("line4"));
1651        // Should not contain line1 or line5
1652        assert!(!output.contains("line1"));
1653        assert!(!output.contains("line5"));
1654    }
1655
1656    #[test]
1657    fn test_highlight_lines() {
1658        let code = "line1\nline2\nline3";
1659        let syntax = Syntax::new(code, "text")
1660            .with_line_numbers(true)
1661            .with_highlight_lines([2]);
1662        let console = Console::new();
1663        let options = ConsoleOptions::default();
1664
1665        let segments = syntax.render(&console, &options);
1666        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1667
1668        // Line 2 should be highlighted with pointer (❱ or > depending on legacy_windows)
1669        assert!(output.contains('❱') || output.contains('>'));
1670    }
1671
1672    #[test]
1673    fn test_stylize_range() {
1674        let mut syntax = Syntax::new("line1\nline2\nline3", "text");
1675        let style = Style::new().with_bold(true);
1676        syntax.stylize_range(style, (2, 0), (2, 5)); // "line2"
1677
1678        let text = syntax.highlight();
1679        let plain = text.plain_text();
1680        assert!(plain.contains("line2"));
1681
1682        // Check that spans were applied
1683        let spans = text.spans();
1684        let has_bold_span = spans.iter().any(|s| s.style.bold == Some(true));
1685        assert!(has_bold_span, "Should have a bold span from stylize_range");
1686    }
1687
1688    #[test]
1689    fn test_with_highlight_range_builder() {
1690        let style = Style::new().with_italic(true);
1691        let syntax = Syntax::new("hello world", "text").with_highlight_range(style, (1, 0), (1, 5)); // "hello"
1692
1693        let text = syntax.highlight();
1694        let spans = text.spans();
1695        let has_italic_span = spans.iter().any(|s| s.style.italic == Some(true));
1696        assert!(
1697            has_italic_span,
1698            "Should have an italic span from with_highlight_range"
1699        );
1700    }
1701
1702    /// Resolve the highlighted colour of the first occurrence of `token` in
1703    /// Python `code` under the default (monokai) theme.
1704    fn python_token_color(code: &str, token: &str) -> Option<Color> {
1705        let syntax = Syntax::new(code, "python");
1706        let text = syntax.highlight();
1707        let plain = text.plain_text();
1708        // ASCII-only test inputs: byte offset == char offset.
1709        let start = plain.find(token).expect("token present in highlighted text");
1710        let mut found = None;
1711        for span in text.spans() {
1712            if span.start <= start && start < span.end {
1713                if let Some(color) = span.style.color {
1714                    found = Some(color);
1715                }
1716            }
1717        }
1718        found
1719    }
1720
1721    const MONOKAI_PLAIN: Color = Color::Rgb {
1722        r: 0xf8,
1723        g: 0xf8,
1724        b: 0xf2,
1725    };
1726    const MONOKAI_OPERATOR: Color = Color::Rgb {
1727        r: 0xff,
1728        g: 0x46,
1729        b: 0x89,
1730    };
1731    const MONOKAI_STRING: Color = Color::Rgb {
1732        r: 0xe6,
1733        g: 0xdb,
1734        b: 0x74,
1735    };
1736    const MONOKAI_COMMENT: Color = Color::Rgb {
1737        r: 0x95,
1738        g: 0x90,
1739        b: 0x77,
1740    };
1741
1742    /// Pygments parity: parameter-annotation `:` is plain Punctuation (white),
1743    /// while the return arrow `->` is an Operator (pink).
1744    #[test]
1745    fn test_python_annotation_colon_is_plain_punctuation() {
1746        let code = "def f(values: Iterable[T]) -> int:\n    pass\n";
1747        // The first `:` in the code is the parameter annotation separator.
1748        assert_eq!(python_token_color(code, ":"), Some(MONOKAI_PLAIN));
1749        assert_eq!(python_token_color(code, "->"), Some(MONOKAI_OPERATOR));
1750    }
1751
1752    /// Pygments parity: docstring triple-quote delimiters take the string
1753    /// colour (the whole docstring is one String.Doc token in Pygments).
1754    #[test]
1755    fn test_python_docstring_quotes_are_string_colored() {
1756        let code = "def f():\n    \"\"\"Doc.\"\"\"\n";
1757        assert_eq!(python_token_color(code, "\"\"\""), Some(MONOKAI_STRING));
1758    }
1759
1760    /// Pygments parity: the `in` of `for ... in` is an Operator.Word (pink),
1761    /// not a plain keyword (cyan).
1762    #[test]
1763    fn test_python_for_in_is_operator_word() {
1764        let code = "for value in values:\n    pass\n";
1765        assert_eq!(python_token_color(code, "in"), Some(MONOKAI_OPERATOR));
1766        assert_eq!(
1767            python_token_color(code, "for"),
1768            Some(Color::Rgb {
1769                r: 0x66,
1770                g: 0xd9,
1771                b: 0xef
1772            })
1773        );
1774    }
1775
1776    /// Pygments parity: the `#` delimiter is part of the comment token.
1777    #[test]
1778    fn test_python_comment_hash_is_comment_colored() {
1779        let code = "# hello\n";
1780        assert_eq!(python_token_color(code, "#"), Some(MONOKAI_COMMENT));
1781    }
1782
1783    /// Indent guides use the theme comment colour dim-blended over the theme
1784    /// background (`bg + (fg - bg) * 0.66`), matching Python Rich + Textual's
1785    /// ANSIToTruecolor dim pre-blend: monokai #959077 over #272822 -> #6f6c5a.
1786    #[test]
1787    fn test_indent_guide_color_monokai_dim_preblend() {
1788        let theme = Syntax::get_theme("monokai");
1789        assert_eq!(
1790            Syntax::indent_guide_color(&*theme, None),
1791            Some(Color::Rgb {
1792                r: 0x6f,
1793                g: 0x6c,
1794                b: 0x5a
1795            })
1796        );
1797    }
1798
1799    #[test]
1800    fn test_embedded_themes() {
1801        // Test that all embedded themes load correctly
1802        let themes = ["dracula", "gruvbox-dark", "nord", "monokai"];
1803        let code = "def hello():\n    print('Hello')";
1804
1805        for theme_name in themes {
1806            let theme = Syntax::get_theme(theme_name);
1807            assert!(
1808                theme.syntect_theme().is_some(),
1809                "Theme '{}' should load a syntect theme",
1810                theme_name
1811            );
1812
1813            // Test that we can render with each theme
1814            let syntax = Syntax::new(code, "python3").with_theme(theme_name);
1815            let console = Console::new();
1816            let options = ConsoleOptions::default();
1817            let segments = syntax.render(&console, &options);
1818
1819            // Should produce non-empty output
1820            assert!(
1821                !segments.is_empty(),
1822                "Theme '{}' should produce output",
1823                theme_name
1824            );
1825        }
1826    }
1827}