zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Minimal Markdown → ratatui [`Text`] renderer.
//!
//! Phase 5 of the terminal refactor (landed early for the REPL scrollback).
//!
//! This is deliberately small: it covers the constructs that appear in command
//! help and command output — headings, bold/italic, inline code, fenced code
//! blocks, bullet/numbered lists, block quotes, and horizontal rules. It is not
//! a full CommonMark renderer; tables, links-as-hyperlinks, and images degrade
//! to plain styled text.

#![allow(dead_code)]

use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};

use crate::tui::theme::Theme;

/// Render Markdown source into styled terminal text using `theme`.
pub fn render(md: &str, theme: &Theme) -> Text<'static> {
    let mut r = Renderer::new(theme);
    for event in Parser::new(md) {
        r.handle(event);
    }
    r.finish()
}

struct Renderer<'t> {
    theme: &'t Theme,
    lines: Vec<Line<'static>>,
    spans: Vec<Span<'static>>,
    /// Style stack for nested emphasis/strong.
    style: Style,
    style_stack: Vec<Style>,
    in_code_block: bool,
    /// List nesting; `Some(n)` tracks the next ordinal for ordered lists.
    list_stack: Vec<Option<u64>>,
    in_blockquote: bool,
}

impl<'t> Renderer<'t> {
    fn new(theme: &'t Theme) -> Self {
        Renderer {
            theme,
            lines: Vec::new(),
            spans: Vec::new(),
            style: Style::default().fg(theme.fg),
            style_stack: Vec::new(),
            in_code_block: false,
            list_stack: Vec::new(),
            in_blockquote: false,
        }
    }

    fn push_style(&mut self, f: impl FnOnce(Style) -> Style) {
        self.style_stack.push(self.style);
        self.style = f(self.style);
    }

    fn pop_style(&mut self) {
        if let Some(s) = self.style_stack.pop() {
            self.style = s;
        }
    }

    /// Flush the in-progress spans as a finished line.
    fn flush_line(&mut self) {
        if self.spans.is_empty() {
            return;
        }
        let spans = std::mem::take(&mut self.spans);
        self.lines.push(Line::from(spans));
    }

    /// Append a blank separator line (coalescing consecutive blanks).
    fn blank_line(&mut self) {
        self.flush_line();
        let already_blank = self
            .lines
            .last()
            .map(|l| l.spans.is_empty())
            .unwrap_or(true);
        if !already_blank && !self.lines.is_empty() {
            self.lines.push(Line::from(""));
        }
    }

    fn handle(&mut self, event: Event) {
        match event {
            Event::Start(tag) => self.start(tag),
            Event::End(tag) => self.end(tag),
            Event::Text(t) => self.text(&t),
            Event::Code(t) => {
                let style = Style::default().fg(self.theme.accent);
                self.spans.push(Span::styled(t.to_string(), style));
            }
            Event::SoftBreak => self.spans.push(Span::raw(" ")),
            Event::HardBreak => self.flush_line(),
            Event::Rule => {
                self.blank_line();
                self.lines.push(Line::from(Span::styled(
                    "".repeat(48),
                    Style::default().fg(self.theme.muted),
                )));
                self.blank_line();
            }
            // HTML, footnotes, task markers, etc. → ignored / plain.
            _ => {}
        }
    }

    fn start(&mut self, tag: Tag) {
        match tag {
            Tag::Heading { level, .. } => {
                self.blank_line();
                let bump = match level {
                    HeadingLevel::H1 | HeadingLevel::H2 => Modifier::BOLD,
                    _ => Modifier::empty(),
                };
                self.push_style(|_| {
                    Style::default()
                        .fg(self.theme.accent)
                        .add_modifier(Modifier::BOLD | bump)
                });
            }
            Tag::Strong => self.push_style(|s| s.add_modifier(Modifier::BOLD)),
            Tag::Emphasis => self.push_style(|s| s.add_modifier(Modifier::ITALIC)),
            Tag::CodeBlock(_kind) => {
                let _ = matches!(_kind, CodeBlockKind::Fenced(_));
                self.blank_line();
                self.in_code_block = true;
                self.push_style(|_| Style::default().fg(self.theme.fg).bg(self.theme.row_alt));
            }
            Tag::List(start) => self.list_stack.push(start),
            Tag::Item => {
                self.flush_line();
                let marker = match self.list_stack.last_mut() {
                    Some(Some(n)) => {
                        let m = format!("{}. ", *n);
                        *n += 1;
                        m
                    }
                    _ => "".to_string(),
                };
                let indent = "  ".repeat(self.list_stack.len().saturating_sub(1));
                self.spans.push(Span::styled(
                    format!("{}{}", indent, marker),
                    Style::default().fg(self.theme.muted),
                ));
            }
            Tag::BlockQuote => {
                self.in_blockquote = true;
                self.push_style(|s| s.fg(self.theme.muted));
            }
            _ => {}
        }
    }

    fn end(&mut self, tag: TagEnd) {
        match tag {
            TagEnd::Heading(_) => {
                self.flush_line();
                self.pop_style();
                self.blank_line();
            }
            TagEnd::Paragraph => {
                self.flush_line();
                self.blank_line();
            }
            TagEnd::Strong | TagEnd::Emphasis => self.pop_style(),
            TagEnd::CodeBlock => {
                self.flush_line();
                self.in_code_block = false;
                self.pop_style();
                self.blank_line();
            }
            TagEnd::List(_) => {
                self.list_stack.pop();
                self.blank_line();
            }
            TagEnd::Item => self.flush_line(),
            TagEnd::BlockQuote => {
                self.in_blockquote = false;
                self.pop_style();
                self.blank_line();
            }
            _ => {}
        }
    }

    fn text(&mut self, t: &str) {
        if self.in_code_block {
            // Each physical line in a code block becomes its own styled line.
            let mut parts = t.split('\n').peekable();
            while let Some(part) = parts.next() {
                self.spans.push(Span::styled(part.to_string(), self.style));
                if parts.peek().is_some() {
                    self.flush_line();
                }
            }
        } else {
            self.spans.push(Span::styled(t.to_string(), self.style));
        }
    }

    fn finish(mut self) -> Text<'static> {
        self.flush_line();
        // Trim a trailing blank line for tidiness.
        while self
            .lines
            .last()
            .map(|l| l.spans.is_empty())
            .unwrap_or(false)
        {
            self.lines.pop();
        }
        Text::from(self.lines)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_headings_and_code() {
        let t = Theme::dark();
        let text = render("# Title\n\nSome `code` here.\n", &t);
        // Heading + blank + paragraph (blank separators may collapse).
        assert!(text.lines.len() >= 2);
    }

    #[test]
    fn renders_list() {
        let t = Theme::dark();
        let text = render("- one\n- two\n", &t);
        let joined: String = text
            .lines
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
            .collect();
        assert!(joined.contains("one"));
        assert!(joined.contains("two"));
        assert!(joined.contains(''));
    }

    #[test]
    fn empty_input_is_empty() {
        let t = Theme::dark();
        let text = render("", &t);
        assert!(text.lines.is_empty());
    }
}