File size: 8,292 Bytes
1851bae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
//! Forge Markdown Stream - Streaming markdown renderer for terminal output.
//!
//! This crate provides a streaming markdown renderer optimized for LLM output.
//! It renders markdown with syntax highlighting, styled headings, tables,
//! lists, and more.
//!
//! # Example
//!
//! ```no_run
//! use forge_markdown_stream::StreamdownRenderer;
//! use std::io;
//!
//! fn main() -> io::Result<()> {
//!     let mut renderer = StreamdownRenderer::new(io::stdout(), 80);
//!
//!     // Push tokens as they arrive from LLM
//!     renderer.push("Hello ")?;
//!     renderer.push("**world**!\n")?;
//!
//!     // Finish rendering
//!     let _ = renderer.finish()?;
//!     Ok(())
//! }
//! ```

mod code;
mod heading;
mod inline;
mod list;
mod renderer;
mod repair;
mod style;
mod table;
mod theme;
mod utils;

use std::io::{self, Write};

pub use renderer::Renderer;
pub use repair::repair_line;
pub use streamdown_parser::Parser;
pub use theme::{Style, Theme};

/// Streaming markdown renderer for terminal output.
///
/// Buffers incoming tokens and renders complete lines with syntax highlighting,
/// styled headings, tables, lists, and more.
///
/// The renderer is generic over the writer type `W`, which must implement
/// `Write`.
pub struct StreamdownRenderer<W: Write> {
    parser: Parser,
    renderer: Renderer<W>,
    line_buffer: String,
}

impl<W: Write> StreamdownRenderer<W> {
    /// Create a new renderer with the given writer and terminal width.
    pub fn new(writer: W, width: usize) -> Self {
        Self {
            parser: Parser::new(),
            renderer: Renderer::new(writer, width),
            line_buffer: String::new(),
        }
    }

    /// Create a new renderer with a custom theme.
    pub fn with_theme(writer: W, width: usize, theme: Theme) -> Self {
        Self {
            parser: Parser::new(),
            renderer: Renderer::with_theme(writer, width, theme),
            line_buffer: String::new(),
        }
    }

    /// Push a token to the renderer.
    ///
    /// Tokens are buffered until a complete line is received, then rendered.
    pub fn push(&mut self, token: &str) -> io::Result<()> {
        self.line_buffer.push_str(token);

        while let Some(pos) = self.line_buffer.find('\n') {
            let line = self.line_buffer.get(..pos).unwrap_or("").to_string();

            for repaired in repair_line(&line, self.parser.state()) {
                for event in self.parser.parse_line(&repaired) {
                    self.renderer.render_event(&event)?;
                }
            }

            self.line_buffer = self.line_buffer.get(pos + 1..).unwrap_or("").to_string();
        }
        Ok(())
    }

