1use crate::parser::parse_document;
2use crate::renderer::render_element_with_options;
3use crate::ThemeMode;
4
5pub struct StreamRenderer {
40 buffer: String,
41 width: usize,
42 theme_mode: ThemeMode,
43 code_theme: Option<String>,
44 ascii_table_borders: bool,
45 rendered_count: usize,
46}
47
48impl StreamRenderer {
49 pub fn new(width: usize, theme_mode: ThemeMode) -> Self {
54 StreamRenderer {
55 buffer: String::new(),
56 width,
57 theme_mode,
58 code_theme: None,
59 ascii_table_borders: false,
60 rendered_count: 0,
61 }
62 }
63
64 pub fn with_code_theme(mut self, theme: &str) -> Self {
68 self.code_theme = Some(theme.to_string());
69 self
70 }
71
72 pub fn with_ascii_table_borders(mut self, ascii: bool) -> Self {
78 self.ascii_table_borders = ascii;
79 self
80 }
81
82 pub fn push(&mut self, text: &str) -> Vec<String> {
88 self.buffer.push_str(text);
89 self.emit_complete()
90 }
91
92 pub fn flush_remaining(&mut self) -> Vec<String> {
97 if self.buffer.trim().is_empty() {
98 return Vec::new();
99 }
100 if !self.buffer.ends_with('\n') {
101 self.buffer.push('\n');
102 }
103 let elements = parse_document(&self.buffer);
104 let total = elements.len();
105 let new_elements: Vec<_> = elements
106 .into_iter()
107 .skip(self.rendered_count)
108 .collect();
109 self.rendered_count = total;
110
111 let mut output: Vec<String> = Vec::new();
112 for elem in &new_elements {
113 output.extend(render_element_with_options(
114 elem,
115 self.width,
116 self.theme_mode,
117 self.code_theme.as_deref(),
118 self.ascii_table_borders,
119 ));
120 }
121 self.buffer.clear();
122 self.rendered_count = 0;
123 output
124 }
125
126 fn emit_complete(&mut self) -> Vec<String> {
127 let (complete, remaining) = split_at_complete_boundary(&self.buffer);
128 if complete.is_empty() {
129 return Vec::new();
130 }
131
132 let elements = parse_document(&complete);
133 let total = elements.len();
134 let new_elements: Vec<_> = elements
135 .into_iter()
136 .skip(self.rendered_count)
137 .collect();
138 self.rendered_count = total;
139
140 let mut output: Vec<String> = Vec::new();
141 for elem in &new_elements {
142 output.extend(render_element_with_options(
143 elem,
144 self.width,
145 self.theme_mode,
146 self.code_theme.as_deref(),
147 self.ascii_table_borders,
148 ));
149 }
150
151 self.buffer = remaining;
152 self.rendered_count = 0;
153 output
154 }
155}
156
157fn split_at_complete_boundary(text: &str) -> (String, String) {
161 if text.is_empty() {
162 return (String::new(), String::new());
163 }
164
165 if let Some(pos) = text.rfind("\n\n") {
168 return (text[..pos].to_string(), trim_leading_newlines(&text[pos + 2..]));
169 }
170
171 let lines: Vec<&str> = text.lines().collect();
173 if lines.len() >= 2 {
174 let first = lines[0];
175 if (first.starts_with("```") || first.starts_with("~~~")) && first.len() >= 3 {
176 let fence = &first[..3];
177 for i in 1..lines.len() {
178 if lines[i].trim().starts_with(fence) && lines[i].trim().len() >= 3
179 && lines[i].trim().chars().take(3).all(|c| c == fence.chars().next().unwrap())
180 {
181 let end_pos = text
182 .char_indices()
183 .nth(text.lines().take(i + 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
184 .map(|(idx, _)| idx)
185 .unwrap_or(text.len());
186 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
187 }
188 }
189 return (String::new(), text.to_string());
191 }
192 }
193
194 if let Some(table_end) = find_complete_table_end(&lines) {
197 if table_end < lines.len() {
198 let end_pos = text
199 .char_indices()
200 .nth(text.lines().take(table_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
201 .map(|(idx, _)| idx)
202 .unwrap_or(text.len());
203 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
204 }
205 return (String::new(), text.to_string());
207 }
208
209 if let Some(def_end) = find_complete_definition_list_end(&lines) {
211 let end_pos = text
212 .char_indices()
213 .nth(text.lines().take(def_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
214 .map(|(idx, _)| idx)
215 .unwrap_or(text.len());
216 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
217 }
218
219 if let Some(html_end) = find_complete_html_block_end(&lines) {
221 let end_pos = text
222 .char_indices()
223 .nth(text.lines().take(html_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
224 .map(|(idx, _)| idx)
225 .unwrap_or(text.len());
226 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
227 }
228
229 if let Some(code_end) = find_complete_indented_code_end(&lines) {
231 let end_pos = text
232 .char_indices()
233 .nth(text.lines().take(code_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
234 .map(|(idx, _)| idx)
235 .unwrap_or(text.len());
236 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
237 }
238
239 if let Some(list_end) = find_complete_list_end(&lines) {
241 let end_pos = text
242 .char_indices()
243 .nth(text.lines().take(list_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
244 .map(|(idx, _)| idx)
245 .unwrap_or(text.len());
246 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
247 }
248
249 if let Some(fn_end) = find_complete_footnote_end(&lines) {
251 let end_pos = text
252 .char_indices()
253 .nth(text.lines().take(fn_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
254 .map(|(idx, _)| idx)
255 .unwrap_or(text.len());
256 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
257 }
258
259 if let Some(last) = lines.last() {
262 let trimmed = last.trim();
263 if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.as_bytes().get(1) == Some(&b' ') {
264 if lines.len() > 1 {
266 let end_pos = text
267 .char_indices()
268 .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
269 .map(|(idx, _)| idx)
270 .unwrap_or(text.len());
271 return (text[..end_pos].to_string(), text[end_pos..].to_string());
272 }
273 return (text.to_string(), String::new());
274 }
275 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
276 return (text.to_string(), String::new());
277 }
278 if trimmed.starts_with('>') {
279 if lines.len() > 1 {
281 let end_pos = text
282 .char_indices()
283 .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
284 .map(|(idx, _)| idx)
285 .unwrap_or(text.len());
286 return (text[..end_pos].to_string(), text[end_pos..].to_string());
287 }
288 return (text.to_string(), String::new());
289 }
290 }
291
292 if text.ends_with('\n') {
294 return (text.to_string(), String::new());
295 }
296
297 if let Some(last_nl) = text.rfind('\n') {
300 let prefix = &text[..last_nl];
301 let pre_lines: Vec<&str> = prefix.lines().collect();
302 if let Some(pre_last) = pre_lines.last() {
303 if is_standalone_line(pre_last) {
304 return (text[..last_nl + 1].to_string(), text[last_nl + 1..].to_string());
305 }
306 }
307 }
308
309 (String::new(), text.to_string())
311}
312
313fn is_standalone_line(line: &str) -> bool {
314 let line = line.trim();
315 if line.starts_with('#') {
316 let level = line.chars().take_while(|&c| c == '#').count();
317 return level <= 6 && line.len() > level && line.as_bytes().get(level) == Some(&b' ');
318 }
319 line == "---" || line == "***" || line == "___" || line.starts_with('>')
320}
321
322fn trim_leading_newlines(s: &str) -> String {
323 s.trim_start_matches('\n').to_string()
324}
325
326fn find_complete_table_end(lines: &[&str]) -> Option<usize> {
327 if lines.len() < 2 {
328 return None;
329 }
330 let header = lines[0].trim();
331 let sep = lines[1].trim();
332 if !header.starts_with('|') || !header.ends_with('|')
333 || !sep.starts_with('|') || !sep.ends_with('|')
334 {
335 return None;
336 }
337 let is_sep = sep
338 .chars()
339 .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
340 .count()
341 == 0;
342 if !is_sep {
343 return None;
344 }
345 let header_cols = header.split('|').filter(|s| !s.is_empty()).count();
347 for i in 2..lines.len() {
348 let tmp = lines[i].trim();
349 if tmp.is_empty() {
350 return Some(i + 1);
352 }
353 if !tmp.starts_with('|') || !tmp.ends_with('|') {
354 return Some(i);
356 }
357 let cols = tmp.split('|').filter(|s| !s.is_empty()).count();
358 if cols != header_cols {
359 return Some(i);
360 }
361 }
362 None
364}
365
366fn find_complete_definition_list_end(lines: &[&str]) -> Option<usize> {
367 if lines.len() < 2 {
368 return None;
369 }
370 let first = lines[0].trim();
371 if first.starts_with('#') || first.starts_with('>') || first.starts_with('|')
372 || first.starts_with('-') || first.starts_with('*') || first.starts_with('`')
373 || first.is_empty()
374 {
375 return None;
376 }
377 if !lines[1].trim().starts_with(": ") {
378 return None;
379 }
380 let mut i = 2;
381 while i < lines.len() {
382 let tmp = lines[i].trim();
383 if tmp.starts_with(": ") {
384 i += 1;
385 } else if tmp.is_empty() {
386 return Some(i + 1);
387 } else {
388 return Some(i);
389 }
390 }
391 None
392}
393
394fn find_complete_html_block_end(lines: &[&str]) -> Option<usize> {
395 let first = lines[0].trim();
396 if !first.starts_with('<') {
397 return None;
398 }
399 let rest = &first[1..];
400 let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace())?;
401 let tag = &rest[..tag_end];
402 let lower = tag.to_lowercase();
403 let valid = matches!(
404 lower.as_str(),
405 "div" | "pre" | "table" | "script" | "style" | "section"
406 | "article" | "nav" | "footer" | "header" | "aside" | "main"
407 | "blockquote" | "form" | "fieldset" | "details" | "dialog"
408 | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
409 | "h3" | "h4" | "h5" | "h6"
410 );
411 if !valid {
412 return None;
413 }
414 let close = format!("</{}>", tag);
415 for i in 1..lines.len() {
416 if lines[i].to_lowercase().contains(&close) {
417 return Some(i + 1);
418 }
419 if lines[i].trim().is_empty() {
420 return Some(i + 1);
421 }
422 }
423 None
424}
425
426fn find_complete_indented_code_end(lines: &[&str]) -> Option<usize> {
427 let first = lines[0];
428 if !first.starts_with(" ") && !(first.starts_with('\t') && first.len() > 1) {
429 return None;
430 }
431 for i in 1..lines.len() {
432 let l = lines[i];
433 if l.starts_with(" ") || (l.starts_with('\t') && l.len() > 1) {
434 continue;
435 }
436 if l.is_empty() {
437 continue;
438 }
439 return Some(i);
440 }
441 None
442}
443
444fn find_complete_list_end(lines: &[&str]) -> Option<usize> {
445 let first = lines[0].trim();
446 let is_unordered = first.starts_with("* ") || first.starts_with("- ") || first.starts_with("+ ");
447 let is_task = first.starts_with("- [ ] ") || first.starts_with("- [x] ") || first.starts_with("- [X] ")
448 || first.starts_with("* [ ] ") || first.starts_with("* [x] ") || first.starts_with("* [X] ");
449 let is_ordered = first.find(". ").map_or(false, |pos| first[..pos].parse::<u64>().is_ok());
450
451 if !is_unordered && !is_task && !is_ordered {
452 return None;
453 }
454
455 for i in 1..lines.len() {
456 let tmp = lines[i].trim();
457 if tmp.is_empty() {
458 return Some(i + 1);
459 }
460
461 if is_unordered || is_task {
462 let still_list = tmp.starts_with("* ") || tmp.starts_with("- ") || tmp.starts_with("+ ")
463 || (is_task && (tmp.starts_with("- [ ] ") || tmp.starts_with("- [x] ") || tmp.starts_with("- [X] ")
464 || tmp.starts_with("* [ ] ") || tmp.starts_with("* [x] ") || tmp.starts_with("* [X] ")));
465 if !still_list {
466 return Some(i);
467 }
468 }
469 if is_ordered {
470 if tmp.find(". ").map_or(true, |pos| tmp[..pos].parse::<u64>().is_err()) {
471 return Some(i);
472 }
473 }
474 }
475 None
476}
477
478fn find_complete_footnote_end(lines: &[&str]) -> Option<usize> {
479 let first = lines[0].trim();
480 if !first.starts_with("[^") {
481 return None;
482 }
483 let close_br = first.find("]:")?;
484 if close_br <= 2 {
485 return None;
486 }
487 for i in 1..lines.len() {
488 let tmp = lines[i];
489 if tmp.trim().is_empty() {
490 return Some(i + 1);
492 }
493 if !tmp.starts_with(" ") {
494 return Some(i);
495 }
496 }
497 None
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_split_at_blank_line() {
506 let (complete, remaining) = split_at_complete_boundary("hello\n\nworld");
507 assert_eq!(complete, "hello");
508 assert_eq!(remaining, "world");
509 }
510
511 #[test]
512 fn test_split_no_boundary() {
513 let (complete, remaining) = split_at_complete_boundary("hello world");
514 assert_eq!(complete, "");
515 assert_eq!(remaining, "hello world");
516 }
517
518 #[test]
519 fn test_split_trailing_newline() {
520 let (complete, remaining) = split_at_complete_boundary("hello\n");
521 assert_eq!(complete, "hello\n");
522 assert_eq!(remaining, "");
523 }
524
525 #[test]
526 fn test_split_complete_fenced_block() {
527 let input = "```rust\nlet x = 1;\n```\nsome text";
528 let (complete, remaining) = split_at_complete_boundary(input);
529 assert!(complete.contains("```"));
530 assert!(complete.contains("```"));
531 assert_eq!(remaining, "some text");
532 }
533
534 #[test]
535 fn test_split_incomplete_fenced_block() {
536 let input = "```rust\nlet x = 1;\nstill writing";
537 let (complete, remaining) = split_at_complete_boundary(input);
538 assert_eq!(complete, "");
539 assert_eq!(remaining, input);
540 }
541
542 #[test]
543 fn test_split_complete_table() {
544 let input = "| a | b |\n|---|---|\n| 1 | 2 |\nnext";
545 let (complete, remaining) = split_at_complete_boundary(input);
546 assert!(complete.contains("| a"));
547 assert!(!complete.ends_with('\n'));
548 assert_eq!(remaining, "next");
549 }
550
551 #[test]
552 fn test_split_complete_heading() {
553 let (complete, remaining) = split_at_complete_boundary("### Hello\nmore");
554 assert_eq!(complete, "### Hello\n");
555 assert_eq!(remaining, "more");
556 }
557
558 #[test]
559 fn test_stream_renderer_paragraph_then_flush() {
560 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
561 let lines = sr.push("Hello world.");
562 assert!(lines.is_empty(), "unterminated paragraph should buffer");
563 let remaining = sr.flush_remaining();
564 assert!(!remaining.is_empty());
565 }
566
567 #[test]
568 fn test_stream_renderer_incremental() {
569 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
570 let lines1 = sr.push("First paragraph.");
571 assert!(lines1.is_empty() || lines1.iter().any(|l| l.contains("First")));
572 let lines2 = sr.push("\n\nSecond paragraph.");
573 assert!(!lines2.is_empty());
574 let final_lines = sr.flush_remaining();
575 assert!(!final_lines.is_empty() || lines2.iter().any(|l| l.contains("Second")));
576 }
577
578 #[test]
579 fn test_stream_renderer_fenced_block() {
580 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
581 let lines1 = sr.push("```rust\nlet x = 1;\n```\n");
582 assert!(!lines1.is_empty());
583 let remaining = sr.flush_remaining();
584 assert!(remaining.is_empty());
585 }
586
587 #[test]
588 fn test_stream_renderer_table() {
589 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
590 let lines = sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
591 assert!(!lines.is_empty());
592 assert!(lines.iter().any(|l| l.contains('│') || l.contains('+')));
593 }
594
595 #[test]
596 fn test_stream_renderer_ascii_borders() {
597 let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_ascii_table_borders(true);
598 let lines = sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
599 assert!(lines.iter().any(|l| l.contains('+')));
600 }
601
602 #[test]
603 fn test_stream_renderer_code_theme() {
604 let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_code_theme("base16-ocean.dark");
605 let lines = sr.push("```rust\nlet x = 1;\n```\n");
606 assert!(!lines.is_empty());
607 }
608}