Skip to main content

gilt/
markdown.rs

1//! Markdown rendering module -- parses CommonMark and produces styled terminal output.
2//!
3//! (a CommonMark-compliant markdown parser) instead of Python's `markdown_it`.
4
5use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
6
7#[cfg(not(feature = "syntax"))]
8use crate::box_chars::HEAVY;
9use crate::box_chars::SIMPLE;
10use crate::console::{Console, ConsoleOptions, Renderable};
11#[cfg(not(feature = "syntax"))]
12use crate::panel::Panel;
13use crate::rule::Rule;
14use crate::segment::Segment;
15use crate::style::{Style, StyleStack};
16use crate::table::Table;
17use crate::text::{JustifyMethod, Text};
18
19// ---------------------------------------------------------------------------
20// Markdown struct
21// ---------------------------------------------------------------------------
22
23/// Renders Markdown-formatted text to styled terminal output.
24///
25/// Supports headings, paragraphs, lists, code blocks, emphasis, links,
26/// block quotes, horizontal rules, and tables.
27#[derive(Debug, Clone)]
28pub struct Markdown {
29    /// Raw markdown source text.
30    pub markup: String,
31    /// Theme for syntax-highlighted code blocks (reserved for future use).
32    pub code_theme: String,
33    /// Lexer for inline code (reserved for future use).
34    pub inline_code_lexer: Option<String>,
35    /// Theme for inline code (reserved for future use).
36    pub inline_code_theme: Option<String>,
37    /// Whether to display hyperlink URLs after link text.
38    pub hyperlinks: bool,
39    /// Text justification method.
40    pub justify: Option<JustifyMethod>,
41}
42
43impl Markdown {
44    /// Create a new `Markdown` renderer from raw markdown text.
45    pub fn new(markup: &str) -> Self {
46        Markdown {
47            markup: markup.to_string(),
48            code_theme: "monokai".to_string(),
49            inline_code_lexer: None,
50            inline_code_theme: None,
51            hyperlinks: true,
52            justify: None,
53        }
54    }
55
56    /// Set the code theme (builder pattern).
57    #[must_use]
58    pub fn with_code_theme(mut self, theme: &str) -> Self {
59        self.code_theme = theme.to_string();
60        self
61    }
62
63    /// Set whether hyperlink URLs are shown (builder pattern).
64    #[must_use]
65    pub fn with_hyperlinks(mut self, hyperlinks: bool) -> Self {
66        self.hyperlinks = hyperlinks;
67        self
68    }
69
70    /// Set the text justification (builder pattern).
71    #[must_use]
72    pub fn with_justify(mut self, justify: JustifyMethod) -> Self {
73        self.justify = Some(justify);
74        self
75    }
76}
77
78// ---------------------------------------------------------------------------
79// List context tracking
80// ---------------------------------------------------------------------------
81
82/// Tracks whether we are inside an ordered or unordered list, and the
83/// current item number for ordered lists.
84#[derive(Debug, Clone)]
85struct ListContext {
86    ordered: bool,
87    item_number: u64,
88}
89
90// ---------------------------------------------------------------------------
91// Table building context
92// ---------------------------------------------------------------------------
93
94/// Accumulates table data during parsing.
95#[derive(Debug, Clone)]
96struct TableContext {
97    alignments: Vec<Alignment>,
98    /// Plain-text column headers (used for `Table::new` which takes `&[&str]`).
99    header_cells: Vec<String>,
100    /// Current row's cells as styled `Text` objects (preserves inline styles).
101    current_row: Vec<Text>,
102    /// Completed data rows as styled `Text` objects.
103    rows: Vec<Vec<Text>>,
104    in_head: bool,
105}
106
107impl TableContext {
108    fn new() -> Self {
109        TableContext {
110            alignments: Vec::new(),
111            header_cells: Vec::new(),
112            current_row: Vec::new(),
113            rows: Vec::new(),
114            in_head: false,
115        }
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Renderable implementation
121// ---------------------------------------------------------------------------
122
123impl Renderable for Markdown {
124    fn gilt_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
125        let mut segments: Vec<Segment> = Vec::new();
126        let width = options.max_width;
127
128        // Style stack for nested inline styles
129        let base_style = Style::null();
130        let mut style_stack = StyleStack::new(base_style);
131
132        // Current text buffer for inline content
133        let mut text_buffer = Text::new("", Style::null());
134
135        // List stack for nested lists
136        let mut list_stack: Vec<ListContext> = Vec::new();
137
138        // Block quote nesting depth
139        let mut blockquote_depth: usize = 0;
140
141        // Link URL tracking
142        let mut link_url: Option<String> = None;
143
144        // Code block accumulator and language tag
145        let mut code_block_text: Option<String> = None;
146        let mut code_block_lang: Option<String> = None;
147
148        // Table context
149        let mut table_ctx: Option<TableContext> = None;
150        let mut in_table_cell = false;
151        // Accumulates styled inline content for the current table cell.
152        let mut cell_text = Text::new("", Style::null());
153
154        // Track if we need a newline before the next block element
155        let mut needs_newline = false;
156
157        // Enable all pulldown-cmark extensions
158        let mut md_options = Options::empty();
159        md_options.insert(Options::ENABLE_TABLES);
160        md_options.insert(Options::ENABLE_STRIKETHROUGH);
161        md_options.insert(Options::ENABLE_TASKLISTS);
162
163        // P3 perf: iterate the parser directly (no lookahead needed)
164        let parser = Parser::new_ext(&self.markup, md_options);
165
166        for event in parser {
167            match event {
168                // -- Headings -----------------------------------------------
169                Event::Start(Tag::Heading { .. }) => {
170                    text_buffer = Text::new("", Style::null());
171                }
172                Event::End(TagEnd::Heading(level)) => {
173                    let style_name = match level {
174                        HeadingLevel::H1 => "markdown.h1",
175                        HeadingLevel::H2 => "markdown.h2",
176                        HeadingLevel::H3 => "markdown.h3",
177                        HeadingLevel::H4 => "markdown.h4",
178                        HeadingLevel::H5 => "markdown.h5",
179                        HeadingLevel::H6 => "markdown.h6",
180                    };
181                    let heading_style = console
182                        .get_style(style_name)
183                        .unwrap_or_else(|_| Style::null());
184
185                    if needs_newline {
186                        segments.push(Segment::line());
187                    }
188
189                    // Apply heading style to the entire text
190                    let text_len = text_buffer.len();
191                    if text_len > 0 {
192                        text_buffer.stylize(heading_style.clone(), 0, Some(text_len));
193                    }
194                    text_buffer.end = String::new();
195
196                    // Render heading text
197                    let heading_opts =
198                        options.update_width(width.saturating_sub(blockquote_depth * 4));
199                    let heading_segs = text_buffer.gilt_console(console, &heading_opts);
200                    segments.extend(heading_segs);
201                    segments.push(Segment::line());
202
203                    // Add underline rule for h1 and h2
204                    if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) {
205                        let rule_style = console
206                            .get_style("markdown.hr")
207                            .unwrap_or_else(|_| Style::null());
208                        let rule = Rule::new().with_style(rule_style).with_end("");
209                        let rule_segs = rule.gilt_console(console, options);
210                        segments.extend(rule_segs);
211                        segments.push(Segment::line());
212                    }
213
214                    needs_newline = true;
215                    text_buffer = Text::new("", Style::null());
216                }
217
218                // -- Paragraphs ---------------------------------------------
219                Event::Start(Tag::Paragraph) => {
220                    text_buffer = Text::new("", Style::null());
221                    if let Some(j) = self.justify {
222                        text_buffer.justify = Some(j);
223                    }
224                    // P2 parity: push paragraph style on entry
225                    let para_style = console
226                        .get_style("markdown.paragraph")
227                        .unwrap_or_else(|_| Style::null());
228                    style_stack.push(para_style);
229                }
230                Event::End(TagEnd::Paragraph) => {
231                    // P2 parity: pop paragraph style
232                    let _ = style_stack.pop();
233
234                    if in_table_cell {
235                        // Inside a table cell, preserve spans from text_buffer
236                        // (using append_text, not plain(), to retain styling).
237                        cell_text.append_text(&text_buffer);
238                        text_buffer = Text::new("", Style::null());
239                        continue;
240                    }
241
242                    if needs_newline {
243                        segments.push(Segment::line());
244                    }
245
246                    // Apply blockquote indentation
247                    let effective_width = width.saturating_sub(blockquote_depth * 4);
248                    let para_opts = options.update_width(effective_width);
249
250                    if blockquote_depth > 0 {
251                        let bq_style = console
252                            .get_style("markdown.block_quote")
253                            .unwrap_or_else(|_| Style::null());
254                        let indent: String =
255                            std::iter::repeat_n(' ', blockquote_depth.saturating_sub(1) * 4)
256                                .collect();
257                        // P2 parity: rich uses ▌ (U+258C left half block) not │ (U+2502)
258                        let bq_prefix = format!("{}\u{258C} ", indent);
259
260                        // P1 parity: preserve inline styles by working per-segment.
261                        // Render the paragraph first, then split at newline segments
262                        // and prepend the blockquote prefix to each logical line,
263                        // keeping the styled segments intact.
264                        let text_segs = text_buffer.gilt_console(console, &para_opts);
265                        if text_segs.is_empty()
266                            || text_segs.iter().all(|s| s.text.trim().is_empty())
267                        {
268                            segments.push(Segment::styled(&bq_prefix, bq_style.clone()));
269                            segments.push(Segment::line());
270                        } else {
271                            // Walk segs, emitting prefix at start-of-line
272                            segments.push(Segment::styled(&bq_prefix, bq_style.clone()));
273                            for seg in &text_segs {
274                                if seg.text == "\n" {
275                                    segments.push(Segment::line());
276                                    segments.push(Segment::styled(&bq_prefix, bq_style.clone()));
277                                } else {
278                                    segments.push(seg.clone());
279                                }
280                            }
281                        }
282                    } else {
283                        let text_segs = text_buffer.gilt_console(console, &para_opts);
284                        segments.extend(text_segs);
285                    }
286
287                    needs_newline = true;
288                    text_buffer = Text::new("", Style::null());
289                }
290
291                // -- Emphasis (italic) --------------------------------------
292                Event::Start(Tag::Emphasis) => {
293                    let em_style = console
294                        .get_style("markdown.em")
295                        .unwrap_or_else(|_| Style::parse("italic"));
296                    style_stack.push(em_style);
297                }
298                Event::End(TagEnd::Emphasis) => {
299                    let _ = style_stack.pop();
300                }
301
302                // -- Strong (bold) ------------------------------------------
303                Event::Start(Tag::Strong) => {
304                    let strong_style = console
305                        .get_style("markdown.strong")
306                        .unwrap_or_else(|_| Style::parse("bold"));
307                    style_stack.push(strong_style);
308                }
309                Event::End(TagEnd::Strong) => {
310                    let _ = style_stack.pop();
311                }
312
313                // -- Strikethrough ------------------------------------------
314                Event::Start(Tag::Strikethrough) => {
315                    let s_style = console
316                        .get_style("markdown.s")
317                        .unwrap_or_else(|_| Style::parse("strike"));
318                    style_stack.push(s_style);
319                }
320                Event::End(TagEnd::Strikethrough) => {
321                    let _ = style_stack.pop();
322                }
323
324                // -- Inline code --------------------------------------------
325                Event::Code(text) => {
326                    let code_style = console
327                        .get_style("markdown.code")
328                        .unwrap_or_else(|_| Style::parse("bold cyan on black"));
329                    let current = style_stack.current().clone();
330                    let combined = current + code_style;
331                    if in_table_cell {
332                        // Redirect styled inline code directly into cell_text so
333                        // it lands at the correct position (not deferred through
334                        // text_buffer, which would reorder it relative to
335                        // surrounding Event::Text spans).  Mirrors the Rich
336                        // v15.0.0 fix (commit 7ef2d05c).
337                        cell_text.append_str(&text, Some(combined));
338                    } else {
339                        text_buffer.append_str(&text, Some(combined));
340                    }
341                }
342
343                // -- Links --------------------------------------------------
344                Event::Start(Tag::Link { dest_url, .. }) => {
345                    let link_style = console
346                        .get_style("markdown.link")
347                        .unwrap_or_else(|_| Style::parse("bright_blue"));
348                    style_stack.push(link_style);
349                    link_url = Some(dest_url.to_string());
350                }
351                Event::End(TagEnd::Link) => {
352                    let _ = style_stack.pop();
353                    // P1 parity: rich shows URL inline as "(url)" when hyperlinks==false;
354                    // when hyperlinks==true, the link text itself is the clickable hyperlink
355                    // (no extra URL appended).
356                    if !self.hyperlinks {
357                        if let Some(ref url) = link_url {
358                            let url_style = console
359                                .get_style("markdown.link_url")
360                                .unwrap_or_else(|_| Style::parse("underline blue"));
361                            text_buffer.append_str(" (", None);
362                            text_buffer.append_str(url, Some(url_style));
363                            text_buffer.append_str(")", None);
364                        }
365                    }
366                    link_url = None;
367                }
368
369                // -- Images (treat like links with alt text) ----------------
370                Event::Start(Tag::Image { dest_url, .. }) => {
371                    let link_style = console
372                        .get_style("markdown.link")
373                        .unwrap_or_else(|_| Style::parse("bright_blue"));
374                    style_stack.push(link_style);
375                    link_url = Some(dest_url.to_string());
376                    // P2 parity: prepend 🌆 emoji prefix for images
377                    text_buffer.append_str("\u{1F306} ", None);
378                }
379                Event::End(TagEnd::Image) => {
380                    let _ = style_stack.pop();
381                    // P1 parity: same as links — show URL inline only when hyperlinks==false
382                    if !self.hyperlinks {
383                        if let Some(ref url) = link_url {
384                            let url_style = console
385                                .get_style("markdown.link_url")
386                                .unwrap_or_else(|_| Style::parse("underline blue"));
387                            text_buffer.append_str(" (", None);
388                            text_buffer.append_str(url, Some(url_style));
389                            text_buffer.append_str(")", None);
390                        }
391                    }
392                    link_url = None;
393                }
394
395                // -- Code blocks --------------------------------------------
396                Event::Start(Tag::CodeBlock(kind)) => {
397                    code_block_text = Some(String::new());
398                    // P1 parity: capture language tag for syntax highlighting
399                    code_block_lang = match kind {
400                        CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
401                        _ => None,
402                    };
403                }
404                Event::End(TagEnd::CodeBlock) => {
405                    if let Some(code_text) = code_block_text.take() {
406                        let _lang = code_block_lang.take();
407                        #[cfg(feature = "syntax")]
408                        let lang = _lang;
409
410                        if needs_newline {
411                            segments.push(Segment::line());
412                        }
413
414                        // Remove trailing newline from code text
415                        let trimmed = code_text.trim_end_matches('\n');
416
417                        // P1 parity: use Syntax renderable when feature is enabled and
418                        // language is known; fall back to plain Panel otherwise.
419                        #[cfg(feature = "syntax")]
420                        {
421                            let used_lang = lang.as_deref().unwrap_or("text");
422                            let syn = crate::syntax::Syntax::new(trimmed, used_lang)
423                                .with_theme(&self.code_theme)
424                                .with_word_wrap(true)
425                                .with_padding(crate::syntax::PaddingSpec::Uniform(1));
426                            let syn_segs = syn.gilt_console(console, options);
427                            segments.extend(syn_segs);
428                        }
429                        #[cfg(not(feature = "syntax"))]
430                        {
431                            let code_style = console
432                                .get_style("markdown.code_block")
433                                .unwrap_or_else(|_| Style::parse("cyan on black"));
434                            let code_content = Text::styled_with(trimmed, code_style.clone());
435                            let panel = Panel::new(code_content)
436                                .with_box_chars(&HEAVY)
437                                .with_style(code_style)
438                                .with_expand(true);
439                            let panel_segs = panel.gilt_console(console, options);
440                            segments.extend(panel_segs);
441                        }
442
443                        needs_newline = true;
444                    }
445                }
446
447                // -- Lists --------------------------------------------------
448                Event::Start(Tag::List(first_item)) => match first_item {
449                    Some(start_num) => {
450                        list_stack.push(ListContext {
451                            ordered: true,
452                            item_number: start_num,
453                        });
454                    }
455                    None => {
456                        list_stack.push(ListContext {
457                            ordered: false,
458                            item_number: 0,
459                        });
460                    }
461                },
462                Event::End(TagEnd::List(_ordered)) => {
463                    list_stack.pop();
464                    if list_stack.is_empty() {
465                        needs_newline = true;
466                    }
467                }
468
469                Event::Start(Tag::Item) => {
470                    text_buffer = Text::new("", Style::null());
471                }
472                Event::End(TagEnd::Item) => {
473                    if needs_newline && list_stack.len() <= 1 {
474                        segments.push(Segment::line());
475                    }
476
477                    let indent_level = list_stack.len().saturating_sub(1);
478                    // P3 perf: use static slices for the most common indent levels
479                    // to avoid a per-item heap allocation.
480                    let indent_owned: String;
481                    let indent: &str = match indent_level {
482                        0 => "",
483                        1 => "    ",
484                        2 => "        ",
485                        _ => {
486                            indent_owned = std::iter::repeat_n(' ', indent_level * 4).collect();
487                            &indent_owned
488                        }
489                    };
490
491                    if let Some(ctx) = list_stack.last_mut() {
492                        if ctx.ordered {
493                            let num_style = console
494                                .get_style("markdown.item.number")
495                                .unwrap_or_else(|_| Style::parse("cyan"));
496                            let prefix = format!("{}{}. ", indent, ctx.item_number);
497                            segments.push(Segment::styled(&prefix, num_style));
498                            ctx.item_number += 1;
499                        } else {
500                            let bullet_style = console
501                                .get_style("markdown.item.bullet")
502                                .unwrap_or_else(|_| Style::parse("bold"));
503                            // P3 parity: rich uses " • " (leading space, 3 cells)
504                            let prefix = format!("{} \u{2022} ", indent);
505                            segments.push(Segment::styled(&prefix, bullet_style));
506                        }
507                    }
508
509                    // Render item text
510                    // P2 parity: account for 3-cell " • " prefix in width calculation
511                    let item_width =
512                        width.saturating_sub((list_stack.len().saturating_sub(1)) * 4 + 3);
513                    let item_opts = options.update_width(item_width);
514                    let item_segs = text_buffer.gilt_console(console, &item_opts);
515                    // P2 parity: prepend the item indent to continuation lines
516                    let cont_indent: String =
517                        std::iter::repeat_n(' ', indent_level * 4 + 3).collect();
518                    let mut first_line = true;
519                    for seg in item_segs {
520                        if !first_line && seg.text == "\n" {
521                            segments.push(seg);
522                            segments.push(Segment::text(&cont_indent));
523                            continue;
524                        }
525                        first_line = false;
526                        segments.push(seg);
527                    }
528
529                    text_buffer = Text::new("", Style::null());
530                    needs_newline = false;
531                }
532
533                // -- Block quotes -------------------------------------------
534                Event::Start(Tag::BlockQuote(_kind)) => {
535                    blockquote_depth += 1;
536                }
537                Event::End(TagEnd::BlockQuote(_kind)) => {
538                    blockquote_depth = blockquote_depth.saturating_sub(1);
539                }
540
541                // -- Tables -------------------------------------------------
542                Event::Start(Tag::Table(alignments)) => {
543                    let mut ctx = TableContext::new();
544                    ctx.alignments = alignments.to_vec();
545                    table_ctx = Some(ctx);
546                }
547                Event::End(TagEnd::Table) => {
548                    if let Some(ctx) = table_ctx.take() {
549                        if needs_newline {
550                            segments.push(Segment::line());
551                        }
552
553                        let table_segs = render_table(console, options, &ctx);
554                        segments.extend(table_segs);
555                        needs_newline = true;
556                    }
557                }
558
559                Event::Start(Tag::TableHead) => {
560                    if let Some(ref mut ctx) = table_ctx {
561                        ctx.in_head = true;
562                    }
563                }
564                Event::End(TagEnd::TableHead) => {
565                    if let Some(ref mut ctx) = table_ctx {
566                        // pulldown-cmark may not emit TableRow for the header,
567                        // so save any accumulated cells as header_cells here.
568                        // Extract plain text — Table::new takes &[&str].
569                        if !ctx.current_row.is_empty() {
570                            ctx.header_cells = ctx
571                                .current_row
572                                .iter()
573                                .map(|t| t.plain().to_string())
574                                .collect();
575                            ctx.current_row.clear();
576                        }
577                        ctx.in_head = false;
578                    }
579                }
580
581                Event::Start(Tag::TableRow) => {
582                    if let Some(ref mut ctx) = table_ctx {
583                        ctx.current_row.clear();
584                    }
585                }
586                Event::End(TagEnd::TableRow) => {
587                    if let Some(ref mut ctx) = table_ctx {
588                        let row = ctx.current_row.clone();
589                        if ctx.in_head {
590                            // Header cells stored as plain text for Table::new.
591                            ctx.header_cells = row.iter().map(|t| t.plain().to_string()).collect();
592                        } else {
593                            ctx.rows.push(row);
594                        }
595                        ctx.current_row.clear();
596                    }
597                }
598
599                Event::Start(Tag::TableCell) => {
600                    in_table_cell = true;
601                    cell_text = Text::new("", Style::null());
602                    text_buffer = Text::new("", Style::null());
603                }
604                Event::End(TagEnd::TableCell) => {
605                    // Flush any remaining text_buffer into cell_text, preserving spans.
606                    if !text_buffer.is_empty() {
607                        cell_text.append_text(&text_buffer);
608                    }
609                    if let Some(ref mut ctx) = table_ctx {
610                        ctx.current_row.push(cell_text.clone());
611                    }
612                    in_table_cell = false;
613                    cell_text = Text::new("", Style::null());
614                    text_buffer = Text::new("", Style::null());
615                }
616
617                // -- Horizontal rule ----------------------------------------
618                Event::Rule => {
619                    if needs_newline {
620                        segments.push(Segment::line());
621                    }
622                    let hr_style = console
623                        .get_style("markdown.hr")
624                        .unwrap_or_else(|_| Style::parse("dim"));
625                    let rule = Rule::new().with_style(hr_style).with_end("");
626                    let rule_segs = rule.gilt_console(console, options);
627                    segments.extend(rule_segs);
628                    segments.push(Segment::line());
629                    needs_newline = true;
630                }
631
632                // -- Text ---------------------------------------------------
633                Event::Text(text) => {
634                    // If inside a code block, accumulate raw text
635                    if let Some(ref mut code_text) = code_block_text {
636                        code_text.push_str(&text);
637                        continue;
638                    }
639
640                    // If inside a table cell, accumulate styled text directly
641                    // so surrounding style (bold, italic) in cells is preserved.
642                    if in_table_cell {
643                        let current_style = style_stack.current().clone();
644                        if current_style.is_null() {
645                            cell_text.append_str(&text, None);
646                        } else {
647                            cell_text.append_str(&text, Some(current_style));
648                        }
649                        continue;
650                    }
651
652                    // Apply current style stack
653                    let current_style = style_stack.current().clone();
654                    if current_style.is_null() {
655                        text_buffer.append_str(&text, None);
656                    } else {
657                        text_buffer.append_str(&text, Some(current_style));
658                    }
659                }
660
661                // -- Breaks -------------------------------------------------
662                Event::SoftBreak => {
663                    if code_block_text.is_some() {
664                        if let Some(ref mut code_text) = code_block_text {
665                            code_text.push('\n');
666                        }
667                    } else if in_table_cell {
668                        cell_text.append_str(" ", None);
669                    } else {
670                        text_buffer.append_str(" ", None);
671                    }
672                }
673                Event::HardBreak => {
674                    if code_block_text.is_some() {
675                        if let Some(ref mut code_text) = code_block_text {
676                            code_text.push('\n');
677                        }
678                    } else if in_table_cell {
679                        cell_text.append_str(" ", None);
680                    } else {
681                        text_buffer.append_str("\n", None);
682                    }
683                }
684
685                // -- GFM task-list markers ------------------------------------
686                // pulldown-cmark emits TaskListMarker *inside* the list item,
687                // before the item's text.  We prepend the checkbox as the
688                // item's bullet/prefix inline with the text buffer.
689                Event::TaskListMarker(checked) => {
690                    // ☑ (U+2611) for checked, ☐ (U+2610) for unchecked
691                    let marker = if checked { "\u{2611} " } else { "\u{2610} " };
692                    let bullet_style = console
693                        .get_style("markdown.item.bullet")
694                        .unwrap_or_else(|_| Style::parse("bold"));
695                    if in_table_cell {
696                        cell_text.append_str(marker, Some(bullet_style));
697                    } else {
698                        text_buffer.append_str(marker, Some(bullet_style));
699                    }
700                }
701
702                // -- HTML (ignored) -----------------------------------------
703                Event::Html(_) | Event::InlineHtml(_) => {}
704
705                // -- Footnotes, metadata, etc. (ignored) --------------------
706                _ => {}
707            }
708        }
709
710        // Handle any remaining text in the buffer (shouldn't normally happen
711        // with well-formed markdown, but handle gracefully)
712        if !text_buffer.plain().is_empty() {
713            text_buffer.end = String::new();
714            let final_segs = text_buffer.gilt_console(console, options);
715            segments.extend(final_segs);
716            segments.push(Segment::line());
717        }
718
719        segments
720    }
721}
722
723// ---------------------------------------------------------------------------
724// Table rendering helper
725// ---------------------------------------------------------------------------
726
727/// Build and render a gilt `Table` from accumulated table context data.
728fn render_table(console: &Console, options: &ConsoleOptions, ctx: &TableContext) -> Vec<Segment> {
729    let headers: Vec<&str> = ctx.header_cells.iter().map(|s| s.as_str()).collect();
730    let mut table = Table::new(&headers);
731
732    // P2 parity: rich uses box.SIMPLE (no outer border, header separator only)
733    table = table.with_box_chars(Some(&SIMPLE));
734
735    // Apply alignment from markdown
736    for (i, alignment) in ctx.alignments.iter().enumerate() {
737        if i < table.columns.len() {
738            table.columns[i].justify = match alignment {
739                Alignment::None | Alignment::Left => JustifyMethod::Left,
740                Alignment::Center => JustifyMethod::Center,
741                Alignment::Right => JustifyMethod::Right,
742            };
743        }
744    }
745
746    // Apply markdown table styles
747    let border_style_name = "markdown.table.border";
748    table.border_style = border_style_name.to_string();
749
750    let header_style_name = "markdown.table.header";
751    table.header_style = header_style_name.to_string();
752
753    // Add data rows — use add_row_text to preserve inline styles (e.g. `code`).
754    for row in &ctx.rows {
755        table.add_row_text(row);
756    }
757
758    table.gilt_console(console, options)
759}
760
761// ---------------------------------------------------------------------------
762// Display
763// ---------------------------------------------------------------------------
764
765impl std::fmt::Display for Markdown {
766    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767        let mut console = Console::builder()
768            .width(f.width().unwrap_or(80))
769            .force_terminal(true)
770            .no_color(true)
771            .build();
772        console.begin_capture();
773        console.print(self);
774        let output = console.end_capture();
775        write!(f, "{}", output.trim_end_matches('\n'))
776    }
777}
778
779// ---------------------------------------------------------------------------
780// Tests
781// ---------------------------------------------------------------------------
782
783#[cfg(test)]
784#[path = "markdown_tests.rs"]
785mod tests;