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, ¶_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, ¶_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;