SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
1851bae verified
Raw
History Blame Contribute Delete
8.29 kB
//! 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);
}
}