    /// Finish rendering, flushing any remaining buffered content.
    /// Returns the underlying writer.
    pub fn finish(mut self) -> io::Result<()> {
        if !self.line_buffer.is_empty() {
            for repaired in repair_line(&self.line_buffer, self.parser.state()) {
                for event in self.parser.parse_line(&repaired) {
                    self.renderer.render_event(&event)?;
                }
            }
        }
        for event in self.parser.finalize() {
            self.renderer.render_event(&event)?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

    use super::StreamdownRenderer;

    fn fixture_rendered_output(markdown: &str, width: usize) -> String {
        let mut output = Vec::new();
        let mut fixture = StreamdownRenderer::new(&mut output, width);
        fixture.push(markdown).unwrap();
        fixture.finish().unwrap();

        let actual = strip_ansi_escapes::strip(output);
        String::from_utf8(actual)
            .unwrap()
            .trim_matches('\n')
            .to_string()
    }

    fn fixture_rendered_output_from_chunks(chunks: &[&str], width: usize) -> String {
        let mut output = Vec::new();
        let mut fixture = StreamdownRenderer::new(&mut output, width);
        for chunk in chunks {
            fixture.push(chunk).unwrap();
        }
        fixture.finish().unwrap();

        let actual = strip_ansi_escapes::strip(output);
        String::from_utf8(actual)
            .unwrap()
            .trim_matches('\n')
            .to_string()
    }

    #[test]
    fn test_streaming_renderer_preserves_korean_spacing_in_structured_markdown() {
        let fixture = concat!(
            "## κ΅¬ν˜„ μš”μ•½\n",
            "- 각 μ„œλΉ„μŠ€μ—μ„œ metadata keyλ₯Ό κ°œλ³„ μˆ˜μ •ν•˜μ§€ μ•Šκ³ , object storage 곡톡 λ ˆμ΄μ–΄μ—μ„œ 일괄 μ •κ·œν™”ν•˜λ„λ‘ λ°˜μ˜ν–ˆμŠ΅λ‹ˆλ‹€.\n",
            "## κ²€ν†  사항\n",
            "- λ³Έ μˆ˜μ •μ€ μ—…λ‘œλ“œ μ‹œ metadata header 이름 문제λ₯Ό ν•΄κ²°ν•©λ‹ˆλ‹€.\n",
            "- 좔가적인 κΆŒν•œ μ •μ±…, bucket policy, reverse proxy μ œν•œμ΄ 있으면 별도 였λ₯˜κ°€ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.\n",
        );
        let actual = fixture_rendered_output(fixture, 200);
        let expected = concat!(
            "## κ΅¬ν˜„ μš”μ•½\n",
            "β€’ 각 μ„œλΉ„μŠ€μ—μ„œ metadata keyλ₯Ό κ°œλ³„ μˆ˜μ •ν•˜μ§€ μ•Šκ³ , object storage 곡톡 λ ˆμ΄μ–΄μ—μ„œ 일괄 μ •κ·œν™”ν•˜λ„λ‘ λ°˜μ˜ν–ˆμŠ΅λ‹ˆλ‹€.\n",
            "\n",
            "## κ²€ν†  사항\n",
            "β€’ λ³Έ μˆ˜μ •μ€ μ—…λ‘œλ“œ μ‹œ metadata header 이름 문제λ₯Ό ν•΄κ²°ν•©λ‹ˆλ‹€.\n",
            "β€’ 좔가적인 κΆŒν•œ μ •μ±…, bucket policy, reverse proxy μ œν•œμ΄ 있으면 별도 였λ₯˜κ°€ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
        );

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_streaming_renderer_preserves_korean_spacing_when_structured_tail_arrives_in_chunks() {
        let fixture = [
            "## κ²€ν†  κ²°κ³Ό\n",
            "- λ³Έ μ‚¬λ‘€λŠ” 슀트리밍 λ§ˆν¬λ‹€μš΄ λ Œλ”λ§μ˜ 곡백 μž¬μ‘°ν•© λ¬Έμ œμ™€ 관련이 μžˆμŠ΅λ‹ˆλ‹€.\n",
            "- 핡심 κ΅¬ν˜„μ€ 곡백 보쑴 λž˜νΌμ— μœ„μΉ˜ν•©λ‹ˆλ‹€.\n",
            "- νšŒκ·€ ν…ŒμŠ€νŠΈλŠ” 슀트리밍 λ Œλ”λŸ¬ 검증 ν•­λͺ©μ— μΆ”κ°€λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.\n\n",
            "후속 μž‘μ—…μ€ λ‹€μŒκ³Ό κ°™μŠ΅λ‹ˆλ‹€.\n",
            "1. λ³€κ²½ 사항을 κ²€ν†  κ°€λŠ₯ν•œ ν˜•μ‹μœΌλ‘œ μ •λ¦¬ν•©λ‹ˆλ‹€.\n",
            "2. μ‹€μ œ λŒ€ν™” 좜λ ₯κ³Ό μœ μ‚¬ν•œ 톡합 ν…ŒμŠ€νŠΈ λ²”μœ„λ₯Ό ",
            "ν™•μž₯ν•©λ‹ˆλ‹€.",
        ];
        let actual = fixture_rendered_output_from_chunks(&fixture, 200);
        let expected = concat!(
            "## κ²€ν†  κ²°κ³Ό\n",
            "β€’ λ³Έ μ‚¬λ‘€λŠ” 슀트리밍 λ§ˆν¬λ‹€μš΄ λ Œλ”λ§μ˜ 곡백 μž¬μ‘°ν•© λ¬Έμ œμ™€ 관련이 μžˆμŠ΅λ‹ˆλ‹€.\n",
            "β€’ 핡심 κ΅¬ν˜„μ€ 곡백 보쑴 λž˜νΌμ— μœ„μΉ˜ν•©λ‹ˆλ‹€.\n",
            "β€’ νšŒκ·€ ν…ŒμŠ€νŠΈλŠ” 슀트리밍 λ Œλ”λŸ¬ 검증 ν•­λͺ©μ— μΆ”κ°€λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.\n",
            "\n",
            "후속 μž‘μ—…μ€ λ‹€μŒκ³Ό κ°™μŠ΅λ‹ˆλ‹€.\n",
            "1. λ³€κ²½ 사항을 κ²€ν†  κ°€λŠ₯ν•œ ν˜•μ‹μœΌλ‘œ μ •λ¦¬ν•©λ‹ˆλ‹€.\n",
            "2. μ‹€μ œ λŒ€ν™” 좜λ ₯κ³Ό μœ μ‚¬ν•œ 톡합 ν…ŒμŠ€νŠΈ λ²”μœ„λ₯Ό ν™•μž₯ν•©λ‹ˆλ‹€.",
        );

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_streaming_renderer_wraps_blockquotes_with_prefix_width_and_long_tokens() {
        let fixture = "> supercalifragilistic\n> ν•œκΈ€ 곡백\n";
        let actual = fixture_rendered_output(fixture, 10);
        let expected = concat!(
            "β”‚ supercal\n",
            "β”‚ ifragili\n",
            "β”‚ stic\n",
            "β”‚ ν•œκΈ€\n",
            "β”‚ 곡백"
        );

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_streaming_renderer_wraps_blockquote_links_without_losing_separator() {
        let fixture = "> [링크](https://example.com/very/long/path) μ„€λͺ…\n";
        let actual = fixture_rendered_output(fixture, 20);
        let expected = concat!(
            "β”‚ 링크\n",
            "β”‚ (https://example.c\n",
            "β”‚ om/very/long/path)\n",
            "β”‚ μ„€λͺ…"
        );

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_streaming_renderer_wraps_nested_blockquotes_with_correct_prefix_width() {
        let fixture = ">> supercalifragilistic\n";
        let actual = fixture_rendered_output(fixture, 12);
        let expected = concat!("β”‚ β”‚ supercal\n", "β”‚ β”‚ ifragili\n", "β”‚ β”‚ stic");

        assert_eq!(actual, expected);
    }
}