modcli/output/
markdown.rs

1/// Minimal Markdown -> ANSI renderer for help text.
2/// Supported:
3/// - Headings: #, ##, ### (bold)
4/// - Lists: lines starting with "- " become bullets
5/// - Inline: **bold**, *italic*, `code`
6/// - Paragraphs: preserved
7pub fn render_markdown(input: &str) -> String {
8    let mut out = String::with_capacity(input.len() + 32);
9    for line in input.lines() {
10        let trimmed = line.trim_start();
11        let styled_line = if let Some(rest) = trimmed.strip_prefix("### ") {
12            format!("\x1b[1m{}\x1b[0m", render_inline(rest))
13        } else if let Some(rest) = trimmed.strip_prefix("## ") {
14            format!("\x1b[1m{}\x1b[0m", render_inline(rest))
15        } else if let Some(rest) = trimmed.strip_prefix("# ") {
16            format!("\x1b[1m{}\x1b[0m", render_inline(rest))
17        } else if let Some(rest) = trimmed.strip_prefix("- ") {
18            format!(" • {}", render_inline(rest))
19        } else {
20            render_inline(line)
21        };
22        out.push_str(&styled_line);
23        out.push('\n');
24    }
25    out
26}
27
28fn render_inline(s: &str) -> String {
29    // Replace code spans first to avoid conflicts with bold/italic
30    let mut out = String::new();
31    let mut i = 0;
32    let bytes = s.as_bytes();
33    while i < bytes.len() {
34        if bytes[i] == b'`' {
35            if let Some(j) = find_next(bytes, i + 1, b'`') {
36                out.push_str("\x1b[7m"); // inverse
37                out.push_str(&s[i + 1..j]);
38                out.push_str("\x1b[0m");
39                i = j + 1;
40                continue;
41            }
42        }
43        out.push(bytes[i] as char);
44        i += 1;
45    }
46    // Bold **...** and italic *...*
47    let s2 = out;
48    let s3 = replace_enclosed(&s2, "**", "\x1b[1m", "\x1b[0m");
49    replace_enclosed(&s3, "*", "\x1b[3m", "\x1b[0m")
50}
51
52fn find_next(bytes: &[u8], mut i: usize, ch: u8) -> Option<usize> {
53    while i < bytes.len() {
54        if bytes[i] == ch {
55            return Some(i);
56        }
57        i += 1;
58    }
59    None
60}
61
62fn replace_enclosed(s: &str, token: &str, start: &str, end: &str) -> String {
63    let mut out = String::with_capacity(s.len());
64    let mut i = 0usize;
65    let tlen = token.len();
66    let bytes = s.as_bytes();
67    while i < bytes.len() {
68        if i + tlen <= bytes.len() && &s[i..i + tlen] == token {
69            if let Some(j) = find_token(&s[i + tlen..], token) {
70                out.push_str(start);
71                out.push_str(&s[i + tlen..i + tlen + j]);
72                out.push_str(end);
73                i = i + tlen + j + tlen;
74                continue;
75            }
76        }
77        out.push(bytes[i] as char);
78        i += 1;
79    }
80    out
81}
82
83fn find_token(hay: &str, token: &str) -> Option<usize> {
84    hay.find(token)
85}