Skip to main content

tui/markdown/
renderer.rs

1use std::borrow::Cow;
2use std::ops::Range;
3
4use pulldown_cmark::{Alignment, CodeBlockKind, Event, Parser, Tag, TagEnd};
5
6use super::headings::MarkdownHeading;
7use super::pulldown_options;
8use super::source_map::SourceMap;
9use super::table::{CellBuilder, TableCell, TableState, line_display_width};
10use crate::line::Line;
11use crate::rendering::render_context::ViewContext;
12use crate::span::Span;
13use crate::style::Style;
14use crate::theme::Theme;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SourceMappedLine {
18    pub source_line_no: usize,
19    pub line: Line,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct MarkdownBlock {
24    pub anchor_line_no: usize,
25    pub rendered_line_range: Range<usize>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MarkdownRenderResult {
30    pub lines: Vec<SourceMappedLine>,
31    pub headings: Vec<MarkdownHeading>,
32    pub blocks: Vec<MarkdownBlock>,
33}
34
35impl MarkdownRenderResult {
36    pub fn to_lines(self) -> Vec<Line> {
37        self.lines.into_iter().map(|line| line.line).collect()
38    }
39}
40
41pub fn render_markdown_result(text: &str, context: &ViewContext) -> MarkdownRenderResult {
42    let source = SourceMap::new(text);
43    MarkdownRenderer::new(context, &source).render()
44}
45
46struct MarkdownRenderer<'a> {
47    context: &'a ViewContext,
48    theme: &'a Theme,
49    source: &'a SourceMap<'a>,
50    style_stack: InlineStyleStack,
51    headings: HeadingCollector,
52    display_lines: Vec<Line>,
53    display_line_sources: Vec<usize>,
54    current_line: Line,
55    current_source_line_no: usize,
56    list_stack: Vec<Option<u64>>,
57    list_item_stack: Vec<ListItemState>,
58    code_buffer: String,
59    code_lang: String,
60    in_code_block: bool,
61    blockquote_depth: usize,
62    table_state: Option<TableState>,
63    active_cell: Option<CellBuilder>,
64    blocks: Vec<MarkdownBlock>,
65    current_block: Option<BlockBuilder>,
66}
67
68struct BlockBuilder {
69    anchor_line_no: usize,
70    first_rendered_line: usize,
71}
72
73#[derive(Default)]
74struct ListItemState {
75    is_loose: bool,
76}
77
78#[derive(Clone, Copy)]
79enum BlockSpacing {
80    None,
81    BlankLineAfter,
82}
83
84impl<'a> MarkdownRenderer<'a> {
85    fn new(context: &'a ViewContext, source: &'a SourceMap<'a>) -> Self {
86        Self {
87            context,
88            theme: &context.theme,
89            source,
90            style_stack: InlineStyleStack::new(),
91            headings: HeadingCollector::new(),
92            display_lines: Vec::new(),
93            display_line_sources: Vec::new(),
94            current_line: Line::default(),
95            current_source_line_no: 1,
96            list_stack: Vec::new(),
97            list_item_stack: Vec::new(),
98            code_buffer: String::new(),
99            code_lang: String::new(),
100            in_code_block: false,
101            blockquote_depth: 0,
102            table_state: None,
103            active_cell: None,
104            blocks: Vec::new(),
105            current_block: None,
106        }
107    }
108
109    fn render(mut self) -> MarkdownRenderResult {
110        let parser = Parser::new_ext(self.source.text(), pulldown_options()).into_offset_iter();
111        for (event, range) in parser {
112            self.handle_event(event, range);
113        }
114
115        self.flush_line();
116        while self.display_lines.last().is_some_and(Line::is_empty) {
117            self.display_lines.pop();
118            self.display_line_sources.pop();
119        }
120
121        self.finalize_current_block();
122
123        let lines: Vec<SourceMappedLine> = self
124            .display_lines
125            .into_iter()
126            .zip(self.display_line_sources)
127            .map(|(line, source_line_no)| SourceMappedLine { source_line_no, line })
128            .collect();
129        MarkdownRenderResult { lines, headings: self.headings.into_headings(), blocks: self.blocks }
130    }
131
132    fn handle_event(&mut self, event: Event<'_>, range: Range<usize>) {
133        self.current_source_line_no = self.source.line_no_for_start(&range);
134
135        match event {
136            Event::Start(tag) => self.handle_start(tag, range),
137            Event::End(tag_end) => self.handle_end(tag_end),
138            Event::Text(text) => {
139                self.headings.append_text(&text);
140                self.push_inline_text(&text);
141            }
142            Event::Code(code) => {
143                self.headings.append_text(&code);
144                self.push_inline_code(&code);
145            }
146            Event::SoftBreak => self.push_soft_break(),
147            Event::HardBreak => self.push_hard_break(),
148            Event::Rule => {
149                self.finish_current_line();
150                self.start_block(self.current_source_line_no);
151                self.push_line(Line::with_style("───────────────", Style::fg(self.theme.muted())));
152                self.finish_rendered_block(BlockSpacing::BlankLineAfter);
153            }
154            _ => {}
155        }
156    }
157
158    fn handle_start(&mut self, tag: Tag<'_>, range: Range<usize>) {
159        match tag {
160            Tag::Heading { level, .. } => {
161                self.headings.begin(level as u8, self.source.line_no_for_start(&range));
162                self.finish_current_line();
163                self.start_block(self.current_source_line_no);
164                let prefix = "#".repeat(level as usize);
165                let style = heading_style(level as u8, self.theme);
166                self.push_styled_text(&format!("{prefix} "), style);
167                self.style_stack.push(style);
168            }
169            Tag::Paragraph if self.list_item_stack.is_empty() => {
170                self.start_block(self.current_source_line_no);
171            }
172            Tag::BlockQuote(_) => {
173                self.finish_current_line();
174                self.blockquote_depth += 1;
175                self.style_stack.push(Style::fg(self.theme.blockquote()));
176            }
177            Tag::List(start) => {
178                if self.list_stack.is_empty() {
179                    self.finish_current_line();
180                }
181                self.list_stack.push(start);
182            }
183            Tag::Item => {
184                self.list_item_stack.push(ListItemState::default());
185                self.finish_current_line();
186                self.start_block(self.current_source_line_no);
187                let indent = "  ".repeat(self.list_stack.len().saturating_sub(1));
188                let marker = match self.list_stack.last_mut() {
189                    Some(Some(n)) => {
190                        let marker = format!("{n}. ");
191                        *n += 1;
192                        marker
193                    }
194                    _ => "- ".to_string(),
195                };
196                self.push_styled_text(&format!("{indent}{marker}"), Style::fg(self.theme.muted()));
197            }
198            Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } => {
199                self.style_stack.push_inline_tag(&tag, self.theme);
200            }
201            Tag::CodeBlock(kind) => self.handle_code_block_start(kind),
202            Tag::Table(_) | Tag::TableRow | Tag::TableCell => self.handle_table_start(tag),
203            _ => {}
204        }
205    }
206
207    fn handle_end(&mut self, tag_end: TagEnd) {
208        match tag_end {
209            TagEnd::Paragraph => self.finish_block(BlockSpacing::BlankLineAfter),
210            TagEnd::Heading(_) => {
211                self.style_stack.pop();
212                self.headings.finish();
213                self.finish_block(BlockSpacing::BlankLineAfter);
214            }
215            TagEnd::BlockQuote(_) => {
216                self.style_stack.pop();
217                self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
218                let spacing =
219                    if self.blockquote_depth == 0 { BlockSpacing::BlankLineAfter } else { BlockSpacing::None };
220                self.finish_block(spacing);
221            }
222            TagEnd::List(_) => {
223                self.list_stack.pop();
224                if self.list_stack.is_empty() {
225                    self.finish_block(BlockSpacing::BlankLineAfter);
226                }
227            }
228            TagEnd::Item => {
229                self.finish_current_line();
230                if self.list_item_stack.pop().is_some_and(|state| state.is_loose) {
231                    self.push_blank_line();
232                }
233            }
234            TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough | TagEnd::Link => self.style_stack.pop(),
235            TagEnd::CodeBlock => self.handle_code_block_end(),
236            TagEnd::Table | TagEnd::TableRow | TagEnd::TableHead | TagEnd::TableCell => self.handle_table_end(tag_end),
237            _ => {}
238        }
239    }
240
241    fn handle_code_block_start(&mut self, kind: CodeBlockKind<'_>) {
242        self.finish_current_line();
243        self.start_block(self.current_source_line_no);
244        self.in_code_block = true;
245        self.code_buffer.clear();
246        self.code_lang = match kind {
247            CodeBlockKind::Fenced(lang) => lang.split(',').next().unwrap_or("").trim().to_string(),
248            CodeBlockKind::Indented => String::new(),
249        };
250    }
251
252    fn handle_code_block_end(&mut self) {
253        self.in_code_block = false;
254        let code = std::mem::take(&mut self.code_buffer);
255        let lang = std::mem::take(&mut self.code_lang);
256        let code_lines = self.context.highlighter().highlight(&code, &lang, self.theme);
257        self.extend_lines(code_lines);
258        self.finish_rendered_block(BlockSpacing::BlankLineAfter);
259    }
260
261    fn handle_table_start(&mut self, tag: Tag<'_>) {
262        match tag {
263            Tag::Table(alignments) => {
264                self.finish_current_line();
265                self.start_block(self.current_source_line_no);
266                self.table_state = Some(TableState::new(&alignments));
267            }
268            Tag::TableRow => {
269                if let Some(ref mut table) = self.table_state {
270                    table.start_row();
271                }
272            }
273            Tag::TableCell => {
274                self.active_cell = Some(CellBuilder::default());
275            }
276            _ => {}
277        }
278    }
279
280    fn handle_table_end(&mut self, tag_end: TagEnd) {
281        match tag_end {
282            TagEnd::Table => {
283                if let Some(table) = self.table_state.take() {
284                    let rendered = table.render(self.theme);
285                    self.extend_lines(rendered);
286                    self.finish_rendered_block(BlockSpacing::BlankLineAfter);
287                }
288            }
289            TagEnd::TableRow | TagEnd::TableHead => {
290                if let Some(ref mut table) = self.table_state {
291                    table.finish_row();
292                }
293            }
294            TagEnd::TableCell => {
295                if let Some(builder) = self.active_cell.take()
296                    && let Some(ref mut table) = self.table_state
297                {
298                    let col_idx = table.current_row.len();
299                    let alignment = table.alignments.get(col_idx).copied().unwrap_or(Alignment::None);
300                    let lines = builder.finish();
301                    let max_width = lines.iter().map(line_display_width).max().unwrap_or(0);
302                    let cell = TableCell { lines, alignment, max_width };
303                    table.add_cell(cell);
304                }
305            }
306            _ => {}
307        }
308    }
309
310    fn push_inline_text(&mut self, text: &str) {
311        if self.in_code_block {
312            self.code_buffer.push_str(text);
313            return;
314        }
315
316        let style = self.style_stack.current();
317        if let Some(cell) = self.active_cell.as_mut() {
318            cell.push_text(text, style);
319            return;
320        }
321
322        self.push_text(text);
323    }
324
325    fn push_inline_code(&mut self, code: &str) {
326        if self.in_code_block {
327            self.code_buffer.push_str(code);
328            return;
329        }
330
331        let style = Style::fg(self.theme.code_fg());
332        if let Some(cell) = self.active_cell.as_mut() {
333            cell.push_code(code, style);
334        } else {
335            self.current_line.push_span(Span::with_style(code, style));
336        }
337    }
338
339    fn push_soft_break(&mut self) {
340        if self.in_code_block {
341            self.code_buffer.push('\n');
342            return;
343        }
344
345        let style = self.style_stack.current();
346        if let Some(cell) = self.active_cell.as_mut() {
347            cell.soft_break(style);
348            return;
349        }
350
351        self.push_text(" ");
352    }
353
354    fn push_hard_break(&mut self) {
355        if self.in_code_block {
356            self.code_buffer.push('\n');
357            return;
358        }
359
360        if let Some(cell) = self.active_cell.as_mut() {
361            cell.hard_break();
362            return;
363        }
364
365        self.flush_line();
366    }
367
368    fn push_text(&mut self, text: &str) {
369        let style = self.style_stack.current();
370        let prefix = self.blockquote_prefix();
371
372        for (index, chunk) in text.split('\n').enumerate() {
373            if index > 0 {
374                self.flush_line();
375            }
376            if self.current_line.is_empty() && !prefix.is_empty() {
377                self.current_line.push_with_style(&*prefix, Style::fg(self.theme.blockquote()));
378            }
379            if !chunk.is_empty() {
380                self.current_line.push_span(Span::with_style(chunk, style));
381            }
382        }
383    }
384
385    fn push_styled_text(&mut self, text: &str, style: Style) {
386        self.current_line.push_span(Span::with_style(text, style));
387    }
388
389    fn finish_current_line(&mut self) {
390        if !self.current_line.is_empty() {
391            self.flush_line();
392        }
393    }
394
395    fn finish_block(&mut self, spacing: BlockSpacing) {
396        self.finish_current_line();
397        self.finish_rendered_block(spacing);
398    }
399
400    fn finish_rendered_block(&mut self, spacing: BlockSpacing) {
401        if matches!(spacing, BlockSpacing::BlankLineAfter) {
402            if let Some(item_state) = self.list_item_stack.last_mut() {
403                item_state.is_loose = true;
404            }
405            self.push_blank_line();
406        }
407    }
408
409    fn push_blank_line(&mut self) {
410        if self.display_lines.is_empty() {
411            return;
412        }
413        if self.display_lines.last().is_some_and(Line::is_empty) {
414            return;
415        }
416        self.push_line(Line::default());
417    }
418
419    fn flush_line(&mut self) {
420        let prefix = self.blockquote_prefix();
421        if !prefix.is_empty() && self.current_line.is_empty() {
422            self.current_line.push_with_style(&*prefix, Style::fg(self.theme.blockquote()));
423        }
424        let line = std::mem::take(&mut self.current_line);
425        self.push_line(line);
426    }
427
428    fn push_line(&mut self, line: Line) {
429        self.display_lines.push(line);
430        self.display_line_sources.push(self.current_source_line_no);
431    }
432
433    fn extend_lines(&mut self, lines: Vec<Line>) {
434        for line in lines {
435            self.push_line(line);
436        }
437    }
438
439    fn blockquote_prefix(&self) -> Cow<'static, str> {
440        if self.blockquote_depth == 0 { Cow::Borrowed("") } else { Cow::Owned("  ".repeat(self.blockquote_depth)) }
441    }
442
443    fn start_block(&mut self, anchor_line_no: usize) {
444        self.finalize_current_block();
445        self.current_block = Some(BlockBuilder { anchor_line_no, first_rendered_line: self.display_lines.len() });
446    }
447
448    fn finalize_current_block(&mut self) {
449        let Some(builder) = self.current_block.take() else {
450            return;
451        };
452        let mut end = self.display_lines.len();
453        while end > builder.first_rendered_line && self.display_lines.get(end - 1).is_some_and(Line::is_empty) {
454            end -= 1;
455        }
456        if end > builder.first_rendered_line {
457            self.blocks.push(MarkdownBlock {
458                anchor_line_no: builder.anchor_line_no,
459                rendered_line_range: builder.first_rendered_line..end,
460            });
461        }
462    }
463}
464
465struct InlineStyleStack {
466    stack: Vec<Style>,
467}
468
469impl InlineStyleStack {
470    fn new() -> Self {
471        Self { stack: Vec::new() }
472    }
473
474    fn push(&mut self, style: Style) {
475        self.stack.push(style);
476    }
477
478    fn pop(&mut self) {
479        self.stack.pop();
480    }
481
482    fn current(&self) -> Style {
483        self.stack.iter().copied().fold(Style::default(), Style::merge)
484    }
485
486    fn push_inline_tag(&mut self, tag: &Tag<'_>, theme: &Theme) {
487        match tag {
488            Tag::Heading { level, .. } => self.push(heading_style(*level as u8, theme)),
489            Tag::BlockQuote(_) => self.push(Style::fg(theme.blockquote())),
490            Tag::Strong => self.push(Style::default().bold()),
491            Tag::Emphasis => self.push(Style::default().italic()),
492            Tag::Strikethrough => self.push(Style::default().strikethrough()),
493            Tag::Link { .. } => self.push(Style::fg(theme.link()).underline()),
494            _ => {}
495        }
496    }
497}
498
499struct HeadingCollector {
500    headings: Vec<MarkdownHeading>,
501    active: Option<ActiveHeading>,
502}
503
504impl HeadingCollector {
505    fn new() -> Self {
506        Self { headings: Vec::new(), active: None }
507    }
508
509    fn begin(&mut self, level: u8, source_line_no: usize) {
510        self.active = Some(ActiveHeading { level, source_line_no, title: String::new() });
511    }
512
513    fn append_text(&mut self, text: &str) {
514        if let Some(active) = self.active.as_mut() {
515            active.title.push_str(text);
516        }
517    }
518
519    fn finish(&mut self) {
520        let Some(active) = self.active.take() else {
521            return;
522        };
523        let title = active.title.trim().to_string();
524        if title.is_empty() {
525            return;
526        }
527        self.headings.push(MarkdownHeading { title, level: active.level, source_line_no: active.source_line_no });
528    }
529
530    fn into_headings(self) -> Vec<MarkdownHeading> {
531        self.headings
532    }
533}
534
535fn heading_style(level: u8, theme: &Theme) -> Style {
536    match level {
537        1 => Style::fg(theme.heading()).bold(),
538        2 => Style::fg(theme.text_primary()).bold(),
539        _ => Style::fg(theme.text_secondary()).bold(),
540    }
541}
542
543struct ActiveHeading {
544    level: u8,
545    source_line_no: usize,
546    title: String,
547}