1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
use std::cell::Cell;
use std::io::{self, Write};

use termcolor::{Buffer, Color, ColorSpec, WriteColor};

use crate::markdown::{MdStream, MdTree};

const DEFAULT_COLUMN_WIDTH: usize = 140;

thread_local! {
    /// Track the position of viewable characters in our buffer
    static CURSOR: Cell<usize> = const { Cell::new(0) };
    /// Width of the terminal
    static WIDTH: Cell<usize> = const { Cell::new(DEFAULT_COLUMN_WIDTH) };
}

/// Print to terminal output to a buffer
pub(crate) fn entrypoint(stream: &MdStream<'_>, buf: &mut Buffer) -> io::Result<()> {
    #[cfg(not(test))]
    if let Some((w, _)) = termize::dimensions() {
        WIDTH.with(|c| c.set(std::cmp::min(w, DEFAULT_COLUMN_WIDTH)));
    }
    write_stream(stream, buf, None, 0)?;
    buf.write_all(b"\n")
}

/// Write the buffer, reset to the default style after each
fn write_stream(
    MdStream(stream): &MdStream<'_>,
    buf: &mut Buffer,
    default: Option<&ColorSpec>,
    indent: usize,
) -> io::Result<()> {
    match default {
        Some(c) => buf.set_color(c)?,
        None => buf.reset()?,
    }

    for tt in stream {
        write_tt(tt, buf, indent)?;
        if let Some(c) = default {
            buf.set_color(c)?;
        }
    }

    buf.reset()?;
    Ok(())
}

fn write_tt(tt: &MdTree<'_>, buf: &mut Buffer, indent: usize) -> io::Result<()> {
    match tt {
        MdTree::CodeBlock { txt, lang: _ } => {
            buf.set_color(ColorSpec::new().set_dimmed(true))?;
            buf.write_all(txt.as_bytes())?;
        }
        MdTree::CodeInline(txt) => {
            buf.set_color(ColorSpec::new().set_dimmed(true))?;
            write_wrapping(buf, txt, indent, None)?;
        }
        MdTree::Strong(txt) => {
            buf.set_color(ColorSpec::new().set_bold(true))?;
            write_wrapping(buf, txt, indent, None)?;
        }
        MdTree::Emphasis(txt) => {
            buf.set_color(ColorSpec::new().set_italic(true))?;
            write_wrapping(buf, txt, indent, None)?;
        }
        MdTree::Strikethrough(txt) => {
            buf.set_color(ColorSpec::new().set_strikethrough(true))?;
            write_wrapping(buf, txt, indent, None)?;
        }
        MdTree::PlainText(txt) => {
            write_wrapping(buf, txt, indent, None)?;
        }
        MdTree::Link { disp, link } => {
            write_wrapping(buf, disp, indent, Some(link))?;
        }
        MdTree::ParagraphBreak => {
            buf.write_all(b"\n\n")?;
            reset_cursor();
        }
        MdTree::LineBreak => {
            buf.write_all(b"\n")?;
            reset_cursor();
        }
        MdTree::HorizontalRule => {
            (0..WIDTH.with(Cell::get)).for_each(|_| buf.write_all(b"-").unwrap());
            reset_cursor();
        }
        MdTree::Heading(n, stream) => {
            let mut cs = ColorSpec::new();
            cs.set_fg(Some(Color::Cyan));
            match n {
                1 => cs.set_intense(true).set_bold(true).set_underline(true),
                2 => cs.set_intense(true).set_underline(true),
                3 => cs.set_intense(true).set_italic(true),
                4.. => cs.set_underline(true).set_italic(true),
                0 => unreachable!(),
            };
            write_stream(stream, buf, Some(&cs), 0)?;
            buf.write_all(b"\n")?;
        }
        MdTree::OrderedListItem(n, stream) => {
            let base = format!("{n}. ");
            write_wrapping(buf, &format!("{base:<4}"), indent, None)?;
            write_stream(stream, buf, None, indent + 4)?;
        }
        MdTree::UnorderedListItem(stream) => {
            let base = "* ";
            write_wrapping(buf, &format!("{base:<4}"), indent, None)?;
            write_stream(stream, buf, None, indent + 4)?;
        }
        // Patterns popped in previous step
        MdTree::Comment(_) | MdTree::LinkDef { .. } | MdTree::RefLink { .. } => unreachable!(),
    }

    buf.reset()?;

    Ok(())
}

/// End of that block, just wrap the line
fn reset_cursor() {
    CURSOR.with(|cur| cur.set(0));
}

/// Change to be generic on Write for testing. If we have a link URL, we don't
/// count the extra tokens to make it clickable.
fn write_wrapping<B: io::Write>(
    buf: &mut B,
    text: &str,
    indent: usize,
    link_url: Option<&str>,
) -> io::Result<()> {
    let ind_ws = &b"          "[..indent];
    let mut to_write = text;
    if let Some(url) = link_url {
        // This is a nonprinting prefix so we don't increment our cursor
        write!(buf, "\x1b]8;;{url}\x1b\\")?;
    }
    CURSOR.with(|cur| {
        loop {
            if cur.get() == 0 {
                buf.write_all(ind_ws)?;
                cur.set(indent);
            }
            let ch_count = WIDTH.with(Cell::get) - cur.get();
            let mut iter = to_write.char_indices();
            let Some((end_idx, _ch)) = iter.nth(ch_count) else {
                // Write entire line
                buf.write_all(to_write.as_bytes())?;
                cur.set(cur.get() + to_write.chars().count());
                break;
            };

            if let Some((break_idx, ch)) = to_write[..end_idx]
                .char_indices()
                .rev()
                .find(|(_idx, ch)| ch.is_whitespace() || ['_', '-'].contains(ch))
            {
                // Found whitespace to break at
                if ch.is_whitespace() {
                    writeln!(buf, "{}", &to_write[..break_idx])?;
                    to_write = to_write[break_idx..].trim_start();
                } else {
                    // Break at a `-` or `_` separator
                    writeln!(buf, "{}", &to_write.get(..break_idx + 1).unwrap_or(to_write))?;
                    to_write = to_write.get(break_idx + 1..).unwrap_or_default().trim_start();
                }
            } else {
                // No whitespace, we need to just split
                let ws_idx =
                    iter.find(|(_, ch)| ch.is_whitespace()).map_or(to_write.len(), |(idx, _)| idx);
                writeln!(buf, "{}", &to_write[..ws_idx])?;
                to_write = to_write.get(ws_idx + 1..).map_or("", str::trim_start);
            }
            cur.set(0);
        }
        if link_url.is_some() {
            buf.write_all(b"\x1b]8;;\x1b\\")?;
        }

        Ok(())
    })
}

#[cfg(test)]
#[path = "tests/term.rs"]
mod tests;