Skip to main content

glamour/
lib.rs

1#![forbid(unsafe_code)]
2// Allow pedantic lints for early-stage API ergonomics.
3#![allow(clippy::nursery)]
4#![allow(clippy::pedantic)]
5#![allow(clippy::len_zero)]
6#![allow(clippy::single_char_pattern)]
7
8//! # Glamour
9//!
10//! A markdown rendering library for terminal applications.
11//!
12//! Glamour transforms markdown into beautifully styled terminal output with:
13//! - Styled headings, lists, and tables
14//! - Code block formatting with optional syntax highlighting
15//! - Link and image handling
16//! - Customizable themes (Dark, Light, ASCII, Pink)
17//!
18//! ## Role in `charmed_rust`
19//!
20//! Glamour is the Markdown renderer for the ecosystem:
21//! - **glow** is the CLI reader built directly on glamour.
22//! - **demo_showcase** uses glamour for in-app documentation pages.
23//! - **lipgloss** provides the styling primitives that glamour applies.
24//!
25//! ## Example
26//!
27//! ```rust
28//! use glamour::{render, Renderer, Style};
29//!
30//! // Quick render with default dark style
31//! let output = render("# Hello\n\nThis is **bold** text.", Style::Dark).unwrap();
32//! println!("{}", output);
33//!
34//! // Custom renderer with word wrap
35//! let renderer = Renderer::new()
36//!     .with_style(Style::Light)
37//!     .with_word_wrap(80);
38//! let output = renderer.render("# Heading\n\nParagraph text.");
39//! ```
40//!
41//! ## Feature Flags
42//!
43//! - `syntax-highlighting`: Enable syntax highlighting for code blocks using
44//!   [syntect](https://crates.io/crates/syntect). This adds ~2MB to binary size
45//!   due to embedded syntax definitions for ~60 languages.
46//!
47//! ### Example with syntax highlighting
48//!
49//! ```toml
50//! [dependencies]
51//! glamour = { version = "0.1", features = ["syntax-highlighting"] }
52//! ```
53//!
54//! When enabled, code blocks with language annotations (e.g., ` ```rust `)
55//! will be rendered with syntax highlighting using the configured theme.
56//! See `docs/SYNTAX_HIGHLIGHTING_RESEARCH.md` for implementation details.
57
58// Syntax highlighting module (optional feature)
59#[cfg(feature = "syntax-highlighting")]
60pub mod syntax;
61
62// Table parsing module for markdown tables
63pub mod table;
64
65use lipgloss::Style as LipglossStyle;
66use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
67use std::collections::HashMap;
68#[cfg(feature = "syntax-highlighting")]
69use std::collections::HashSet;
70
71// Conditional serde import
72#[cfg(all(feature = "syntax-highlighting", feature = "serde"))]
73use serde::{Deserialize, Serialize};
74
75/// Default width for word wrapping.
76const DEFAULT_WIDTH: usize = 80;
77const DEFAULT_MARGIN: usize = 2;
78const DEFAULT_LIST_INDENT: usize = 2;
79const DEFAULT_LIST_LEVEL_INDENT: usize = 4;
80
81// ============================================================================
82// Style Configuration Types
83// ============================================================================
84
85/// Primitive style settings for text elements.
86#[derive(Debug, Clone, Default)]
87pub struct StylePrimitive {
88    /// Prefix added before the block.
89    pub block_prefix: String,
90    /// Suffix added after the block.
91    pub block_suffix: String,
92    /// Prefix added before text.
93    pub prefix: String,
94    /// Suffix added after text.
95    pub suffix: String,
96    /// Foreground color (ANSI color code or hex).
97    pub color: Option<String>,
98    /// Background color (ANSI color code or hex).
99    pub background_color: Option<String>,
100    /// Whether text is underlined.
101    pub underline: Option<bool>,
102    /// Whether text is bold.
103    pub bold: Option<bool>,
104    /// Whether text is italic.
105    pub italic: Option<bool>,
106    /// Whether text has strikethrough.
107    pub crossed_out: Option<bool>,
108    /// Whether text is faint.
109    pub faint: Option<bool>,
110    /// Format string for special elements (e.g., "Image: {{.text}}").
111    pub format: String,
112}
113
114impl StylePrimitive {
115    /// Creates a new empty style primitive.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Sets the prefix.
121    pub fn prefix(mut self, p: impl Into<String>) -> Self {
122        self.prefix = p.into();
123        self
124    }
125
126    /// Sets the suffix.
127    pub fn suffix(mut self, s: impl Into<String>) -> Self {
128        self.suffix = s.into();
129        self
130    }
131
132    /// Sets the block prefix.
133    pub fn block_prefix(mut self, p: impl Into<String>) -> Self {
134        self.block_prefix = p.into();
135        self
136    }
137
138    /// Sets the block suffix.
139    pub fn block_suffix(mut self, s: impl Into<String>) -> Self {
140        self.block_suffix = s.into();
141        self
142    }
143
144    /// Sets the foreground color.
145    pub fn color(mut self, c: impl Into<String>) -> Self {
146        self.color = Some(c.into());
147        self
148    }
149
150    /// Sets the background color.
151    pub fn background_color(mut self, c: impl Into<String>) -> Self {
152        self.background_color = Some(c.into());
153        self
154    }
155
156    /// Sets bold.
157    pub fn bold(mut self, b: bool) -> Self {
158        self.bold = Some(b);
159        self
160    }
161
162    /// Sets italic.
163    pub fn italic(mut self, i: bool) -> Self {
164        self.italic = Some(i);
165        self
166    }
167
168    /// Sets underline.
169    pub fn underline(mut self, u: bool) -> Self {
170        self.underline = Some(u);
171        self
172    }
173
174    /// Sets strikethrough.
175    pub fn crossed_out(mut self, c: bool) -> Self {
176        self.crossed_out = Some(c);
177        self
178    }
179
180    /// Sets faint.
181    pub fn faint(mut self, f: bool) -> Self {
182        self.faint = Some(f);
183        self
184    }
185
186    /// Sets the format string.
187    pub fn format(mut self, f: impl Into<String>) -> Self {
188        self.format = f.into();
189        self
190    }
191
192    /// Converts to a lipgloss style.
193    pub fn to_lipgloss(&self) -> LipglossStyle {
194        let mut style = LipglossStyle::new();
195
196        if let Some(ref color) = self.color {
197            style = style.foreground(color.as_str());
198        }
199        if let Some(ref bg) = self.background_color {
200            style = style.background(bg.as_str());
201        }
202        if self.bold == Some(true) {
203            style = style.bold();
204        }
205        if self.italic == Some(true) {
206            style = style.italic();
207        }
208        if self.underline == Some(true) {
209            style = style.underline();
210        }
211        if self.crossed_out == Some(true) {
212            style = style.strikethrough();
213        }
214        if self.faint == Some(true) {
215            style = style.faint();
216        }
217
218        style
219    }
220}
221
222/// Block-level style settings.
223#[derive(Debug, Clone, Default)]
224pub struct StyleBlock {
225    /// Primitive style settings.
226    pub style: StylePrimitive,
227    /// Indentation level.
228    pub indent: Option<usize>,
229    /// Prefix used for indentation.
230    pub indent_prefix: Option<String>,
231    /// Margin around the block.
232    pub margin: Option<usize>,
233}
234
235impl StyleBlock {
236    /// Creates a new empty block style.
237    pub fn new() -> Self {
238        Self::default()
239    }
240
241    /// Sets the primitive style.
242    pub fn style(mut self, s: StylePrimitive) -> Self {
243        self.style = s;
244        self
245    }
246
247    /// Sets the indent.
248    pub fn indent(mut self, i: usize) -> Self {
249        self.indent = Some(i);
250        self
251    }
252
253    /// Sets the indent prefix.
254    pub fn indent_prefix(mut self, s: impl Into<String>) -> Self {
255        self.indent_prefix = Some(s.into());
256        self
257    }
258
259    /// Sets the margin.
260    pub fn margin(mut self, m: usize) -> Self {
261        self.margin = Some(m);
262        self
263    }
264}
265
266/// Code block style settings.
267#[derive(Debug, Clone, Default)]
268pub struct StyleCodeBlock {
269    /// Block style settings.
270    pub block: StyleBlock,
271    /// Syntax highlighting theme name.
272    pub theme: Option<String>,
273}
274
275impl StyleCodeBlock {
276    /// Creates a new code block style.
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    /// Sets the block style.
282    pub fn block(mut self, b: StyleBlock) -> Self {
283        self.block = b;
284        self
285    }
286
287    /// Sets the theme.
288    pub fn theme(mut self, t: impl Into<String>) -> Self {
289        self.theme = Some(t.into());
290        self
291    }
292}
293
294/// List style settings.
295#[derive(Debug, Clone, Default)]
296pub struct StyleList {
297    /// Block style settings.
298    pub block: StyleBlock,
299    /// Additional indent per nesting level.
300    pub level_indent: usize,
301}
302
303impl StyleList {
304    /// Creates a new list style.
305    pub fn new() -> Self {
306        Self {
307            level_indent: DEFAULT_LIST_LEVEL_INDENT,
308            ..Default::default()
309        }
310    }
311
312    /// Sets the block style.
313    pub fn block(mut self, b: StyleBlock) -> Self {
314        self.block = b;
315        self
316    }
317
318    /// Sets the level indent.
319    pub fn level_indent(mut self, i: usize) -> Self {
320        self.level_indent = i;
321        self
322    }
323}
324
325/// Table style settings.
326#[derive(Debug, Clone, Default)]
327pub struct StyleTable {
328    /// Block style settings.
329    pub block: StyleBlock,
330    /// Center separator character.
331    pub center_separator: Option<String>,
332    /// Column separator character.
333    pub column_separator: Option<String>,
334    /// Row separator character.
335    pub row_separator: Option<String>,
336}
337
338impl StyleTable {
339    /// Creates a new table style.
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    /// Sets separators.
345    pub fn separators(
346        mut self,
347        center: impl Into<String>,
348        column: impl Into<String>,
349        row: impl Into<String>,
350    ) -> Self {
351        self.center_separator = Some(center.into());
352        self.column_separator = Some(column.into());
353        self.row_separator = Some(row.into());
354        self
355    }
356}
357
358/// Task item style settings.
359#[derive(Debug, Clone, Default)]
360pub struct StyleTask {
361    /// Primitive style settings.
362    pub style: StylePrimitive,
363    /// Marker for checked items.
364    pub ticked: String,
365    /// Marker for unchecked items.
366    pub unticked: String,
367}
368
369impl StyleTask {
370    /// Creates a new task style.
371    pub fn new() -> Self {
372        Self {
373            ticked: "[x] ".to_string(),
374            unticked: "[ ] ".to_string(),
375            ..Default::default()
376        }
377    }
378
379    /// Sets the ticked marker.
380    pub fn ticked(mut self, t: impl Into<String>) -> Self {
381        self.ticked = t.into();
382        self
383    }
384
385    /// Sets the unticked marker.
386    pub fn unticked(mut self, u: impl Into<String>) -> Self {
387        self.unticked = u.into();
388        self
389    }
390}
391
392// ============================================================================
393// Syntax Highlighting Configuration (optional feature)
394// ============================================================================
395
396/// Configuration for syntax highlighting behavior.
397///
398/// This struct is only available when the `syntax-highlighting` feature is enabled.
399///
400/// # Example
401///
402/// ```rust,ignore
403/// use glamour::SyntaxThemeConfig;
404///
405/// let config = SyntaxThemeConfig::default()
406///     .theme("Solarized (dark)")
407///     .line_numbers(true);
408/// ```
409///
410/// # Serialization
411///
412/// When the `serde` feature is enabled, this struct can be serialized/deserialized:
413///
414/// ```toml
415/// # config.toml example
416/// [syntax]
417/// theme_name = "Solarized (dark)"
418/// line_numbers = true
419/// ```
420#[cfg(feature = "syntax-highlighting")]
421#[derive(Debug, Clone)]
422#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
423pub struct SyntaxThemeConfig {
424    /// Theme name (e.g., "base16-ocean.dark", "Solarized (dark)").
425    /// Use `SyntaxTheme::available_themes()` to see all options.
426    pub theme_name: String,
427    /// Whether to show line numbers in code blocks.
428    pub line_numbers: bool,
429    /// Custom language aliases (e.g., "rs" -> "rust").
430    /// These override the built-in aliases.
431    pub language_aliases: HashMap<String, String>,
432    /// Languages to never highlight (render as plain text).
433    pub disabled_languages: HashSet<String>,
434}
435
436#[cfg(feature = "syntax-highlighting")]
437impl Default for SyntaxThemeConfig {
438    fn default() -> Self {
439        Self {
440            theme_name: "base16-ocean.dark".to_string(),
441            line_numbers: false,
442            language_aliases: HashMap::new(),
443            disabled_languages: HashSet::new(),
444        }
445    }
446}
447
448#[cfg(feature = "syntax-highlighting")]
449impl SyntaxThemeConfig {
450    /// Creates a new syntax theme config with defaults.
451    pub fn new() -> Self {
452        Self::default()
453    }
454
455    /// Sets the syntax highlighting theme.
456    ///
457    /// Available themes include:
458    /// - `base16-ocean.dark` (default)
459    /// - `base16-eighties.dark`
460    /// - `base16-mocha.dark`
461    /// - `InspiredGitHub`
462    /// - `Solarized (dark)`
463    /// - `Solarized (light)`
464    pub fn theme(mut self, name: impl Into<String>) -> Self {
465        self.theme_name = name.into();
466        self
467    }
468
469    /// Enables or disables line numbers in code blocks.
470    pub fn line_numbers(mut self, enabled: bool) -> Self {
471        self.line_numbers = enabled;
472        self
473    }
474
475    /// Adds a custom language alias.
476    ///
477    /// This allows mapping custom identifiers to languages.
478    /// For example, `("dockerfile", "docker")` would map the
479    /// `dockerfile` language hint to Docker syntax.
480    pub fn language_alias(mut self, alias: impl Into<String>, language: impl Into<String>) -> Self {
481        self.language_aliases.insert(alias.into(), language.into());
482        self
483    }
484
485    /// Disables highlighting for a specific language.
486    ///
487    /// Languages in this set will be rendered as plain text.
488    pub fn disable_language(mut self, lang: impl Into<String>) -> Self {
489        self.disabled_languages.insert(lang.into());
490        self
491    }
492
493    /// Adds a validated custom language alias.
494    ///
495    /// Unlike [`language_alias`](Self::language_alias), this method validates that:
496    /// - The target language is recognized by the syntax highlighter
497    /// - Adding this alias would not create a cycle in the alias chain
498    ///
499    /// # Errors
500    ///
501    /// Returns an error string if the target language is unrecognized or if
502    /// the alias would create a cycle.
503    pub fn try_language_alias(
504        mut self,
505        alias: impl Into<String>,
506        language: impl Into<String>,
507    ) -> Result<Self, String> {
508        let alias = alias.into();
509        let language = language.into();
510
511        // Self-alias is always a cycle.
512        if alias == language {
513            return Err(format!(
514                "Alias '{}' -> '{}' would create a cycle (self-referential).",
515                alias, language
516            ));
517        }
518
519        // Check target language is recognized (either directly or through
520        // the built-in alias table in LanguageDetector).
521        let detector = crate::syntax::LanguageDetector::new();
522        if !detector.is_supported(&language) {
523            return Err(format!(
524                "Unknown target language '{}'. The language must be recognized by the syntax highlighter.",
525                language
526            ));
527        }
528
529        // Check for alias cycles: walk the alias chain from `language` and
530        // ensure we never revisit `alias`.
531        if self.would_create_cycle(&alias, &language) {
532            return Err(format!(
533                "Alias '{}' -> '{}' would create a cycle in the alias chain.",
534                alias, language
535            ));
536        }
537
538        self.language_aliases.insert(alias, language);
539        Ok(self)
540    }
541
542    /// Returns true if adding `alias -> target` would create a cycle.
543    fn would_create_cycle(&self, alias: &str, target: &str) -> bool {
544        let mut visited = HashSet::new();
545        visited.insert(alias);
546
547        let mut current = target;
548        while let Some(next) = self.language_aliases.get(current) {
549            if !visited.insert(next.as_str()) {
550                return true;
551            }
552            current = next;
553        }
554        false
555    }
556
557    /// Validates that the configured theme and language aliases are valid.
558    ///
559    /// Checks:
560    /// - Theme exists in the available theme set
561    /// - All alias target languages are recognized
562    /// - No cycles exist in the alias chain
563    ///
564    /// # Returns
565    ///
566    /// `Ok(())` if the configuration is valid, or an error message describing
567    /// the first problem found.
568    pub fn validate(&self) -> Result<(), String> {
569        use crate::syntax::SyntaxTheme;
570
571        if SyntaxTheme::from_name(&self.theme_name).is_none() {
572            let available = SyntaxTheme::available_themes().join(", ");
573            return Err(format!(
574                "Unknown syntax theme '{}'. Available themes: {}",
575                self.theme_name, available
576            ));
577        }
578
579        // Validate alias targets
580        let detector = crate::syntax::LanguageDetector::new();
581        for (alias, target) in &self.language_aliases {
582            if !detector.is_supported(target) {
583                return Err(format!(
584                    "Language alias '{}' points to unrecognized language '{}'.",
585                    alias, target
586                ));
587            }
588        }
589
590        // Check for cycles in the alias chain
591        for alias in self.language_aliases.keys() {
592            let mut visited = HashSet::new();
593            visited.insert(alias.as_str());
594            let mut current = alias.as_str();
595            while let Some(next) = self.language_aliases.get(current) {
596                if !visited.insert(next.as_str()) {
597                    return Err(format!(
598                        "Alias chain starting at '{}' contains a cycle.",
599                        alias
600                    ));
601                }
602                current = next;
603            }
604        }
605
606        Ok(())
607    }
608
609    /// Resolves a language identifier through custom aliases.
610    ///
611    /// If a custom alias exists, returns the mapped language.
612    /// Otherwise returns the original language.
613    pub fn resolve_language<'a>(&'a self, lang: &'a str) -> &'a str {
614        self.language_aliases
615            .get(lang)
616            .map(|s| s.as_str())
617            .unwrap_or(lang)
618    }
619
620    /// Checks if a language is disabled.
621    pub fn is_disabled(&self, lang: &str) -> bool {
622        self.disabled_languages.contains(lang)
623    }
624}
625
626/// Complete style configuration for rendering.
627#[derive(Debug, Clone, Default)]
628pub struct StyleConfig {
629    // Document
630    pub document: StyleBlock,
631
632    // Block elements
633    pub block_quote: StyleBlock,
634    pub paragraph: StyleBlock,
635    pub list: StyleList,
636
637    // Headings
638    pub heading: StyleBlock,
639    pub h1: StyleBlock,
640    pub h2: StyleBlock,
641    pub h3: StyleBlock,
642    pub h4: StyleBlock,
643    pub h5: StyleBlock,
644    pub h6: StyleBlock,
645
646    // Inline elements
647    pub text: StylePrimitive,
648    pub strikethrough: StylePrimitive,
649    pub emph: StylePrimitive,
650    pub strong: StylePrimitive,
651    pub horizontal_rule: StylePrimitive,
652
653    // List items
654    pub item: StylePrimitive,
655    pub enumeration: StylePrimitive,
656    pub task: StyleTask,
657
658    // Links and images
659    pub link: StylePrimitive,
660    pub link_text: StylePrimitive,
661    pub image: StylePrimitive,
662    pub image_text: StylePrimitive,
663
664    // Code
665    pub code: StyleBlock,
666    pub code_block: StyleCodeBlock,
667
668    // Tables
669    pub table: StyleTable,
670
671    // Definition lists
672    pub definition_list: StyleBlock,
673    pub definition_term: StylePrimitive,
674    pub definition_description: StylePrimitive,
675
676    // Syntax highlighting configuration (optional feature)
677    #[cfg(feature = "syntax-highlighting")]
678    pub syntax_config: SyntaxThemeConfig,
679}
680
681impl StyleConfig {
682    /// Creates a new empty style config.
683    pub fn new() -> Self {
684        Self::default()
685    }
686
687    /// Gets the style for a heading level.
688    pub fn heading_style(&self, level: HeadingLevel) -> &StyleBlock {
689        match level {
690            HeadingLevel::H1 => &self.h1,
691            HeadingLevel::H2 => &self.h2,
692            HeadingLevel::H3 => &self.h3,
693            HeadingLevel::H4 => &self.h4,
694            HeadingLevel::H5 => &self.h5,
695            HeadingLevel::H6 => &self.h6,
696        }
697    }
698
699    /// Sets the syntax highlighting theme.
700    ///
701    /// This method is only available when the `syntax-highlighting` feature is enabled.
702    ///
703    /// # Example
704    ///
705    /// ```rust,ignore
706    /// let config = StyleConfig::default()
707    ///     .syntax_theme("Solarized (dark)");
708    /// ```
709    #[cfg(feature = "syntax-highlighting")]
710    pub fn syntax_theme(mut self, theme: impl Into<String>) -> Self {
711        self.syntax_config.theme_name = theme.into();
712        self
713    }
714
715    /// Enables or disables line numbers in code blocks.
716    ///
717    /// This method is only available when the `syntax-highlighting` feature is enabled.
718    #[cfg(feature = "syntax-highlighting")]
719    pub fn with_line_numbers(mut self, enabled: bool) -> Self {
720        self.syntax_config.line_numbers = enabled;
721        self
722    }
723
724    /// Adds a custom language alias.
725    ///
726    /// This allows mapping custom identifiers to languages.
727    ///
728    /// This method is only available when the `syntax-highlighting` feature is enabled.
729    #[cfg(feature = "syntax-highlighting")]
730    pub fn language_alias(mut self, alias: impl Into<String>, language: impl Into<String>) -> Self {
731        self.syntax_config
732            .language_aliases
733            .insert(alias.into(), language.into());
734        self
735    }
736
737    /// Adds a validated custom language alias.
738    ///
739    /// Unlike [`language_alias`](Self::language_alias), this validates that the
740    /// target language is recognized and that no alias cycle is created.
741    ///
742    /// This method is only available when the `syntax-highlighting` feature is enabled.
743    ///
744    /// # Errors
745    ///
746    /// Returns an error string if the target language is unrecognized or if
747    /// the alias would create a cycle.
748    #[cfg(feature = "syntax-highlighting")]
749    pub fn try_language_alias(
750        self,
751        alias: impl Into<String>,
752        language: impl Into<String>,
753    ) -> Result<Self, String> {
754        let syntax_config = self.syntax_config.try_language_alias(alias, language)?;
755        Ok(Self {
756            syntax_config,
757            ..self
758        })
759    }
760
761    /// Disables syntax highlighting for a specific language.
762    ///
763    /// Languages in this set will be rendered as plain text.
764    ///
765    /// This method is only available when the `syntax-highlighting` feature is enabled.
766    #[cfg(feature = "syntax-highlighting")]
767    pub fn disable_language(mut self, lang: impl Into<String>) -> Self {
768        self.syntax_config.disabled_languages.insert(lang.into());
769        self
770    }
771
772    /// Sets the full syntax configuration.
773    ///
774    /// This method is only available when the `syntax-highlighting` feature is enabled.
775    #[cfg(feature = "syntax-highlighting")]
776    pub fn with_syntax_config(mut self, config: SyntaxThemeConfig) -> Self {
777        self.syntax_config = config;
778        self
779    }
780
781    /// Gets a reference to the syntax configuration.
782    ///
783    /// This method is only available when the `syntax-highlighting` feature is enabled.
784    #[cfg(feature = "syntax-highlighting")]
785    pub fn syntax(&self) -> &SyntaxThemeConfig {
786        &self.syntax_config
787    }
788}
789
790// ============================================================================
791// Built-in Styles
792// ============================================================================
793
794/// Available built-in styles.
795#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
796pub enum Style {
797    /// ASCII-only style (no special characters).
798    Ascii,
799    /// Dark terminal style (default).
800    #[default]
801    Dark,
802    /// Dracula theme style (purple accents, # heading prefixes).
803    Dracula,
804    /// Light terminal style.
805    Light,
806    /// Pink accent style.
807    Pink,
808    /// Tokyo Night theme style (soft purple/blue).
809    TokyoNight,
810    /// No TTY style (for non-terminal output).
811    NoTty,
812    /// Auto-detect based on terminal.
813    Auto,
814}
815
816impl Style {
817    /// Gets the style configuration for this style.
818    pub fn config(&self) -> StyleConfig {
819        match self {
820            Style::Ascii | Style::NoTty => ascii_style(),
821            Style::Dark | Style::Auto => dark_style(),
822            Style::Dracula => dracula_style(),
823            Style::Light => light_style(),
824            Style::Pink => pink_style(),
825            Style::TokyoNight => tokyo_night_style(),
826        }
827    }
828}
829
830/// Creates the ASCII style configuration.
831pub fn ascii_style() -> StyleConfig {
832    StyleConfig {
833        document: StyleBlock::new()
834            .style(StylePrimitive::new().block_prefix("\n").block_suffix("\n"))
835            .margin(DEFAULT_MARGIN),
836        block_quote: StyleBlock::new().indent(1).indent_prefix("| "),
837        paragraph: StyleBlock::new(),
838        list: StyleList::new().level_indent(DEFAULT_LIST_LEVEL_INDENT),
839        heading: StyleBlock::new().style(StylePrimitive::new().block_suffix("\n")),
840        h1: StyleBlock::new().style(StylePrimitive::new().prefix("# ")),
841        h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
842        h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
843        h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
844        h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
845        h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ")),
846        strikethrough: StylePrimitive::new().block_prefix("~~").block_suffix("~~"),
847        emph: StylePrimitive::new().block_prefix("*").block_suffix("*"),
848        strong: StylePrimitive::new().block_prefix("**").block_suffix("**"),
849        horizontal_rule: StylePrimitive::new().format("\n--------\n"),
850        item: StylePrimitive::new().block_prefix("• "),
851        enumeration: StylePrimitive::new().block_prefix(". "),
852        task: StyleTask::new().ticked("[x] ").unticked("[ ] "),
853        image_text: StylePrimitive::new().format("Image: {{.text}} →"),
854        code: StyleBlock::new(),
855        code_block: StyleCodeBlock::new().block(StyleBlock::new().margin(DEFAULT_MARGIN)),
856        table: StyleTable::new().separators("|", "|", "-"),
857        definition_description: StylePrimitive::new().block_prefix("\n* "),
858        ..Default::default()
859    }
860}
861
862/// Creates the dark style configuration.
863pub fn dark_style() -> StyleConfig {
864    StyleConfig {
865        document: StyleBlock::new()
866            .style(
867                StylePrimitive::new()
868                    .block_prefix("\n")
869                    .block_suffix("\n")
870                    .color("252"),
871            )
872            .margin(DEFAULT_MARGIN),
873        block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
874        paragraph: StyleBlock::new().style(StylePrimitive::new().color("252")),
875        list: StyleList::new().level_indent(DEFAULT_LIST_INDENT),
876        heading: StyleBlock::new().style(
877            StylePrimitive::new()
878                .block_suffix("\n")
879                .color("39")
880                .bold(true),
881        ),
882        h1: StyleBlock::new().style(
883            StylePrimitive::new()
884                .prefix(" ")
885                .suffix(" ")
886                .color("228")
887                .background_color("63")
888                .bold(true),
889        ),
890        h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
891        h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
892        h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
893        h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
894        h6: StyleBlock::new().style(
895            StylePrimitive::new()
896                .prefix("###### ")
897                .color("35")
898                .bold(false),
899        ),
900        strikethrough: StylePrimitive::new().crossed_out(true),
901        emph: StylePrimitive::new().italic(true),
902        strong: StylePrimitive::new().bold(true),
903        horizontal_rule: StylePrimitive::new().color("240").format("\n--------\n"),
904        item: StylePrimitive::new().block_prefix("• "),
905        enumeration: StylePrimitive::new().block_prefix(". "),
906        task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
907        link: StylePrimitive::new().color("30").underline(true),
908        link_text: StylePrimitive::new().color("35").bold(true),
909        image: StylePrimitive::new().color("212").underline(true),
910        image_text: StylePrimitive::new()
911            .color("243")
912            .format("Image: {{.text}} →"),
913        code: StyleBlock::new().style(
914            StylePrimitive::new()
915                .prefix(" ")
916                .suffix(" ")
917                .color("203")
918                .background_color("236"),
919        ),
920        code_block: StyleCodeBlock::new().block(
921            StyleBlock::new()
922                .style(StylePrimitive::new().color("244"))
923                .margin(DEFAULT_MARGIN),
924        ),
925        definition_description: StylePrimitive::new().block_prefix("\n→ "),
926        ..Default::default()
927    }
928}
929
930/// Creates the light style configuration.
931pub fn light_style() -> StyleConfig {
932    StyleConfig {
933        document: StyleBlock::new()
934            .style(
935                StylePrimitive::new()
936                    .block_prefix("\n")
937                    .block_suffix("\n")
938                    .color("234"),
939            )
940            .margin(DEFAULT_MARGIN),
941        block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
942        paragraph: StyleBlock::new().style(StylePrimitive::new().color("234")),
943        list: StyleList::new().level_indent(DEFAULT_LIST_INDENT),
944        heading: StyleBlock::new().style(
945            StylePrimitive::new()
946                .block_suffix("\n")
947                .color("27")
948                .bold(true),
949        ),
950        h1: StyleBlock::new().style(
951            StylePrimitive::new()
952                .prefix(" ")
953                .suffix(" ")
954                .color("228")
955                .background_color("63")
956                .bold(true),
957        ),
958        h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
959        h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
960        h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
961        h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
962        h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ").bold(false)),
963        strikethrough: StylePrimitive::new().crossed_out(true),
964        emph: StylePrimitive::new().italic(true),
965        strong: StylePrimitive::new().bold(true),
966        horizontal_rule: StylePrimitive::new().color("249").format("\n--------\n"),
967        item: StylePrimitive::new().block_prefix("• "),
968        enumeration: StylePrimitive::new().block_prefix(". "),
969        task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
970        link: StylePrimitive::new().color("36").underline(true),
971        link_text: StylePrimitive::new().color("29").bold(true),
972        image: StylePrimitive::new().color("205").underline(true),
973        image_text: StylePrimitive::new()
974            .color("243")
975            .format("Image: {{.text}} →"),
976        code: StyleBlock::new().style(
977            StylePrimitive::new()
978                .prefix(" ")
979                .suffix(" ")
980                .color("203")
981                .background_color("254"),
982        ),
983        code_block: StyleCodeBlock::new().block(
984            StyleBlock::new()
985                .style(StylePrimitive::new().color("242"))
986                .margin(DEFAULT_MARGIN),
987        ),
988        definition_description: StylePrimitive::new().block_prefix("\n→ "),
989        ..Default::default()
990    }
991}
992
993/// Creates the pink style configuration.
994pub fn pink_style() -> StyleConfig {
995    StyleConfig {
996        document: StyleBlock::new().margin(DEFAULT_MARGIN),
997        block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
998        list: StyleList::new().level_indent(DEFAULT_LIST_INDENT),
999        heading: StyleBlock::new().style(
1000            StylePrimitive::new()
1001                .block_suffix("\n")
1002                .color("212")
1003                .bold(true),
1004        ),
1005        h1: StyleBlock::new().style(StylePrimitive::new().block_prefix("\n").block_suffix("\n")),
1006        h2: StyleBlock::new().style(StylePrimitive::new().prefix("▌ ")),
1007        h3: StyleBlock::new().style(StylePrimitive::new().prefix("┃ ")),
1008        h4: StyleBlock::new().style(StylePrimitive::new().prefix("│ ")),
1009        h5: StyleBlock::new().style(StylePrimitive::new().prefix("┆ ")),
1010        h6: StyleBlock::new().style(StylePrimitive::new().prefix("┊ ").bold(false)),
1011        strikethrough: StylePrimitive::new().crossed_out(true),
1012        emph: StylePrimitive::new().italic(true),
1013        strong: StylePrimitive::new().bold(true),
1014        horizontal_rule: StylePrimitive::new().color("212").format("\n──────\n"),
1015        item: StylePrimitive::new().block_prefix("• "),
1016        enumeration: StylePrimitive::new().block_prefix(". "),
1017        task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
1018        link: StylePrimitive::new().color("99").underline(true),
1019        link_text: StylePrimitive::new().bold(true),
1020        image: StylePrimitive::new().underline(true),
1021        image_text: StylePrimitive::new().format("Image: {{.text}}"),
1022        code: StyleBlock::new().style(
1023            StylePrimitive::new()
1024                .prefix(" ")
1025                .suffix(" ")
1026                .color("212")
1027                .background_color("236"),
1028        ),
1029        definition_description: StylePrimitive::new().block_prefix("\n→ "),
1030        ..Default::default()
1031    }
1032}
1033
1034/// Creates the Dracula style configuration.
1035///
1036/// Dracula theme colors:
1037/// - Text: #f8f8f2 (light gray)
1038/// - Heading: #bd93f9 (purple)
1039/// - Bold: #ffb86c (orange)
1040/// - Italic: #f1fa8c (yellow-green)
1041/// - Code: #50fa7b (green)
1042/// - Link: #8be9fd (cyan)
1043pub fn dracula_style() -> StyleConfig {
1044    StyleConfig {
1045        document: StyleBlock::new()
1046            .style(
1047                StylePrimitive::new()
1048                    .block_prefix("\n")
1049                    .block_suffix("\n")
1050                    .color("#f8f8f2"),
1051            )
1052            .margin(DEFAULT_MARGIN),
1053        block_quote: StyleBlock::new()
1054            .style(StylePrimitive::new().color("#f1fa8c").italic(true))
1055            .indent(DEFAULT_MARGIN),
1056        list: StyleList::new()
1057            .block(StyleBlock::new().style(StylePrimitive::new().color("#f8f8f2")))
1058            .level_indent(DEFAULT_MARGIN),
1059        heading: StyleBlock::new().style(
1060            StylePrimitive::new()
1061                .block_suffix("\n")
1062                .color("#bd93f9")
1063                .bold(true),
1064        ),
1065        // Dracula uses # prefix for h1 (matching Go behavior)
1066        h1: StyleBlock::new().style(StylePrimitive::new().prefix("# ")),
1067        h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
1068        h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
1069        h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
1070        h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
1071        h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ")),
1072        strikethrough: StylePrimitive::new().crossed_out(true),
1073        emph: StylePrimitive::new().italic(true).color("#f1fa8c"),
1074        strong: StylePrimitive::new().bold(true).color("#ffb86c"),
1075        horizontal_rule: StylePrimitive::new()
1076            .color("#6272A4")
1077            .format("\n--------\n"),
1078        item: StylePrimitive::new().block_prefix("• "),
1079        enumeration: StylePrimitive::new().block_prefix(". ").color("#8be9fd"),
1080        task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
1081        link: StylePrimitive::new().color("#8be9fd").underline(true),
1082        link_text: StylePrimitive::new().color("#ff79c6"),
1083        image: StylePrimitive::new().color("#8be9fd").underline(true),
1084        image_text: StylePrimitive::new()
1085            .color("#ff79c6")
1086            .format("Image: {{.text}} →"),
1087        code: StyleBlock::new().style(StylePrimitive::new().color("#50fa7b")),
1088        code_block: StyleCodeBlock::new().block(
1089            StyleBlock::new()
1090                .style(StylePrimitive::new().color("#ffb86c"))
1091                .margin(DEFAULT_MARGIN),
1092        ),
1093        definition_description: StylePrimitive::new().block_prefix("\n🠶 "),
1094        ..Default::default()
1095    }
1096}
1097
1098/// Creates the Tokyo Night style configuration.
1099///
1100/// Tokyo Night theme colors:
1101/// - Text: #a9b1d6 (soft gray-blue)
1102/// - Heading: #bb9af7 (soft purple)
1103/// - Code: #9ece6a (green)
1104/// - Link: #7aa2f7 (blue)
1105pub fn tokyo_night_style() -> StyleConfig {
1106    StyleConfig {
1107        document: StyleBlock::new()
1108            .style(
1109                StylePrimitive::new()
1110                    .block_prefix("\n")
1111                    .block_suffix("\n")
1112                    .color("#a9b1d6"),
1113            )
1114            .margin(DEFAULT_MARGIN),
1115        block_quote: StyleBlock::new().indent(1).indent_prefix("│ "),
1116        list: StyleList::new()
1117            .block(StyleBlock::new().style(StylePrimitive::new().color("#a9b1d6")))
1118            .level_indent(DEFAULT_LIST_INDENT),
1119        heading: StyleBlock::new().style(
1120            StylePrimitive::new()
1121                .block_suffix("\n")
1122                .color("#bb9af7")
1123                .bold(true),
1124        ),
1125        h1: StyleBlock::new().style(StylePrimitive::new().prefix("# ").bold(true)),
1126        h2: StyleBlock::new().style(StylePrimitive::new().prefix("## ")),
1127        h3: StyleBlock::new().style(StylePrimitive::new().prefix("### ")),
1128        h4: StyleBlock::new().style(StylePrimitive::new().prefix("#### ")),
1129        h5: StyleBlock::new().style(StylePrimitive::new().prefix("##### ")),
1130        h6: StyleBlock::new().style(StylePrimitive::new().prefix("###### ")),
1131        strikethrough: StylePrimitive::new().crossed_out(true),
1132        emph: StylePrimitive::new().italic(true),
1133        strong: StylePrimitive::new().bold(true),
1134        horizontal_rule: StylePrimitive::new()
1135            .color("#565f89")
1136            .format("\n--------\n"),
1137        item: StylePrimitive::new().block_prefix("• "),
1138        enumeration: StylePrimitive::new().block_prefix(". ").color("#7aa2f7"),
1139        task: StyleTask::new().ticked("[✓] ").unticked("[ ] "),
1140        link: StylePrimitive::new().color("#7aa2f7").underline(true),
1141        link_text: StylePrimitive::new().color("#2ac3de"),
1142        image: StylePrimitive::new().color("#7aa2f7").underline(true),
1143        image_text: StylePrimitive::new()
1144            .color("#2ac3de")
1145            .format("Image: {{.text}} →"),
1146        code: StyleBlock::new().style(StylePrimitive::new().color("#9ece6a")),
1147        code_block: StyleCodeBlock::new().block(
1148            StyleBlock::new()
1149                .style(StylePrimitive::new().color("#ff9e64"))
1150                .margin(DEFAULT_MARGIN),
1151        ),
1152        definition_description: StylePrimitive::new().block_prefix("\n🠶 "),
1153        ..Default::default()
1154    }
1155}
1156
1157// ============================================================================
1158// Renderer
1159// ============================================================================
1160
1161/// Options for the markdown renderer (Go API: `AnsiOptions`).
1162///
1163/// This struct is also exported as `RendererOptions` for backwards compatibility.
1164#[derive(Debug, Clone)]
1165pub struct AnsiOptions {
1166    /// Word wrap width.
1167    pub word_wrap: usize,
1168    /// Base URL for resolving relative links.
1169    pub base_url: Option<String>,
1170    /// Whether to preserve newlines.
1171    pub preserve_newlines: bool,
1172    /// Style configuration.
1173    pub styles: StyleConfig,
1174}
1175
1176/// Backwards-compatible type alias for [`AnsiOptions`].
1177pub type RendererOptions = AnsiOptions;
1178
1179impl Default for AnsiOptions {
1180    fn default() -> Self {
1181        Self {
1182            word_wrap: DEFAULT_WIDTH,
1183            base_url: None,
1184            preserve_newlines: false,
1185            styles: dark_style(),
1186        }
1187    }
1188}
1189
1190/// Markdown renderer for terminal output (Go API: `TermRenderer`).
1191///
1192/// This struct is also exported as `Renderer` for backwards compatibility.
1193///
1194/// The `TermRenderer` name matches the Go `glamour` library's API for
1195/// rendering markdown to ANSI-styled terminal output.
1196#[derive(Debug, Clone)]
1197pub struct TermRenderer {
1198    options: AnsiOptions,
1199}
1200
1201/// Backwards-compatible type alias for [`TermRenderer`].
1202pub type Renderer = TermRenderer;
1203
1204impl Default for TermRenderer {
1205    fn default() -> Self {
1206        Self::new()
1207    }
1208}
1209
1210impl TermRenderer {
1211    /// Creates a new renderer with default settings.
1212    pub fn new() -> Self {
1213        Self {
1214            options: AnsiOptions::default(),
1215        }
1216    }
1217
1218    /// Sets the style for rendering.
1219    pub fn with_style(mut self, style: Style) -> Self {
1220        self.options.styles = style.config();
1221        self
1222    }
1223
1224    /// Sets a custom style configuration.
1225    pub fn with_style_config(mut self, config: StyleConfig) -> Self {
1226        self.options.styles = config;
1227        self
1228    }
1229
1230    /// Sets the word wrap width.
1231    pub fn with_word_wrap(mut self, width: usize) -> Self {
1232        self.options.word_wrap = width;
1233        self
1234    }
1235
1236    /// Sets the base URL for resolving relative links.
1237    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
1238        self.options.base_url = Some(url.into());
1239        self
1240    }
1241
1242    /// Sets whether to preserve newlines.
1243    pub fn with_preserved_newlines(mut self, preserve: bool) -> Self {
1244        self.options.preserve_newlines = preserve;
1245        self
1246    }
1247
1248    /// Renders markdown to styled terminal output.
1249    pub fn render(&self, markdown: &str) -> String {
1250        let mut ctx = RenderContext::new(&self.options);
1251        ctx.render(markdown)
1252    }
1253
1254    /// Renders markdown bytes to styled terminal output.
1255    pub fn render_bytes(&self, markdown: &[u8]) -> Result<String, std::str::Utf8Error> {
1256        let text = std::str::from_utf8(markdown)?;
1257        Ok(self.render(text))
1258    }
1259
1260    /// Changes the syntax highlighting theme at runtime.
1261    ///
1262    /// This allows switching themes without creating a new Renderer instance.
1263    ///
1264    /// # Arguments
1265    ///
1266    /// * `theme` - Theme name (e.g., "base16-ocean.dark", "Solarized (dark)")
1267    ///
1268    /// # Returns
1269    ///
1270    /// `Ok(())` if the theme exists and was applied, or an error message if the theme
1271    /// was not found.
1272    ///
1273    /// # Example
1274    ///
1275    /// ```rust,ignore
1276    /// use glamour::Renderer;
1277    ///
1278    /// let mut renderer = Renderer::new();
1279    /// renderer.set_syntax_theme("Solarized (dark)")?;
1280    /// let output = renderer.render("```rust\nfn main() {}\n```");
1281    /// ```
1282    #[cfg(feature = "syntax-highlighting")]
1283    pub fn set_syntax_theme(&mut self, theme: impl Into<String>) -> Result<(), String> {
1284        let theme_name = theme.into();
1285
1286        // Validate the theme exists before setting it
1287        use crate::syntax::SyntaxTheme;
1288        if SyntaxTheme::from_name(&theme_name).is_none() {
1289            let available = SyntaxTheme::available_themes().join(", ");
1290            return Err(format!(
1291                "Unknown syntax theme '{}'. Available themes: {}",
1292                theme_name, available
1293            ));
1294        }
1295
1296        self.options.styles.syntax_config.theme_name = theme_name;
1297        Ok(())
1298    }
1299
1300    /// Enables or disables line numbers in code blocks at runtime.
1301    ///
1302    /// # Example
1303    ///
1304    /// ```rust,ignore
1305    /// use glamour::Renderer;
1306    ///
1307    /// let mut renderer = Renderer::new();
1308    /// renderer.set_line_numbers(true);
1309    /// ```
1310    #[cfg(feature = "syntax-highlighting")]
1311    pub fn set_line_numbers(&mut self, enabled: bool) {
1312        self.options.styles.syntax_config.line_numbers = enabled;
1313    }
1314
1315    /// Returns a reference to the current syntax configuration.
1316    ///
1317    /// This method is only available when the `syntax-highlighting` feature is enabled.
1318    #[cfg(feature = "syntax-highlighting")]
1319    pub fn syntax_config(&self) -> &SyntaxThemeConfig {
1320        &self.options.styles.syntax_config
1321    }
1322
1323    /// Returns a mutable reference to the current syntax configuration.
1324    ///
1325    /// This allows runtime modification of all syntax highlighting settings.
1326    ///
1327    /// # Example
1328    ///
1329    /// ```rust,ignore
1330    /// use glamour::Renderer;
1331    ///
1332    /// let mut renderer = Renderer::new();
1333    /// renderer.syntax_config_mut()
1334    ///     .language_aliases
1335    ///     .insert("rs".to_string(), "rust".to_string());
1336    /// ```
1337    #[cfg(feature = "syntax-highlighting")]
1338    pub fn syntax_config_mut(&mut self) -> &mut SyntaxThemeConfig {
1339        &mut self.options.styles.syntax_config
1340    }
1341}
1342
1343/// Render context that tracks state during rendering.
1344struct RenderContext<'a> {
1345    options: &'a AnsiOptions,
1346    output: String,
1347    // Track element nesting
1348    in_heading: Option<HeadingLevel>,
1349    in_emphasis: bool,
1350    in_strong: bool,
1351    in_strikethrough: bool,
1352    in_link: bool,
1353    in_image: bool,
1354    in_code_block: bool,
1355    block_quote_depth: usize,
1356    block_quote_pending_separator: Option<usize>,
1357    pending_block_quote_decrement: usize,
1358    in_paragraph: bool,
1359    in_list: bool,
1360    ordered_list_stack: Vec<bool>,
1361    list_depth: usize,
1362    list_item_number: Vec<usize>,
1363    in_table: bool,
1364    table_alignments: Vec<pulldown_cmark::Alignment>,
1365    table_row: Vec<String>,
1366    table_rows: Vec<Vec<String>>,
1367    table_header_row: Option<Vec<String>>,
1368    table_header: bool,
1369    current_cell: String,
1370    // Buffering
1371    text_buffer: String,
1372    link_url: String,
1373    link_title: String,
1374    link_is_autolink_email: bool,
1375    image_url: String,
1376    image_title: String,
1377    code_block_language: String,
1378    code_block_content: String,
1379}
1380
1381impl<'a> RenderContext<'a> {
1382    fn new(options: &'a AnsiOptions) -> Self {
1383        Self {
1384            options,
1385            output: String::new(),
1386            in_heading: None,
1387            in_emphasis: false,
1388            in_strong: false,
1389            in_strikethrough: false,
1390            in_link: false,
1391            in_image: false,
1392            in_code_block: false,
1393            block_quote_depth: 0,
1394            block_quote_pending_separator: None,
1395            pending_block_quote_decrement: 0,
1396            in_paragraph: false,
1397            in_list: false,
1398            ordered_list_stack: Vec::new(),
1399            list_depth: 0,
1400            list_item_number: Vec::new(),
1401            in_table: false,
1402            table_alignments: Vec::new(),
1403            table_row: Vec::new(),
1404            table_rows: Vec::new(),
1405            table_header_row: None,
1406            table_header: false,
1407            current_cell: String::new(),
1408            text_buffer: String::new(),
1409            link_url: String::new(),
1410            link_title: String::new(),
1411            link_is_autolink_email: false,
1412            image_url: String::new(),
1413            image_title: String::new(),
1414            code_block_language: String::new(),
1415            code_block_content: String::new(),
1416        }
1417    }
1418
1419    fn render(&mut self, markdown: &str) -> String {
1420        // Enable tables and other extensions
1421        let mut opts = Options::empty();
1422        opts.insert(Options::ENABLE_TABLES);
1423        opts.insert(Options::ENABLE_STRIKETHROUGH);
1424        opts.insert(Options::ENABLE_TASKLISTS);
1425
1426        let parser = Parser::new_ext(markdown, opts);
1427
1428        // Document prefix
1429        self.output
1430            .push_str(&self.options.styles.document.style.block_prefix);
1431
1432        // Add margin
1433        let margin = self.options.styles.document.margin.unwrap_or(0);
1434
1435        for event in parser {
1436            self.handle_event(event);
1437        }
1438
1439        // Document suffix
1440        self.output
1441            .push_str(&self.options.styles.document.style.block_suffix);
1442
1443        // Apply margin
1444        if margin > 0 {
1445            let margin_str = " ".repeat(margin);
1446            self.output = self
1447                .output
1448                .lines()
1449                .map(|line| format!("{}{}", margin_str, line))
1450                .collect::<Vec<_>>()
1451                .join("\n");
1452        }
1453
1454        std::mem::take(&mut self.output)
1455    }
1456
1457    fn handle_event(&mut self, event: Event) {
1458        match event {
1459            // Block elements
1460            Event::Start(Tag::Heading { level, .. }) => {
1461                self.in_heading = Some(level);
1462                self.text_buffer.clear();
1463            }
1464            Event::End(TagEnd::Heading(_level)) => {
1465                self.flush_heading();
1466                self.in_heading = None;
1467            }
1468
1469            Event::Start(Tag::Paragraph) => {
1470                if let Some(depth) = self.block_quote_pending_separator.take()
1471                    && depth > 0
1472                {
1473                    let indent_prefix = self
1474                        .options
1475                        .styles
1476                        .block_quote
1477                        .indent_prefix
1478                        .as_deref()
1479                        .unwrap_or("│ ");
1480                    let prefix = indent_prefix.repeat(depth);
1481                    self.output.push_str(&prefix);
1482                    self.output.push('\n');
1483                }
1484                if !self.in_list {
1485                    self.text_buffer.clear();
1486                }
1487                self.in_paragraph = true;
1488            }
1489            Event::End(TagEnd::Paragraph) => {
1490                if !self.in_list && !self.in_table {
1491                    self.flush_paragraph();
1492                }
1493                self.in_paragraph = false;
1494                if self.pending_block_quote_decrement > 0 {
1495                    self.block_quote_depth = self
1496                        .block_quote_depth
1497                        .saturating_sub(self.pending_block_quote_decrement);
1498                    self.pending_block_quote_decrement = 0;
1499                    if let Some(ref mut sep_depth) = self.block_quote_pending_separator
1500                        && *sep_depth > self.block_quote_depth
1501                    {
1502                        *sep_depth = self.block_quote_depth;
1503                    }
1504                }
1505                if self.block_quote_depth == 0 {
1506                    self.block_quote_pending_separator = None;
1507                }
1508            }
1509
1510            Event::Start(Tag::BlockQuote(_kind)) => {
1511                if self.block_quote_depth == 0 {
1512                    self.output.push('\n');
1513                }
1514                self.block_quote_depth += 1;
1515            }
1516            Event::End(TagEnd::BlockQuote(_)) => {
1517                if self.in_paragraph {
1518                    self.pending_block_quote_decrement += 1;
1519                } else {
1520                    self.block_quote_depth = self.block_quote_depth.saturating_sub(1);
1521                    // Update pending separator to match new depth (prevents stale
1522                    // high depth values from nested blockquotes)
1523                    if let Some(ref mut sep_depth) = self.block_quote_pending_separator
1524                        && *sep_depth > self.block_quote_depth
1525                    {
1526                        *sep_depth = self.block_quote_depth;
1527                    }
1528                    if self.block_quote_depth == 0 {
1529                        self.block_quote_pending_separator = None;
1530                    }
1531                }
1532            }
1533
1534            Event::Start(Tag::CodeBlock(kind)) => {
1535                self.in_code_block = true;
1536                self.code_block_content.clear();
1537                match kind {
1538                    CodeBlockKind::Fenced(lang) => {
1539                        self.code_block_language = lang.to_string();
1540                    }
1541                    CodeBlockKind::Indented => {
1542                        self.code_block_language.clear();
1543                    }
1544                }
1545            }
1546            Event::End(TagEnd::CodeBlock) => {
1547                self.flush_code_block();
1548                self.in_code_block = false;
1549            }
1550
1551            // Lists
1552            Event::Start(Tag::List(first_item)) => {
1553                // If we're starting a nested list inside a list item, flush the parent
1554                // item's text first (before its nested children)
1555                if self.list_depth > 0 && !self.text_buffer.is_empty() {
1556                    self.flush_list_item();
1557                }
1558                self.in_list = true;
1559                self.list_depth += 1;
1560                // Track ordered/unordered state per list level
1561                self.ordered_list_stack.push(first_item.is_some());
1562                self.list_item_number.push(first_item.unwrap_or(1) as usize);
1563                if self.list_depth == 1 {
1564                    self.output.push('\n');
1565                }
1566            }
1567            Event::End(TagEnd::List(_)) => {
1568                self.list_depth = self.list_depth.saturating_sub(1);
1569                self.list_item_number.pop();
1570                self.ordered_list_stack.pop();
1571                if self.list_depth == 0 {
1572                    self.in_list = false;
1573                }
1574            }
1575
1576            Event::Start(Tag::Item) => {
1577                self.text_buffer.clear();
1578            }
1579            Event::End(TagEnd::Item) => {
1580                self.flush_list_item();
1581            }
1582
1583            // Tables
1584            Event::Start(Tag::Table(alignments)) => {
1585                self.in_table = true;
1586                self.table_alignments = alignments;
1587                self.table_rows.clear();
1588                self.table_header_row = None;
1589            }
1590            Event::End(TagEnd::Table) => {
1591                self.flush_table();
1592                self.in_table = false;
1593                self.table_alignments.clear();
1594                self.table_rows.clear();
1595                self.table_header_row = None;
1596            }
1597
1598            Event::Start(Tag::TableHead) => {
1599                self.table_header = true;
1600                self.table_row.clear();
1601            }
1602            Event::End(TagEnd::TableHead) => {
1603                // Store header row for later
1604                self.table_header_row = Some(std::mem::take(&mut self.table_row));
1605                self.table_header = false;
1606            }
1607
1608            Event::Start(Tag::TableRow) => {
1609                self.table_row.clear();
1610            }
1611            Event::End(TagEnd::TableRow) => {
1612                // Store row for later
1613                self.table_rows.push(std::mem::take(&mut self.table_row));
1614            }
1615
1616            Event::Start(Tag::TableCell) => {
1617                self.current_cell.clear();
1618            }
1619            Event::End(TagEnd::TableCell) => {
1620                self.table_row.push(std::mem::take(&mut self.current_cell));
1621            }
1622
1623            // Inline elements
1624            Event::Start(Tag::Emphasis) => {
1625                self.in_emphasis = true;
1626                if self.options.styles.emph.italic == Some(true) && !self.in_table {
1627                    // SGR italic on
1628                    self.text_buffer.push_str("\x1b[3m");
1629                }
1630                if !self.in_table {
1631                    self.text_buffer
1632                        .push_str(&self.options.styles.emph.block_prefix);
1633                } else {
1634                    self.current_cell
1635                        .push_str(&self.options.styles.emph.block_prefix);
1636                }
1637            }
1638            Event::End(TagEnd::Emphasis) => {
1639                self.in_emphasis = false;
1640                if !self.in_table {
1641                    self.text_buffer
1642                        .push_str(&self.options.styles.emph.block_suffix);
1643                    if self.options.styles.emph.italic == Some(true) {
1644                        // SGR italic off
1645                        self.text_buffer.push_str("\x1b[23m");
1646                    }
1647                } else {
1648                    self.current_cell
1649                        .push_str(&self.options.styles.emph.block_suffix);
1650                }
1651            }
1652
1653            Event::Start(Tag::Strong) => {
1654                self.in_strong = true;
1655                if self.options.styles.strong.bold == Some(true) && !self.in_table {
1656                    // SGR bold on
1657                    self.text_buffer.push_str("\x1b[1m");
1658                }
1659                if !self.in_table {
1660                    self.text_buffer
1661                        .push_str(&self.options.styles.strong.block_prefix);
1662                } else {
1663                    self.current_cell
1664                        .push_str(&self.options.styles.strong.block_prefix);
1665                }
1666            }
1667            Event::End(TagEnd::Strong) => {
1668                self.in_strong = false;
1669                if !self.in_table {
1670                    self.text_buffer
1671                        .push_str(&self.options.styles.strong.block_suffix);
1672                    if self.options.styles.strong.bold == Some(true) {
1673                        // SGR bold off (normal intensity)
1674                        self.text_buffer.push_str("\x1b[22m");
1675                    }
1676                } else {
1677                    self.current_cell
1678                        .push_str(&self.options.styles.strong.block_suffix);
1679                }
1680            }
1681
1682            Event::Start(Tag::Strikethrough) => {
1683                self.in_strikethrough = true;
1684                if self.options.styles.strikethrough.crossed_out == Some(true) && !self.in_table {
1685                    // SGR strikethrough on
1686                    self.text_buffer.push_str("\x1b[9m");
1687                }
1688                if !self.in_table {
1689                    self.text_buffer
1690                        .push_str(&self.options.styles.strikethrough.block_prefix);
1691                } else {
1692                    self.current_cell
1693                        .push_str(&self.options.styles.strikethrough.block_prefix);
1694                }
1695            }
1696            Event::End(TagEnd::Strikethrough) => {
1697                self.in_strikethrough = false;
1698                if !self.in_table {
1699                    self.text_buffer
1700                        .push_str(&self.options.styles.strikethrough.block_suffix);
1701                    if self.options.styles.strikethrough.crossed_out == Some(true) {
1702                        // SGR strikethrough off
1703                        self.text_buffer.push_str("\x1b[29m");
1704                    }
1705                } else {
1706                    self.current_cell
1707                        .push_str(&self.options.styles.strikethrough.block_suffix);
1708                }
1709            }
1710
1711            Event::Start(Tag::Link {
1712                link_type,
1713                dest_url,
1714                title,
1715                ..
1716            }) => {
1717                self.in_link = true;
1718                self.link_url = dest_url.to_string();
1719                self.link_title = title.to_string();
1720                self.link_is_autolink_email = matches!(link_type, pulldown_cmark::LinkType::Email);
1721            }
1722            Event::End(TagEnd::Link) => {
1723                // Append URL after link text, like Go glamour does
1724                // But don't duplicate if the link text is already the URL (autolinks)
1725                if self.link_is_autolink_email
1726                    && !self.link_url.is_empty()
1727                    && !self.link_url.starts_with("mailto:")
1728                {
1729                    self.link_url = format!("mailto:{}", self.link_url);
1730                }
1731                if !self.link_url.is_empty() && !self.text_buffer.ends_with(&self.link_url) {
1732                    self.text_buffer.push(' ');
1733                    self.text_buffer.push_str(&self.link_url);
1734                }
1735                self.in_link = false;
1736                self.link_is_autolink_email = false;
1737                self.link_url.clear();
1738                self.link_title.clear();
1739            }
1740
1741            Event::Start(Tag::Image {
1742                dest_url, title, ..
1743            }) => {
1744                self.in_image = true;
1745                self.image_url = dest_url.to_string();
1746                self.image_title = title.to_string();
1747            }
1748            Event::End(TagEnd::Image) => {
1749                self.flush_image();
1750                self.in_image = false;
1751            }
1752
1753            // Text content
1754            Event::Text(text) => {
1755                if self.in_code_block {
1756                    self.code_block_content.push_str(&text);
1757                } else if self.in_table {
1758                    self.current_cell.push_str(&text);
1759                } else if self.in_image {
1760                    // Buffer for image alt text
1761                    self.text_buffer.push_str(&text);
1762                } else {
1763                    self.text_buffer.push_str(&text);
1764                }
1765            }
1766
1767            Event::Code(code) => {
1768                let styled = self.style_inline_code(&code);
1769                if self.in_table {
1770                    self.current_cell.push_str(&styled);
1771                } else {
1772                    self.text_buffer.push_str(&styled);
1773                }
1774            }
1775
1776            Event::SoftBreak => {
1777                if self.options.preserve_newlines {
1778                    if self.in_table {
1779                        self.current_cell.push('\n');
1780                    } else {
1781                        self.text_buffer.push('\n');
1782                    }
1783                } else if self.in_table {
1784                    self.current_cell.push(' ');
1785                } else {
1786                    self.text_buffer.push(' ');
1787                }
1788            }
1789
1790            Event::HardBreak => {
1791                if self.in_table {
1792                    self.current_cell.push('\n');
1793                } else {
1794                    self.text_buffer.push('\n');
1795                }
1796            }
1797
1798            Event::Rule => {
1799                self.output
1800                    .push_str(&self.options.styles.horizontal_rule.format);
1801            }
1802
1803            Event::TaskListMarker(checked) => {
1804                if checked {
1805                    self.text_buffer.push_str(&self.options.styles.task.ticked);
1806                } else {
1807                    self.text_buffer
1808                        .push_str(&self.options.styles.task.unticked);
1809                }
1810            }
1811
1812            // Ignore other events
1813            _ => {}
1814        }
1815    }
1816
1817    fn flush_heading(&mut self) {
1818        if let Some(level) = self.in_heading {
1819            let heading_style = self.options.styles.heading_style(level);
1820            let base_heading = &self.options.styles.heading;
1821
1822            // Build the heading text
1823            let mut heading_text = String::new();
1824            heading_text.push_str(&heading_style.style.prefix);
1825            heading_text.push_str(&self.text_buffer);
1826            heading_text.push_str(&heading_style.style.suffix);
1827
1828            // Apply lipgloss styling
1829            let mut style = base_heading.style.to_lipgloss();
1830
1831            // Merge heading-level specific styles
1832            if let Some(ref color) = heading_style.style.color {
1833                style = style.foreground(color.as_str());
1834            }
1835            if let Some(ref bg) = heading_style.style.background_color {
1836                style = style.background(bg.as_str());
1837            }
1838            if heading_style.style.bold == Some(true) {
1839                style = style.bold();
1840            }
1841            if heading_style.style.italic == Some(true) {
1842                style = style.italic();
1843            }
1844
1845            let rendered = style.render(&heading_text);
1846
1847            self.output.push_str(&heading_style.style.block_prefix);
1848            self.output.push('\n');
1849            self.output.push_str(&rendered);
1850            self.output.push_str(&base_heading.style.block_suffix);
1851
1852            self.text_buffer.clear();
1853        }
1854    }
1855
1856    fn flush_paragraph(&mut self) {
1857        if !self.text_buffer.is_empty() {
1858            let text = std::mem::take(&mut self.text_buffer);
1859
1860            // Apply word wrap
1861            let wrapped = self.word_wrap(&text);
1862
1863            // Apply paragraph styling
1864            let style = self.options.styles.paragraph.style.to_lipgloss();
1865            let rendered = style.render(&wrapped);
1866
1867            // Add block quote indent if needed
1868            if self.block_quote_depth > 0 {
1869                let indent_prefix = self
1870                    .options
1871                    .styles
1872                    .block_quote
1873                    .indent_prefix
1874                    .as_deref()
1875                    .unwrap_or("│ ");
1876                let prefix = indent_prefix.repeat(self.block_quote_depth);
1877                let indented = rendered
1878                    .lines()
1879                    .map(|line| format!("{}{}", prefix, line))
1880                    .collect::<Vec<_>>()
1881                    .join("\n");
1882                self.output.push_str(&indented);
1883                self.output.push('\n');
1884                self.block_quote_pending_separator = Some(self.block_quote_depth);
1885            } else {
1886                self.output.push_str(&rendered);
1887                self.output.push_str("\n\n");
1888            }
1889        }
1890    }
1891
1892    fn flush_list_item(&mut self) {
1893        let mut text = std::mem::take(&mut self.text_buffer);
1894        if text.is_empty() {
1895            return;
1896        }
1897
1898        let mut task_marker: Option<String> = None;
1899        for marker in [
1900            &self.options.styles.task.ticked,
1901            &self.options.styles.task.unticked,
1902        ] {
1903            if text.starts_with(marker) {
1904                task_marker = Some(marker.clone());
1905                text = text[marker.len()..].to_string();
1906                break;
1907            }
1908        }
1909
1910        let indent = (self.list_depth - 1) * self.options.styles.list.level_indent;
1911        let indent_str = " ".repeat(indent);
1912
1913        let is_ordered = self.ordered_list_stack.last().copied().unwrap_or(false);
1914        let mut prefix = if is_ordered {
1915            let num = self.list_item_number.last().copied().unwrap_or(1);
1916            if let Some(last) = self.list_item_number.last_mut() {
1917                *last += 1;
1918            }
1919            format!("{}{}", num, &self.options.styles.enumeration.block_prefix)
1920        } else {
1921            self.options.styles.item.block_prefix.clone()
1922        };
1923        if let Some(marker) = task_marker {
1924            prefix = marker;
1925        }
1926
1927        let line = format!("{}{}{}", indent_str, prefix, text.trim());
1928        let doc_style = self.options.styles.document.style.to_lipgloss();
1929        self.output.push_str(&doc_style.render(&line));
1930        self.output.push('\n');
1931    }
1932
1933    fn flush_code_block(&mut self) {
1934        let content = std::mem::take(&mut self.code_block_content);
1935        let language = std::mem::take(&mut self.code_block_language);
1936        let style = &self.options.styles.code_block;
1937
1938        self.output.push('\n');
1939
1940        // Apply margin
1941        let margin = style.block.margin.unwrap_or(0);
1942        let margin_str = " ".repeat(margin);
1943
1944        // Try syntax highlighting if feature is enabled and language is specified
1945        #[cfg(feature = "syntax-highlighting")]
1946        {
1947            use crate::syntax::{LanguageDetector, SyntaxTheme, highlight_code};
1948
1949            let syntax_config = &self.options.styles.syntax_config;
1950
1951            if !language.is_empty() && !syntax_config.is_disabled(&language) {
1952                // Resolve language through custom aliases
1953                let resolved_lang = syntax_config.resolve_language(&language);
1954
1955                let detector = LanguageDetector::new();
1956                if detector.is_supported(resolved_lang) {
1957                    // Get theme from syntax config, code_block style, or use default
1958                    let theme = SyntaxTheme::from_name(&syntax_config.theme_name)
1959                        .or_else(|| {
1960                            style
1961                                .theme
1962                                .as_ref()
1963                                .and_then(|name| SyntaxTheme::from_name(name))
1964                        })
1965                        .unwrap_or_else(SyntaxTheme::default_dark);
1966
1967                    let highlighted = highlight_code(&content, resolved_lang, &theme);
1968
1969                    // Output with optional line numbers
1970                    for (idx, line) in highlighted.lines().enumerate() {
1971                        self.output.push_str(&margin_str);
1972                        if syntax_config.line_numbers {
1973                            // Format line number with right-aligned padding
1974                            let line_num = idx + 1;
1975                            self.output.push_str(&format!("{:4} │ ", line_num));
1976                        }
1977                        self.output.push_str(line);
1978                        self.output.push('\n');
1979                    }
1980
1981                    self.output.push('\n');
1982                    return;
1983                }
1984            }
1985        }
1986
1987        // Suppress unused variable warning when feature is disabled
1988        let _ = &language;
1989
1990        // Fallback: no syntax highlighting
1991        for line in content.lines() {
1992            self.output.push_str(&margin_str);
1993            self.output.push_str(line);
1994            self.output.push('\n');
1995        }
1996
1997        self.output.push('\n');
1998    }
1999
2000    fn flush_table(&mut self) {
2001        use crate::table::{
2002            ColumnWidthConfig, MINIMAL_ASCII_BORDER, MINIMAL_BORDER, ParsedTable, TableCell,
2003            calculate_column_widths, render_minimal_row, render_minimal_separator,
2004        };
2005
2006        // Collect all rows (header + body) to count columns
2007        let num_cols = self.table_alignments.len();
2008        if num_cols == 0 {
2009            return;
2010        }
2011
2012        let mut parsed_table = ParsedTable::new();
2013        parsed_table.alignments = self.table_alignments.clone();
2014
2015        if let Some(header_strs) = &self.table_header_row {
2016            parsed_table.header = header_strs
2017                .iter()
2018                .enumerate()
2019                .map(|(i, s)| {
2020                    let align = self
2021                        .table_alignments
2022                        .get(i)
2023                        .copied()
2024                        .unwrap_or(pulldown_cmark::Alignment::None);
2025                    TableCell::new(s.clone(), align)
2026                })
2027                .collect();
2028        }
2029
2030        for row_strs in &self.table_rows {
2031            let row_cells = row_strs
2032                .iter()
2033                .enumerate()
2034                .map(|(i, s)| {
2035                    let align = self
2036                        .table_alignments
2037                        .get(i)
2038                        .copied()
2039                        .unwrap_or(pulldown_cmark::Alignment::None);
2040                    TableCell::new(s.clone(), align)
2041                })
2042                .collect();
2043            parsed_table.rows.push(row_cells);
2044        }
2045
2046        if parsed_table.is_empty() {
2047            return;
2048        }
2049
2050        // Determine border style - use minimal borders to match Go glamour
2051        // Go glamour only renders internal separators (no outer borders)
2052        let col_sep = self
2053            .options
2054            .styles
2055            .table
2056            .column_separator
2057            .as_deref()
2058            .unwrap_or("│");
2059        let border = if col_sep == "|" {
2060            MINIMAL_ASCII_BORDER
2061        } else {
2062            MINIMAL_BORDER
2063        };
2064
2065        // Calculate column widths
2066        let margin = self
2067            .options
2068            .styles
2069            .document
2070            .margin
2071            .unwrap_or(DEFAULT_MARGIN);
2072        let max_width = self.options.word_wrap.saturating_sub(2 * margin);
2073        let cell_padding = 1;
2074
2075        // Use border_width=0 for minimal style since we don't have outer borders
2076        let width_config = ColumnWidthConfig::new()
2077            .cell_padding(cell_padding)
2078            .border_width(1) // Internal separators still take 1 char width
2079            .max_table_width(max_width);
2080
2081        let column_widths = calculate_column_widths(&parsed_table, &width_config);
2082        let widths = &column_widths.widths;
2083
2084        // Output a blank styled line first (matching Go behavior)
2085        let doc_style = &self.options.styles.document.style;
2086        let lipgloss = doc_style.to_lipgloss();
2087        // Just a newline with background if set
2088        self.output.push('\n');
2089
2090        // No top border - Go glamour doesn't render outer borders
2091
2092        // Header row (rendered without outer borders)
2093        if !parsed_table.header.is_empty() {
2094            let rendered_header =
2095                render_minimal_row(&parsed_table.header, widths, &border, cell_padding);
2096            self.output.push_str(&lipgloss.render(&rendered_header));
2097            self.output.push('\n');
2098
2099            // Header separator (internal only)
2100            let sep = render_minimal_separator(widths, &border, cell_padding);
2101            if !sep.is_empty() {
2102                self.output.push_str(&lipgloss.render(&sep));
2103                self.output.push('\n');
2104            }
2105        }
2106
2107        // Body rows (rendered without outer borders)
2108        for row in parsed_table.rows.iter() {
2109            let rendered_row = render_minimal_row(row, widths, &border, cell_padding);
2110            self.output.push_str(&lipgloss.render(&rendered_row));
2111            self.output.push('\n');
2112        }
2113
2114        // No bottom border - Go glamour doesn't render outer borders
2115
2116        self.output.push('\n');
2117    }
2118
2119    fn flush_image(&mut self) {
2120        let alt_text = std::mem::take(&mut self.text_buffer);
2121        let url = std::mem::take(&mut self.image_url);
2122
2123        let style = &self.options.styles.image_text;
2124        let format = if style.format.is_empty() {
2125            "Image: {{.text}} →"
2126        } else {
2127            &style.format
2128        };
2129
2130        let text = format.replace("{{.text}}", &alt_text);
2131
2132        let link_style = self.options.styles.image.to_lipgloss();
2133        let rendered_url = link_style.render(&url);
2134
2135        self.output.push_str(&text);
2136        self.output.push(' ');
2137        self.output.push_str(&rendered_url);
2138    }
2139
2140    fn style_inline_code(&self, code: &str) -> String {
2141        let style = &self.options.styles.code;
2142        let lipgloss_style = style.style.to_lipgloss();
2143
2144        // Build the code text with prefix/suffix INSIDE the styled region
2145        // Go glamour includes padding spaces inside the ANSI-styled region
2146        let code_with_padding = format!("{}{}{}", style.style.prefix, code, style.style.suffix);
2147        lipgloss_style.render(&code_with_padding)
2148    }
2149
2150    /// Calculate the visible width of a string (excluding ANSI escapes).
2151    /// Copied from lipgloss to handle ANSI-aware wrapping.
2152    #[allow(dead_code)]
2153    fn visible_width(&self, s: &str) -> usize {
2154        visible_width(s)
2155    }
2156
2157    fn word_wrap(&self, text: &str) -> String {
2158        let width = self.options.word_wrap;
2159        if width == 0 {
2160            return text.to_string();
2161        }
2162
2163        let mut result = String::new();
2164        let mut current_line = String::new();
2165
2166        for word in text.split_whitespace() {
2167            if current_line.is_empty() {
2168                current_line.push_str(word);
2169            } else if visible_width(&current_line) + 1 + visible_width(word) <= width {
2170                current_line.push(' ');
2171                current_line.push_str(word);
2172            } else {
2173                result.push_str(&current_line);
2174                result.push('\n');
2175                current_line = word.to_string();
2176            }
2177        }
2178
2179        if !current_line.is_empty() {
2180            result.push_str(&current_line);
2181        }
2182
2183        result
2184    }
2185}
2186
2187/// Calculate the visible width of a string (excluding ANSI escapes).
2188pub(crate) fn visible_width(s: &str) -> usize {
2189    let mut width = 0;
2190    #[derive(Clone, Copy, PartialEq)]
2191    enum State {
2192        Normal,
2193        Esc,
2194        Csi,
2195        Osc,
2196    }
2197    let mut state = State::Normal;
2198
2199    for c in s.chars() {
2200        match state {
2201            State::Normal => {
2202                if c == '\x1b' {
2203                    state = State::Esc;
2204                } else {
2205                    width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
2206                }
2207            }
2208            State::Esc => {
2209                if c == '[' {
2210                    state = State::Csi;
2211                } else if c == ']' {
2212                    state = State::Osc;
2213                } else {
2214                    // Handle simple escapes like \x1b7 (save cursor) or \x1b> (keypad)
2215                    // They are single char after ESC.
2216                    state = State::Normal;
2217                }
2218            }
2219            State::Csi => {
2220                // CSI sequence: [params] [intermediate] final
2221                // Final byte is 0x40-0x7E (@ to ~)
2222                if ('@'..='~').contains(&c) {
2223                    state = State::Normal;
2224                }
2225            }
2226            State::Osc => {
2227                // OSC sequence: ] [params] ; [text] BEL/ST
2228                // Handle BEL (\x07)
2229                if c == '\x07' {
2230                    state = State::Normal;
2231                } else if c == '\x1b' {
2232                    // Handle ST (ESC \) - we see ESC, transition to Esc to handle the backslash
2233                    state = State::Esc;
2234                }
2235            }
2236        }
2237    }
2238
2239    width
2240}
2241
2242// ============================================================================
2243// Convenience Functions
2244// ============================================================================
2245
2246/// Render markdown with the specified style.
2247pub fn render(markdown: &str, style: Style) -> Result<String, std::convert::Infallible> {
2248    Ok(Renderer::new().with_style(style).render(markdown))
2249}
2250
2251/// Render markdown with the default dark style.
2252pub fn render_with_environment_config(markdown: &str) -> String {
2253    // Check GLAMOUR_STYLE environment variable
2254    let style = std::env::var("GLAMOUR_STYLE")
2255        .ok()
2256        .and_then(|s| match s.as_str() {
2257            "ascii" => Some(Style::Ascii),
2258            "dark" => Some(Style::Dark),
2259            "dracula" => Some(Style::Dracula),
2260            "light" => Some(Style::Light),
2261            "pink" => Some(Style::Pink),
2262            "notty" => Some(Style::NoTty),
2263            "auto" => Some(Style::Auto),
2264            _ => None,
2265        })
2266        .unwrap_or(Style::Auto);
2267
2268    Renderer::new().with_style(style).render(markdown)
2269}
2270
2271/// Available style names for configuration.
2272pub fn available_styles() -> HashMap<&'static str, Style> {
2273    let mut styles = HashMap::new();
2274    styles.insert("ascii", Style::Ascii);
2275    styles.insert("dark", Style::Dark);
2276    styles.insert("dracula", Style::Dracula);
2277    styles.insert("light", Style::Light);
2278    styles.insert("pink", Style::Pink);
2279    styles.insert("notty", Style::NoTty);
2280    styles.insert("auto", Style::Auto);
2281    styles
2282}
2283
2284/// Prelude module for convenient imports.
2285pub mod prelude {
2286    pub use crate::{
2287        AnsiOptions, Renderer, RendererOptions, Style, StyleBlock, StyleCodeBlock, StyleConfig,
2288        StyleList, StylePrimitive, StyleTable, StyleTask, TermRenderer, ascii_style,
2289        available_styles, dark_style, dracula_style, light_style, pink_style, render,
2290        render_with_environment_config,
2291    };
2292}
2293
2294// ============================================================================
2295// Tests
2296// ============================================================================
2297
2298#[cfg(test)]
2299mod tests {
2300    use super::*;
2301
2302    #[test]
2303    fn test_renderer_new() {
2304        let renderer = Renderer::new();
2305        assert_eq!(renderer.options.word_wrap, DEFAULT_WIDTH);
2306    }
2307
2308    #[test]
2309    fn test_renderer_with_word_wrap() {
2310        let renderer = Renderer::new().with_word_wrap(120);
2311        assert_eq!(renderer.options.word_wrap, 120);
2312    }
2313
2314    #[test]
2315    fn test_renderer_with_style() {
2316        let renderer = Renderer::new().with_style(Style::Light);
2317        // Light style has different document color
2318        assert!(renderer.options.styles.document.style.color.is_some());
2319    }
2320
2321    #[test]
2322    fn test_render_simple_text() {
2323        let renderer = Renderer::new().with_style(Style::Ascii);
2324        let output = renderer.render("Hello, world!");
2325        assert!(output.contains("Hello, world!"));
2326    }
2327
2328    #[test]
2329    fn test_render_heading() {
2330        let renderer = Renderer::new().with_style(Style::Ascii);
2331        let output = renderer.render("# Heading");
2332        assert!(output.contains("# Heading"));
2333    }
2334
2335    #[test]
2336    fn test_render_emphasis() {
2337        let renderer = Renderer::new().with_style(Style::Ascii);
2338        let output = renderer.render("*italic*");
2339        assert!(output.contains("*italic*"));
2340    }
2341
2342    #[test]
2343    fn test_render_strong() {
2344        let renderer = Renderer::new().with_style(Style::Ascii);
2345        let output = renderer.render("**bold**");
2346        assert!(output.contains("**bold**"));
2347    }
2348
2349    #[test]
2350    fn test_render_code() {
2351        let renderer = Renderer::new().with_style(Style::Ascii);
2352        let output = renderer.render("`code`");
2353        // ASCII style renders inline code as plain text without backticks
2354        assert!(output.contains("code"));
2355        assert!(!output.contains("`"));
2356    }
2357
2358    #[test]
2359    fn test_render_horizontal_rule() {
2360        let renderer = Renderer::new().with_style(Style::Ascii);
2361        let output = renderer.render("---");
2362        assert!(output.contains("--------"));
2363    }
2364
2365    #[test]
2366    fn test_render_list() {
2367        let renderer = Renderer::new().with_style(Style::Ascii);
2368        let output = renderer.render("* item 1\n* item 2");
2369        assert!(output.contains("item 1"));
2370        assert!(output.contains("item 2"));
2371    }
2372
2373    #[test]
2374    fn test_render_nested_list() {
2375        let renderer = Renderer::new().with_style(Style::Dark);
2376        let output = renderer.render("- Item 1\n  - Nested 1\n  - Nested 2\n- Item 2");
2377        assert!(output.contains("Item 1"));
2378        assert!(output.contains("Nested 1"));
2379        assert!(output.contains("Nested 2"));
2380        assert!(output.contains("Item 2"));
2381    }
2382
2383    #[test]
2384    fn test_render_mixed_nested_list() {
2385        let renderer = Renderer::new().with_style(Style::Dark);
2386        let output = renderer.render("1. First\n   - Sub item\n   - Sub item\n2. Second");
2387        assert!(output.contains("First"));
2388        assert!(output.contains("Sub item"));
2389        assert!(output.contains("Second"));
2390        // Verify the second item is rendered as ordered (with number)
2391        assert!(output.contains("2."));
2392    }
2393
2394    #[test]
2395    fn test_render_link() {
2396        let renderer = Renderer::new().with_style(Style::Dark);
2397        let output = renderer.render("[Link text](https://example.com)");
2398        assert!(output.contains("Link text"));
2399        // URL should be appended after link text
2400        assert!(output.contains("https://example.com"));
2401    }
2402
2403    #[test]
2404    fn test_render_autolink() {
2405        let renderer = Renderer::new().with_style(Style::Dark);
2406        let output = renderer.render("<https://example.com>");
2407        // For autolinks, URL should appear only once (not duplicated)
2408        let url_count = output.matches("https://example.com").count();
2409        assert_eq!(url_count, 1, "Autolink URL should appear exactly once");
2410    }
2411
2412    #[test]
2413    fn test_render_autolink_email() {
2414        let renderer = Renderer::new().with_style(Style::Dark);
2415        let output = renderer.render("<user@example.com>");
2416        assert!(output.contains("user@example.com"));
2417        assert!(output.contains("mailto:user@example.com"));
2418        let mailto_count = output.matches("mailto:user@example.com").count();
2419        assert_eq!(mailto_count, 1, "Email autolink should include mailto once");
2420    }
2421
2422    #[test]
2423    fn test_render_ordered_list() {
2424        let renderer = Renderer::new().with_style(Style::Ascii);
2425        let output = renderer.render("1. first\n2. second");
2426        assert!(output.contains("first"));
2427        assert!(output.contains("second"));
2428    }
2429
2430    #[test]
2431    fn test_render_table() {
2432        let renderer = Renderer::new().with_style(Style::Ascii);
2433        let output = renderer.render("| A | B |\n|---|---|\n| 1 | 2 |");
2434        assert!(output.contains("|"));
2435        assert!(output.contains("A"));
2436        assert!(output.contains("B"));
2437    }
2438
2439    #[test]
2440    fn test_render_table_dark_debug() {
2441        let renderer = Renderer::new().with_style(Style::Dark);
2442        let output = renderer.render("| A | B |\n|---|---|\n| 1 | 2 |");
2443
2444        // Print each line with visible markers
2445        eprintln!("=== RUST TABLE OUTPUT (2x2, dark) ===");
2446        for (i, line) in output.lines().enumerate() {
2447            eprintln!("Line {}: len={} chars", i, line.chars().count());
2448            // Print escaped version
2449            let escaped: String = line
2450                .chars()
2451                .map(|c| {
2452                    if c == '\x1b' {
2453                        "\\x1b".to_string()
2454                    } else if c == '│' {
2455                        "│".to_string()
2456                    } else if c == '─' {
2457                        "─".to_string()
2458                    } else if c == '┼' {
2459                        "┼".to_string()
2460                    } else {
2461                        c.to_string()
2462                    }
2463                })
2464                .collect();
2465            eprintln!("  {:?}", escaped);
2466        }
2467        eprintln!("=== END OUTPUT ===");
2468
2469        // Verify basic structure
2470        assert!(
2471            output.contains("│") || output.contains("|"),
2472            "Should contain column separator"
2473        );
2474        assert!(output.contains("A"), "Should contain header A");
2475    }
2476
2477    #[test]
2478    fn test_style_primitive_builder() {
2479        let style = StylePrimitive::new()
2480            .color("red")
2481            .bold(true)
2482            .prefix("> ")
2483            .suffix(" <");
2484
2485        assert_eq!(style.color, Some("red".to_string()));
2486        assert_eq!(style.bold, Some(true));
2487        assert_eq!(style.prefix, "> ");
2488        assert_eq!(style.suffix, " <");
2489    }
2490
2491    #[test]
2492    fn test_style_block_builder() {
2493        let block = StyleBlock::new().margin(4).indent(2).indent_prefix("  ");
2494
2495        assert_eq!(block.margin, Some(4));
2496        assert_eq!(block.indent, Some(2));
2497        assert_eq!(block.indent_prefix, Some("  ".to_string()));
2498    }
2499
2500    #[test]
2501    fn test_style_config_heading() {
2502        let config = dark_style();
2503        let h1 = config.heading_style(HeadingLevel::H1);
2504        assert!(
2505            !h1.style.prefix.is_empty() || h1.style.suffix.len() > 0 || h1.style.color.is_some()
2506        );
2507    }
2508
2509    #[test]
2510    fn test_available_styles() {
2511        let styles = available_styles();
2512        assert!(styles.contains_key("dark"));
2513        assert!(styles.contains_key("light"));
2514        assert!(styles.contains_key("ascii"));
2515        assert!(styles.contains_key("pink"));
2516    }
2517
2518    #[test]
2519    fn test_render_function() {
2520        let result = render("# Test", Style::Ascii);
2521        assert!(result.is_ok());
2522        assert!(result.unwrap().contains("Test"));
2523    }
2524
2525    #[test]
2526    fn test_dark_style() {
2527        let config = dark_style();
2528        assert!(config.heading.style.bold == Some(true));
2529        assert!(config.document.margin.is_some());
2530    }
2531
2532    #[test]
2533    fn test_light_style() {
2534        let config = light_style();
2535        assert!(config.heading.style.bold == Some(true));
2536    }
2537
2538    #[test]
2539    fn test_ascii_style() {
2540        let config = ascii_style();
2541        assert_eq!(config.h1.style.prefix, "# ");
2542    }
2543
2544    #[test]
2545    fn test_ascii_style_inline_code_and_lists() {
2546        let renderer = Renderer::new().with_style(Style::Ascii);
2547        let output = renderer.render("A `code` example.\n\n- Item 1\n- Item 2");
2548        assert!(output.contains("code"));
2549        assert!(!output.contains("`code`"));
2550        assert!(output.contains("• Item 1"));
2551        assert!(output.contains("• Item 2"));
2552    }
2553
2554    #[test]
2555    fn test_pink_style() {
2556        let config = pink_style();
2557        assert!(config.heading.style.color.is_some());
2558    }
2559
2560    #[test]
2561    fn test_dracula_style() {
2562        let config = dracula_style();
2563        // Dracula uses # prefix for h1 headings (matching Go behavior)
2564        assert_eq!(config.h1.style.prefix, "# ");
2565        assert_eq!(config.h2.style.prefix, "## ");
2566        assert_eq!(config.h3.style.prefix, "### ");
2567        // Heading should be bold and purple
2568        assert!(config.heading.style.bold == Some(true));
2569        assert!(config.heading.style.color.is_some());
2570        // Dracula uses specific colors
2571        assert_eq!(config.heading.style.color.as_deref(), Some("#bd93f9")); // purple
2572        assert_eq!(config.strong.color.as_deref(), Some("#ffb86c")); // orange bold
2573        assert_eq!(config.emph.color.as_deref(), Some("#f1fa8c")); // yellow-green italic
2574    }
2575
2576    #[test]
2577    fn test_dracula_heading_output() {
2578        let renderer = Renderer::new().with_style(Style::Dracula);
2579        let output = renderer.render("# Heading");
2580        // Verify the heading has # prefix
2581        assert!(output.contains("# "), "Dracula h1 should have '# ' prefix");
2582        assert!(output.contains("Heading"));
2583    }
2584
2585    #[test]
2586    fn test_word_wrap() {
2587        let renderer = Renderer::new().with_word_wrap(20);
2588        let output = renderer.render("This is a very long line that should be wrapped.");
2589        // The output should contain newlines due to wrapping
2590        assert!(output.len() > 0);
2591    }
2592
2593    #[test]
2594    fn test_render_code_block() {
2595        let renderer = Renderer::new().with_style(Style::Ascii);
2596        let output = renderer.render("```rust\nfn main() {}\n```");
2597        // With syntax highlighting, tokens may be split by ANSI codes
2598        // So check for individual tokens instead of the full string
2599        assert!(output.contains("fn"));
2600        assert!(output.contains("main"));
2601    }
2602
2603    #[test]
2604    fn test_render_blockquote() {
2605        let renderer = Renderer::new().with_style(Style::Dark);
2606        let output = renderer.render("> quoted text");
2607        assert!(output.contains("quoted"));
2608    }
2609
2610    #[test]
2611    fn test_strikethrough() {
2612        let renderer = Renderer::new().with_style(Style::Ascii);
2613        let output = renderer.render("~~deleted~~");
2614        assert!(output.contains("~~"));
2615        assert!(output.contains("deleted"));
2616    }
2617
2618    #[test]
2619    fn test_task_list() {
2620        let renderer = Renderer::new().with_style(Style::Ascii);
2621        let output = renderer.render("- [ ] todo\n- [x] done");
2622        assert!(output.contains("[ ] todo"));
2623        assert!(output.contains("[x] done"));
2624        assert!(!output.contains("* [ ]"));
2625    }
2626
2627    // ========================================================================
2628    // Syntax Theme Config Tests (feature-gated)
2629    // ========================================================================
2630
2631    #[cfg(feature = "syntax-highlighting")]
2632    mod syntax_config_tests {
2633        use super::*;
2634
2635        #[test]
2636        fn test_syntax_theme_config_default() {
2637            let config = SyntaxThemeConfig::default();
2638            assert_eq!(config.theme_name, "base16-ocean.dark");
2639            assert!(!config.line_numbers);
2640            assert!(config.language_aliases.is_empty());
2641            assert!(config.disabled_languages.is_empty());
2642        }
2643
2644        #[test]
2645        fn test_syntax_theme_config_builder() {
2646            let config = SyntaxThemeConfig::new()
2647                .theme("Solarized (dark)")
2648                .line_numbers(true)
2649                .language_alias("dockerfile", "docker")
2650                .disable_language("text");
2651
2652            assert_eq!(config.theme_name, "Solarized (dark)");
2653            assert!(config.line_numbers);
2654            assert_eq!(
2655                config.language_aliases.get("dockerfile"),
2656                Some(&"docker".to_string())
2657            );
2658            assert!(config.disabled_languages.contains("text"));
2659        }
2660
2661        #[test]
2662        fn test_syntax_theme_config_resolve_language() {
2663            let config = SyntaxThemeConfig::new()
2664                .language_alias("rs", "rust")
2665                .language_alias("dockerfile", "docker");
2666
2667            assert_eq!(config.resolve_language("rs"), "rust");
2668            assert_eq!(config.resolve_language("dockerfile"), "docker");
2669            assert_eq!(config.resolve_language("python"), "python"); // No alias
2670        }
2671
2672        #[test]
2673        fn test_syntax_theme_config_is_disabled() {
2674            let config = SyntaxThemeConfig::new()
2675                .disable_language("text")
2676                .disable_language("plain");
2677
2678            assert!(config.is_disabled("text"));
2679            assert!(config.is_disabled("plain"));
2680            assert!(!config.is_disabled("rust"));
2681        }
2682
2683        #[test]
2684        fn test_syntax_theme_config_validate() {
2685            let valid = SyntaxThemeConfig::new().theme("base16-ocean.dark");
2686            assert!(valid.validate().is_ok());
2687
2688            let invalid = SyntaxThemeConfig::new().theme("nonexistent-theme");
2689            assert!(invalid.validate().is_err());
2690            let err = invalid.validate().unwrap_err();
2691            assert!(err.contains("Unknown syntax theme"));
2692            assert!(err.contains("nonexistent-theme"));
2693        }
2694
2695        #[test]
2696        fn test_style_config_syntax_methods() {
2697            let config = StyleConfig::default()
2698                .syntax_theme("Solarized (dark)")
2699                .with_line_numbers(true)
2700                .language_alias("rs", "rust")
2701                .disable_language("text");
2702
2703            assert_eq!(config.syntax().theme_name, "Solarized (dark)");
2704            assert!(config.syntax().line_numbers);
2705            assert_eq!(
2706                config.syntax().language_aliases.get("rs"),
2707                Some(&"rust".to_string())
2708            );
2709            assert!(config.syntax().disabled_languages.contains("text"));
2710        }
2711
2712        #[test]
2713        fn test_style_config_with_syntax_config() {
2714            let syntax_config = SyntaxThemeConfig::new()
2715                .theme("InspiredGitHub")
2716                .line_numbers(true);
2717
2718            let style_config = StyleConfig::default().with_syntax_config(syntax_config);
2719
2720            assert_eq!(style_config.syntax().theme_name, "InspiredGitHub");
2721            assert!(style_config.syntax().line_numbers);
2722        }
2723
2724        #[test]
2725        fn test_render_with_line_numbers() {
2726            let config = StyleConfig::default().with_line_numbers(true);
2727            let renderer = Renderer::new().with_style_config(config);
2728
2729            let output = renderer.render("```rust\nfn main() {\n    println!(\"Hello\");\n}\n```");
2730
2731            // Should contain line numbers
2732            assert!(output.contains("1 │"));
2733            assert!(output.contains("2 │"));
2734            assert!(output.contains("3 │"));
2735        }
2736
2737        #[test]
2738        fn test_render_with_disabled_language() {
2739            let config = StyleConfig::default().disable_language("rust");
2740            let renderer = Renderer::new().with_style_config(config);
2741
2742            let output = renderer.render("```rust\nfn main() {}\n```");
2743
2744            // Should NOT have ANSI codes since rust is disabled
2745            // The output should just have the plain text
2746            assert!(output.contains("fn main()"));
2747        }
2748
2749        #[test]
2750        fn test_render_with_language_alias() {
2751            let config = StyleConfig::default().language_alias("rs", "rust");
2752            let renderer = Renderer::new().with_style_config(config);
2753
2754            let output = renderer.render("```rs\nfn main() {}\n```");
2755
2756            // Should be highlighted as Rust (contains ANSI codes)
2757            assert!(output.contains("fn"));
2758            assert!(output.contains("main"));
2759            assert!(output.contains('\x1b'));
2760        }
2761
2762        #[test]
2763        fn test_runtime_theme_switching() {
2764            let mut renderer = Renderer::new();
2765
2766            // Default theme
2767            let original_theme = renderer.syntax_config().theme_name.clone();
2768            assert_eq!(original_theme, "base16-ocean.dark");
2769
2770            // Switch to a different theme
2771            renderer.set_syntax_theme("Solarized (dark)").unwrap();
2772            assert_eq!(renderer.syntax_config().theme_name, "Solarized (dark)");
2773
2774            // Render with new theme
2775            let output = renderer.render("```rust\nfn main() {}\n```");
2776            assert!(output.contains('\x1b')); // Should have ANSI codes
2777        }
2778
2779        #[test]
2780        fn test_runtime_theme_switching_invalid_theme() {
2781            let mut renderer = Renderer::new();
2782
2783            let result = renderer.set_syntax_theme("nonexistent-theme-xyz");
2784            assert!(result.is_err());
2785
2786            let err = result.unwrap_err();
2787            assert!(err.contains("Unknown syntax theme"));
2788            assert!(err.contains("nonexistent-theme-xyz"));
2789            assert!(err.contains("Available themes"));
2790
2791            // Theme should not have changed
2792            assert_eq!(renderer.syntax_config().theme_name, "base16-ocean.dark");
2793        }
2794
2795        #[test]
2796        fn test_runtime_line_numbers_toggle() {
2797            let mut renderer = Renderer::new();
2798
2799            // Default should be off
2800            assert!(!renderer.syntax_config().line_numbers);
2801
2802            // Enable line numbers
2803            renderer.set_line_numbers(true);
2804            assert!(renderer.syntax_config().line_numbers);
2805
2806            let output = renderer.render("```rust\nfn main() {}\n```");
2807            assert!(output.contains("1 │"));
2808
2809            // Disable line numbers
2810            renderer.set_line_numbers(false);
2811            assert!(!renderer.syntax_config().line_numbers);
2812        }
2813
2814        #[test]
2815        fn test_syntax_config_mut() {
2816            let mut renderer = Renderer::new();
2817
2818            // Modify config through mutable reference
2819            renderer
2820                .syntax_config_mut()
2821                .language_aliases
2822                .insert("myrs".to_string(), "rust".to_string());
2823
2824            let config = renderer.syntax_config();
2825            assert_eq!(
2826                config.language_aliases.get("myrs"),
2827                Some(&"rust".to_string())
2828            );
2829        }
2830
2831        // ====================================================================
2832        // Language alias validation (bd-1ywx)
2833        // ====================================================================
2834
2835        #[test]
2836        fn try_alias_valid_language_succeeds() {
2837            let config = SyntaxThemeConfig::new()
2838                .try_language_alias("rs", "rust")
2839                .unwrap();
2840            assert_eq!(config.resolve_language("rs"), "rust");
2841        }
2842
2843        #[test]
2844        fn try_alias_invalid_language_fails() {
2845            let result = SyntaxThemeConfig::new().try_language_alias("foo", "nonexistent-lang-xyz");
2846            assert!(result.is_err());
2847            let err = result.unwrap_err();
2848            assert!(err.contains("Unknown target language"));
2849            assert!(err.contains("nonexistent-lang-xyz"));
2850        }
2851
2852        #[test]
2853        fn try_alias_direct_cycle_detected() {
2854            // py3 -> python, then python -> py3 would create a cycle.
2855            // Both "python" and "py3" are recognized by the built-in detector.
2856            let config = SyntaxThemeConfig::new()
2857                .try_language_alias("py3", "python")
2858                .unwrap();
2859            let result = config.try_language_alias("python", "py3");
2860            assert!(result.is_err());
2861            let err = result.unwrap_err();
2862            assert!(err.contains("cycle"));
2863        }
2864
2865        #[test]
2866        fn try_alias_indirect_cycle_detected() {
2867            // py3 -> python, python -> rs, then rs -> py3 creates a cycle.
2868            // All targets are recognized by the built-in detector.
2869            let config = SyntaxThemeConfig::new()
2870                .try_language_alias("py3", "python")
2871                .unwrap()
2872                .try_language_alias("python", "rs")
2873                .unwrap();
2874            let result = config.try_language_alias("rs", "py3");
2875            assert!(result.is_err());
2876            let err = result.unwrap_err();
2877            assert!(err.contains("cycle"));
2878        }
2879
2880        #[test]
2881        fn try_alias_no_false_cycle_for_chain() {
2882            // a -> rust, b -> rust is fine (no cycle, just shared target)
2883            let config = SyntaxThemeConfig::new()
2884                .try_language_alias("a", "rust")
2885                .unwrap()
2886                .try_language_alias("b", "rust")
2887                .unwrap();
2888            assert_eq!(config.resolve_language("a"), "rust");
2889            assert_eq!(config.resolve_language("b"), "rust");
2890        }
2891
2892        #[test]
2893        fn try_alias_overwrite_existing_alias() {
2894            // Overwriting an alias is fine as long as the new target is valid
2895            let config = SyntaxThemeConfig::new()
2896                .try_language_alias("rs", "rust")
2897                .unwrap()
2898                .try_language_alias("rs", "python")
2899                .unwrap();
2900            assert_eq!(config.resolve_language("rs"), "python");
2901        }
2902
2903        #[test]
2904        fn try_alias_via_style_config_valid() {
2905            let config = StyleConfig::default()
2906                .try_language_alias("rs", "rust")
2907                .unwrap();
2908            assert_eq!(
2909                config.syntax().language_aliases.get("rs"),
2910                Some(&"rust".to_string())
2911            );
2912        }
2913
2914        #[test]
2915        fn try_alias_via_style_config_invalid() {
2916            let result = StyleConfig::default().try_language_alias("foo", "nonexistent-lang-xyz");
2917            assert!(result.is_err());
2918        }
2919
2920        #[test]
2921        fn validate_catches_bad_alias_target() {
2922            let mut config = SyntaxThemeConfig::new();
2923            // Bypass validation by inserting directly
2924            config
2925                .language_aliases
2926                .insert("foo".into(), "nonexistent-lang".into());
2927            let result = config.validate();
2928            assert!(result.is_err());
2929            let err = result.unwrap_err();
2930            assert!(err.contains("unrecognized language"));
2931            assert!(err.contains("nonexistent-lang"));
2932        }
2933
2934        #[test]
2935        fn validate_catches_alias_cycle() {
2936            let mut config = SyntaxThemeConfig::new();
2937            // Bypass try_language_alias by inserting cycle directly.
2938            // Use real language names so the target validation passes but
2939            // the cycle check catches the loop.
2940            config.language_aliases.insert("python".into(), "rs".into());
2941            config.language_aliases.insert("rs".into(), "python".into());
2942            let result = config.validate();
2943            assert!(result.is_err());
2944            let err = result.unwrap_err();
2945            assert!(err.contains("cycle"));
2946        }
2947
2948        #[test]
2949        fn validate_accepts_good_config() {
2950            let config = SyntaxThemeConfig::new()
2951                .try_language_alias("rs", "rust")
2952                .unwrap()
2953                .try_language_alias("py3", "python")
2954                .unwrap();
2955            assert!(config.validate().is_ok());
2956        }
2957
2958        #[test]
2959        fn unchecked_alias_still_works() {
2960            // The original language_alias() should still work without validation
2961            let config = SyntaxThemeConfig::new().language_alias("foo", "nonexistent");
2962            assert_eq!(config.resolve_language("foo"), "nonexistent");
2963        }
2964
2965        #[test]
2966        fn self_alias_is_cycle() {
2967            let result = SyntaxThemeConfig::new().try_language_alias("rust", "rust");
2968            assert!(result.is_err());
2969            let err = result.unwrap_err();
2970            assert!(err.contains("cycle"));
2971        }
2972    }
2973}
2974
2975// ============================================================================
2976// E2E Syntax Highlighting Tests
2977// ============================================================================
2978
2979#[cfg(test)]
2980#[cfg(feature = "syntax-highlighting")]
2981mod e2e_highlighting_tests {
2982    use super::*;
2983
2984    // ========================================================================
2985    // Full Document Rendering Tests
2986    // ========================================================================
2987
2988    #[test]
2989    fn test_document_with_mixed_code_blocks() {
2990        let markdown = r#"
2991# My Document
2992
2993Here's some Rust:
2994
2995```rust
2996fn main() {
2997    println!("Hello");
2998}
2999```
3000
3001And some Python:
3002
3003```python
3004def main():
3005    print("Hello")
3006```
3007
3008And some JSON:
3009
3010```json
3011{"key": "value"}
3012```
3013"#;
3014
3015        let renderer = Renderer::new().with_style(Style::Dark);
3016        let output = renderer.render(markdown);
3017
3018        // All code blocks should be highlighted (have ANSI codes)
3019        assert!(output.contains("\x1b["), "Should have color codes");
3020
3021        // All content should be present (check tokens separately as ANSI codes may split them)
3022        assert!(output.contains("fn"), "Should contain Rust fn keyword");
3023        assert!(output.contains("main"), "Should contain main function");
3024        assert!(output.contains("def"), "Should contain Python def keyword");
3025        assert!(output.contains("key"), "Should contain JSON key");
3026    }
3027
3028    #[test]
3029    fn test_document_with_inline_code_not_syntax_highlighted() {
3030        let renderer = Renderer::new().with_style(Style::Dark);
3031        let markdown = "Here is `inline code` in a sentence.";
3032        let output = renderer.render(markdown);
3033
3034        // Inline code should be styled (with background) but NOT syntax highlighted
3035        assert!(
3036            output.contains("inline code"),
3037            "Should contain inline code text"
3038        );
3039        // Inline code uses lipgloss styling, not syntect highlighting
3040    }
3041
3042    #[test]
3043    fn test_real_readme_rendering() {
3044        // Use a small synthetic README since we can't use include_str! on project README
3045        let readme = r#"
3046# My Project
3047
3048A library for doing things.
3049
3050## Installation
3051
3052```bash
3053cargo add my-project
3054```
3055
3056## Usage
3057
3058```rust
3059use my_project::do_thing;
3060
3061fn main() {
3062    do_thing();
3063}
3064```
3065
3066## Features
3067
3068- Feature 1
3069- Feature 2
3070- Feature 3
3071
3072| Column A | Column B |
3073|----------|----------|
3074| Value 1  | Value 2  |
3075
3076## License
3077
3078MIT
3079"#;
3080
3081        let config = StyleConfig::default().syntax_theme("base16-ocean.dark");
3082        let renderer = Renderer::new().with_style_config(config);
3083
3084        // Should not panic
3085        let output = renderer.render(readme);
3086
3087        // Should produce substantial output
3088        assert!(
3089            output.len() > readme.len() / 2,
3090            "Output should be substantial, got {} chars from {} input chars",
3091            output.len(),
3092            readme.len()
3093        );
3094
3095        // Should contain key content (check tokens separately as ANSI codes may split them)
3096        assert!(output.contains("My Project"), "Should contain title");
3097        assert!(output.contains("cargo"), "Should contain cargo command");
3098        assert!(output.contains("do_thing"), "Should contain Rust code");
3099    }
3100
3101    // ========================================================================
3102    // Theme Consistency Tests
3103    // ========================================================================
3104
3105    #[test]
3106    fn test_theme_consistency_across_blocks() {
3107        let markdown = r#"
3108```rust
3109fn a() {}
3110```
3111
3112Some text in between.
3113
3114```rust
3115fn b() {}
3116```
3117"#;
3118
3119        let renderer = Renderer::new().with_style(Style::Dark);
3120        let output = renderer.render(markdown);
3121
3122        // Both `fn` keywords should have the same color
3123        let fn_indices: Vec<_> = output.match_indices("fn").collect();
3124        assert!(
3125            fn_indices.len() >= 2,
3126            "Should have at least 2 'fn' keywords, found {}",
3127            fn_indices.len()
3128        );
3129
3130        // Extract the ANSI escape sequence before each `fn`
3131        let get_escape_before = |idx: usize| -> Option<&str> {
3132            let prefix = &output[..idx];
3133            // Find the last escape sequence before the keyword
3134            if let Some(esc_start) = prefix.rfind("\x1b[") {
3135                // Find the 'm' that ends the escape sequence
3136                let search_area = &prefix[esc_start..];
3137                if let Some(m_pos) = search_area.find('m') {
3138                    return Some(&prefix[esc_start..esc_start + m_pos + 1]);
3139                }
3140            }
3141            None
3142        };
3143
3144        let color1 = get_escape_before(fn_indices[0].0);
3145        let color2 = get_escape_before(fn_indices[1].0);
3146
3147        assert_eq!(
3148            color1, color2,
3149            "Same tokens should have same colors: {:?} vs {:?}",
3150            color1, color2
3151        );
3152    }
3153
3154    // ========================================================================
3155    // Error Resilience Tests
3156    // ========================================================================
3157
3158    #[test]
3159    fn test_malformed_language_tag() {
3160        // Language tag with extra whitespace/content
3161        let markdown = "```rust with extra stuff\nfn main() {}\n```";
3162
3163        let renderer = Renderer::new().with_style(Style::Dark);
3164        // Should not panic
3165        let output = renderer.render(markdown);
3166
3167        // Content should still be rendered (even if not highlighted)
3168        assert!(
3169            output.contains("fn main"),
3170            "Should contain code content even with malformed tag"
3171        );
3172    }
3173
3174    #[test]
3175    fn test_very_long_code_block() {
3176        let code = "let x = 1;\n".repeat(1000); // 1000 lines
3177        let markdown = format!("```rust\n{}```", code);
3178
3179        // Should complete without timeout or crash
3180        let start = std::time::Instant::now();
3181        let renderer = Renderer::new().with_style(Style::Dark);
3182        let output = renderer.render(&markdown);
3183        let duration = start.elapsed();
3184
3185        assert!(
3186            duration.as_secs() < 5,
3187            "Should complete in <5s, took {:?}",
3188            duration
3189        );
3190        // Check tokens separately as ANSI codes may split them
3191        assert!(output.contains("let"), "Should contain let keyword");
3192        assert!(output.contains("x"), "Should contain variable x");
3193    }
3194
3195    #[test]
3196    fn test_code_block_with_unicode() {
3197        let markdown = r#"
3198```rust
3199fn main() {
3200    let emoji = "🦀";
3201    let chinese = "你好";
3202    let japanese = "こんにちは";
3203    let arabic = "مرحبا";
3204}
3205```
3206"#;
3207
3208        let renderer = Renderer::new().with_style(Style::Dark);
3209        let output = renderer.render(markdown);
3210
3211        assert!(output.contains("🦀"), "Should preserve crab emoji");
3212        assert!(
3213            output.contains("你好"),
3214            "Should preserve Chinese characters"
3215        );
3216        assert!(
3217            output.contains("こんにちは"),
3218            "Should preserve Japanese characters"
3219        );
3220        assert!(
3221            output.contains("مرحبا"),
3222            "Should preserve Arabic characters"
3223        );
3224    }
3225
3226    #[test]
3227    fn test_empty_code_block() {
3228        let markdown = "```rust\n```";
3229
3230        let renderer = Renderer::new().with_style(Style::Dark);
3231        // Should not panic on empty code block
3232        let output = renderer.render(markdown);
3233
3234        // Output should exist (may just be whitespace/margins)
3235        assert!(output.len() > 0, "Should produce some output");
3236    }
3237
3238    #[test]
3239    fn test_code_block_with_only_whitespace() {
3240        let markdown = "```rust\n   \n\t\n   \n```";
3241
3242        let renderer = Renderer::new().with_style(Style::Dark);
3243        // Should not panic
3244        let output = renderer.render(markdown);
3245
3246        // Should handle gracefully
3247        assert!(output.len() > 0, "Should produce some output");
3248    }
3249
3250    #[test]
3251    fn test_unknown_language_graceful_fallback() {
3252        let markdown = "```notareallanguage123\nsome code here\n```";
3253
3254        let renderer = Renderer::new().with_style(Style::Dark);
3255        // Should not panic, should render as plain text
3256        let output = renderer.render(markdown);
3257
3258        assert!(
3259            output.contains("some code here"),
3260            "Should render unknown language code as plain text"
3261        );
3262    }
3263
3264    #[test]
3265    fn test_special_characters_in_code() {
3266        let markdown = r#"
3267```rust
3268fn main() {
3269    let s = "<script>alert('xss')</script>";
3270    let regex = r"[a-z]+\d*";
3271    let backslash = "\\";
3272    let null_byte = "\0";
3273}
3274```
3275"#;
3276
3277        let renderer = Renderer::new().with_style(Style::Dark);
3278        // Should not panic or produce invalid output
3279        let output = renderer.render(markdown);
3280
3281        assert!(
3282            output.contains("script"),
3283            "Should handle HTML-like content in code"
3284        );
3285        assert!(output.contains("regex"), "Should handle regex patterns");
3286    }
3287
3288    // ========================================================================
3289    // Multiple Theme Tests
3290    // ========================================================================
3291
3292    #[test]
3293    fn test_different_themes_produce_different_output() {
3294        let markdown = "```rust\nfn main() {}\n```";
3295
3296        let theme1 = StyleConfig::default().syntax_theme("base16-ocean.dark");
3297        let theme2 = StyleConfig::default().syntax_theme("Solarized (dark)");
3298
3299        let renderer1 = Renderer::new().with_style_config(theme1);
3300        let renderer2 = Renderer::new().with_style_config(theme2);
3301
3302        let output1 = renderer1.render(markdown);
3303        let output2 = renderer2.render(markdown);
3304
3305        // Different themes should produce different ANSI escape sequences
3306        assert_ne!(
3307            output1, output2,
3308            "Different themes should produce different output"
3309        );
3310
3311        // But both should contain the code
3312        assert!(output1.contains("fn"), "Theme 1 should contain code");
3313        assert!(output2.contains("fn"), "Theme 2 should contain code");
3314    }
3315
3316    #[test]
3317    fn test_all_available_themes_render_without_panic() {
3318        use crate::syntax::SyntaxTheme;
3319
3320        let markdown = "```rust\nfn main() { println!(\"hello\"); }\n```";
3321
3322        for theme_name in SyntaxTheme::available_themes() {
3323            let config = StyleConfig::default().syntax_theme(theme_name);
3324            let renderer = Renderer::new().with_style_config(config);
3325
3326            // Should not panic for any theme
3327            let output = renderer.render(markdown);
3328            assert!(
3329                output.contains("fn"),
3330                "Theme '{}' should render code content",
3331                theme_name
3332            );
3333        }
3334    }
3335
3336    // ========================================================================
3337    // Line Numbers Tests
3338    // ========================================================================
3339
3340    #[test]
3341    fn test_line_numbers_correct_count() {
3342        let markdown = "```rust\nline1\nline2\nline3\nline4\nline5\n```";
3343
3344        let config = StyleConfig::default().with_line_numbers(true);
3345        let renderer = Renderer::new().with_style_config(config);
3346        let output = renderer.render(markdown);
3347
3348        // Should have line numbers 1 through 5
3349        assert!(output.contains("1 │"), "Should have line 1");
3350        assert!(output.contains("2 │"), "Should have line 2");
3351        assert!(output.contains("3 │"), "Should have line 3");
3352        assert!(output.contains("4 │"), "Should have line 4");
3353        assert!(output.contains("5 │"), "Should have line 5");
3354    }
3355
3356    #[test]
3357    fn test_line_numbers_disabled_by_default() {
3358        let markdown = "```rust\nfn main() {}\n```";
3359
3360        let renderer = Renderer::new().with_style(Style::Dark);
3361        let output = renderer.render(markdown);
3362
3363        // Should NOT have line number markers
3364        assert!(
3365            !output.contains("1 │"),
3366            "Line numbers should be disabled by default"
3367        );
3368    }
3369
3370    // ========================================================================
3371    // Language Alias Tests
3372    // ========================================================================
3373
3374    #[test]
3375    fn test_custom_language_alias_applied() {
3376        let markdown = "```myrust\nfn main() {}\n```";
3377
3378        let config = StyleConfig::default().language_alias("myrust", "rust");
3379        let renderer = Renderer::new().with_style_config(config);
3380        let output = renderer.render(markdown);
3381
3382        // Should be highlighted as Rust (contains ANSI escape codes)
3383        assert!(
3384            output.contains('\x1b'),
3385            "Custom alias 'myrust' should be highlighted as Rust"
3386        );
3387    }
3388
3389    // ========================================================================
3390    // Performance Tests
3391    // ========================================================================
3392
3393    #[test]
3394    fn test_many_small_code_blocks_performance() {
3395        // Document with many small code blocks
3396        let mut markdown = String::new();
3397        for i in 0..100 {
3398            markdown.push_str(&format!("\n```rust\nfn func_{}() {{ }}\n```\n", i));
3399        }
3400
3401        let start = std::time::Instant::now();
3402        let renderer = Renderer::new().with_style(Style::Dark);
3403        let output = renderer.render(&markdown);
3404        let duration = start.elapsed();
3405
3406        assert!(
3407            duration.as_secs() < 5,
3408            "100 code blocks should render in <5s, took {:?}",
3409            duration
3410        );
3411        assert!(output.contains("func_0"), "Should contain first function");
3412        assert!(output.contains("func_99"), "Should contain last function");
3413    }
3414
3415    // ========================================================================
3416    // Integration with Other Markdown Elements
3417    // ========================================================================
3418
3419    #[test]
3420    fn test_code_blocks_with_surrounding_elements() {
3421        let markdown = r#"
3422# Header
3423
3424Some **bold** and *italic* text.
3425
3426> A blockquote with `inline code`.
3427
3428```rust
3429fn main() {}
3430```
3431
3432| Table | Header |
3433|-------|--------|
3434| cell  | cell   |
3435
34361. List item 1
34372. List item 2
3438
3439```python
3440def hello():
3441    pass
3442```
3443
3444---
3445
3446The end.
3447"#;
3448
3449        let renderer = Renderer::new().with_style(Style::Dark);
3450        // Should handle all elements without issues
3451        let output = renderer.render(markdown);
3452
3453        // Verify key elements are present (check tokens separately as ANSI codes may split them)
3454        assert!(output.contains("Header"), "Should contain heading");
3455        assert!(output.contains("fn"), "Should contain Rust fn keyword");
3456        assert!(output.contains("def"), "Should contain Python def keyword");
3457        assert!(output.contains("Table"), "Should contain table");
3458        assert!(output.contains("List item"), "Should contain list");
3459    }
3460}
3461
3462#[cfg(test)]
3463mod table_spacing_tests {
3464    use super::*;
3465
3466    #[test]
3467    fn test_table_spacing_matches_go() {
3468        let renderer = Renderer::new().with_style(Style::Dark);
3469        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
3470        let output = renderer.render(md);
3471
3472        // Print each line for debugging
3473        for (i, line) in output.lines().enumerate() {
3474            eprintln!("Line {}: {:?}", i, line);
3475        }
3476
3477        let lines: Vec<&str> = output.lines().collect();
3478        // Minimal border structure (matching Go glamour):
3479        // Line 0: "" (empty prefix)
3480        // Line 1: "" (blank line before table)
3481        // Line 2: Header row (A │ B) - no outer borders
3482        // Line 3: Separator (─┼─)
3483        // Line 4: Data row (1 │ 2) - no outer borders
3484        // Line 5: "" (blank line after table)
3485
3486        assert!(
3487            lines.len() >= 4,
3488            "Expected at least 4 lines for minimal table"
3489        );
3490
3491        // Find the header row (contains A and B with internal separator)
3492        let header_line = lines
3493            .iter()
3494            .find(|l| l.contains('A') && l.contains('B'))
3495            .expect("Should have header row with A and B");
3496        assert!(
3497            header_line.contains('│'),
3498            "Should have internal column separator"
3499        );
3500
3501        // Find the separator line (contains ─ and ┼)
3502        let sep_line = lines
3503            .iter()
3504            .find(|l| l.contains('─') && l.contains('┼'))
3505            .expect("Should have header separator");
3506        assert!(sep_line.contains('┼'), "Should have cross junction");
3507
3508        // Verify there are NO outer borders (no ╭, ╰, ├, ┤)
3509        for line in &lines {
3510            assert!(!line.contains('╭'), "Should NOT have top-left corner");
3511            assert!(!line.contains('╰'), "Should NOT have bottom-left corner");
3512        }
3513    }
3514
3515    #[test]
3516    fn test_table_respects_word_wrap() {
3517        let markdown = "| A | B |\n|---|---|\n| 1 | 2 |";
3518
3519        // Render with 40 width
3520        let renderer_small = Renderer::new().with_word_wrap(40).with_style(Style::Ascii);
3521        let output_small = renderer_small.render(markdown);
3522
3523        // Render with 120 width
3524        let renderer_large = Renderer::new().with_word_wrap(120).with_style(Style::Ascii);
3525        let output_large = renderer_large.render(markdown);
3526
3527        // With minimal borders (matching Go glamour), we don't have top/bottom borders.
3528        // Instead, find the header separator line (contains - and |)
3529        let small_sep = output_small
3530            .lines()
3531            .find(|l| l.contains('─') && l.contains('|'))
3532            .expect("Could not find header separator in small output");
3533
3534        let large_sep = output_large
3535            .lines()
3536            .find(|l| l.contains('─') && l.contains('|'))
3537            .expect("Could not find header separator in large output");
3538
3539        // With equal column distribution and max width constraint,
3540        // our calculate_column_widths logic calculates width based on CONTENT.
3541        // It only *shrinks* if it exceeds max_width. It doesn't *expand* to fill max_width.
3542        //
3543        // So for small content that fits in both widths, table width should be the same
3544        // (content-sized, not expanded to fill available space).
3545
3546        let width_small = small_sep.chars().count();
3547        let width_large = large_sep.chars().count();
3548
3549        assert!(width_small <= 40, "Small table should fit in 40 chars");
3550        assert_eq!(
3551            width_small, width_large,
3552            "Table should be compact (content-sized) when it fits"
3553        );
3554    }
3555
3556    #[test]
3557    fn test_image_link_arrow_glyph() {
3558        // Verify image links use Unicode arrow (→) matching Go behavior
3559        let renderer = Renderer::new().with_style(Style::Dark);
3560        let output = renderer.render("![Alt text](https://example.com/image.png)");
3561        assert!(
3562            output.contains("→"),
3563            "Image link should use Unicode arrow (→), got: {}",
3564            output
3565        );
3566        assert!(output.contains("Image: Alt text"));
3567        assert!(output.contains("https://example.com/image.png"));
3568    }
3569
3570    #[test]
3571    fn test_image_link_arrow_in_all_styles() {
3572        // All styles with arrows should use → (Unicode arrow)
3573        for style in [Style::Dark, Style::Light, Style::Dracula] {
3574            let renderer = Renderer::new().with_style(style);
3575            let output = renderer.render("![Test](http://example.com/test.png)");
3576            assert!(
3577                output.contains("→"),
3578                "{:?} style should use Unicode arrow (→)",
3579                style
3580            );
3581        }
3582    }
3583}