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") {
169 let prefix = &text[..pos];
170 if let Some(last_line) = prefix.lines().last()
171 && is_table_separator(last_line.trim())
172 {
173 return (String::new(), text.to_string());
174 }
175 return (prefix.to_string(), trim_leading_newlines(&text[pos + 2..]));
176 }
177
178 let lines: Vec<&str> = text.lines().collect();
180 if lines.len() >= 2 {
181 let first = lines[0];
182 if (first.starts_with("```") || first.starts_with("~~~")) && first.len() >= 3 {
183 let fence = &first[..3];
184 for (i, line) in lines.iter().enumerate().skip(1) {
185 if line.trim().starts_with(fence) && line.trim().len() >= 3
186 && line.trim().chars().take(3).all(|c| c == fence.chars().next().unwrap())
187 {
188 let end_pos = text
189 .char_indices()
190 .nth(text.lines().take(i + 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
191 .map(|(idx, _)| idx)
192 .unwrap_or(text.len());
193 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
194 }
195 }
196 return (String::new(), text.to_string());
198 }
199 }
200
201 if let Some(table_end) = find_complete_table_end(&lines) {
204 let end_pos = text
205 .char_indices()
206 .nth(text.lines().take(table_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
207 .map(|(idx, _)| idx)
208 .unwrap_or(text.len());
209 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
210 }
211
212 if lines.len() >= 2
215 && lines[0].trim().starts_with('|')
216 && lines[0].trim().ends_with('|')
217 && lines[1].trim().starts_with('|')
218 && lines[1].trim().ends_with('|')
219 {
220 let sep = lines[1].trim();
221 let is_separator = sep
222 .chars()
223 .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
224 .count()
225 == 0;
226 if is_separator {
227 return (String::new(), text.to_string());
228 }
229 }
230
231 if let Some(def_end) = find_complete_definition_list_end(&lines) {
233 let end_pos = text
234 .char_indices()
235 .nth(text.lines().take(def_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
236 .map(|(idx, _)| idx)
237 .unwrap_or(text.len());
238 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
239 }
240
241 if lines.len() >= 2
243 && is_definition_list_term(lines[0].trim())
244 && !lines[1].trim().starts_with(": ")
245 {
246 return (String::new(), text.to_string());
247 }
248
249 if let Some(html_end) = find_complete_html_block_end(&lines) {
251 let end_pos = text
252 .char_indices()
253 .nth(text.lines().take(html_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 is_html_block_tag(lines[0].trim()) {
261 return (String::new(), text.to_string());
262 }
263
264 if let Some(code_end) = find_complete_indented_code_end(&lines) {
266 let end_pos = text
267 .char_indices()
268 .nth(text.lines().take(code_end).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(), trim_leading_newlines(&text[end_pos..]));
272 }
273
274 if (lines[0].starts_with(" ") || (lines[0].starts_with('\t') && lines[0].len() > 1))
276 && lines.len() == 1
277 {
278 return (String::new(), text.to_string());
279 }
280
281 if let Some(list_end) = find_complete_list_end(&lines) {
283 let end_pos = text
284 .char_indices()
285 .nth(text.lines().take(list_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
286 .map(|(idx, _)| idx)
287 .unwrap_or(text.len());
288 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
289 }
290
291 if is_any_list_item(lines[0].trim()) {
293 return (String::new(), text.to_string());
294 }
295
296 if let Some(fn_end) = find_complete_footnote_end(&lines) {
298 let end_pos = text
299 .char_indices()
300 .nth(text.lines().take(fn_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
301 .map(|(idx, _)| idx)
302 .unwrap_or(text.len());
303 return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
304 }
305
306 if is_footnote_line(lines[0].trim()) {
308 return (String::new(), text.to_string());
309 }
310
311 if let Some(last) = lines.last() {
314 let trimmed = last.trim();
315 if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.as_bytes().get(1) == Some(&b' ') {
316 if lines.len() > 1 {
318 let end_pos = text
319 .char_indices()
320 .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
321 .map(|(idx, _)| idx)
322 .unwrap_or(text.len());
323 return (text[..end_pos].to_string(), text[end_pos..].to_string());
324 }
325 return (text.to_string(), String::new());
326 }
327 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
328 return (text.to_string(), String::new());
329 }
330 if trimmed.starts_with('>') {
331 if lines.len() > 1 {
333 let end_pos = text
334 .char_indices()
335 .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
336 .map(|(idx, _)| idx)
337 .unwrap_or(text.len());
338 return (text[..end_pos].to_string(), text[end_pos..].to_string());
339 }
340 return (text.to_string(), String::new());
341 }
342 }
343
344 if lines.len() == 1 {
346 let trimmed = lines[0].trim();
347 if trimmed.starts_with('|') && trimmed.ends_with('|') {
348 return (String::new(), text.to_string());
349 }
350 }
351
352 if text.ends_with('\n') {
354 return (text.to_string(), String::new());
355 }
356
357 if let Some(last_nl) = text.rfind('\n') {
360 let prefix = &text[..last_nl];
361 let pre_lines: Vec<&str> = prefix.lines().collect();
362 if let Some(pre_last) = pre_lines.last()
363 && is_standalone_line(pre_last) {
364 return (text[..last_nl + 1].to_string(), text[last_nl + 1..].to_string());
365 }
366 }
367
368 (String::new(), text.to_string())
370}
371
372fn is_standalone_line(line: &str) -> bool {
373 let line = line.trim();
374 if line.starts_with('#') {
375 let level = line.chars().take_while(|&c| c == '#').count();
376 return level <= 6 && line.len() > level && line.as_bytes().get(level) == Some(&b' ');
377 }
378 line == "---" || line == "***" || line == "___" || line.starts_with('>')
379}
380
381fn trim_leading_newlines(s: &str) -> String {
382 s.trim_start_matches('\n').to_string()
383}
384
385fn is_table_separator(line: &str) -> bool {
386 let l = line.trim();
387 if !l.starts_with('|') || !l.ends_with('|') {
388 return false;
389 }
390 l.chars()
391 .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
392 .count()
393 == 0
394}
395
396fn find_complete_table_end(lines: &[&str]) -> Option<usize> {
397 if lines.len() < 2 {
398 return None;
399 }
400 let header = lines[0].trim();
401 let sep = lines[1].trim();
402 if !header.starts_with('|') || !header.ends_with('|')
403 || !sep.starts_with('|') || !sep.ends_with('|')
404 {
405 return None;
406 }
407 let is_sep = sep
408 .chars()
409 .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
410 .count()
411 == 0;
412 if !is_sep {
413 return None;
414 }
415 let header_cols = header.split('|').filter(|s| !s.is_empty()).count();
416 let mut seen_data = false;
417 for (i, tmp) in lines.iter().enumerate().skip(2) {
418 let tmp = tmp.trim();
419 if tmp.is_empty() {
420 if seen_data {
421 return Some(i + 1);
422 }
423 continue;
424 }
425 seen_data = true;
426 if !tmp.starts_with('|') || !tmp.ends_with('|') {
427 return Some(i);
428 }
429 let cols = tmp.split('|').filter(|s| !s.is_empty()).count();
430 if cols != header_cols {
431 return Some(i);
432 }
433 }
434 None
435}
436
437fn find_complete_definition_list_end(lines: &[&str]) -> Option<usize> {
438 if lines.len() < 2 {
439 return None;
440 }
441 let first = lines[0].trim();
442 if first.starts_with('#') || first.starts_with('>') || first.starts_with('|')
443 || first.starts_with('-') || first.starts_with('*') || first.starts_with('`')
444 || first.is_empty()
445 {
446 return None;
447 }
448 if !lines[1].trim().starts_with(": ") {
449 return None;
450 }
451 let mut i = 2;
452 while i < lines.len() {
453 let tmp = lines[i].trim();
454 if tmp.starts_with(": ") {
455 i += 1;
456 } else if tmp.is_empty() {
457 return Some(i + 1);
458 } else {
459 return Some(i);
460 }
461 }
462 None
463}
464
465fn find_complete_html_block_end(lines: &[&str]) -> Option<usize> {
466 let first = lines[0].trim();
467 if !first.starts_with('<') {
468 return None;
469 }
470 let rest = &first[1..];
471 let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace())?;
472 let tag = &rest[..tag_end];
473 let lower = tag.to_lowercase();
474 let valid = matches!(
475 lower.as_str(),
476 "div" | "pre" | "table" | "script" | "style" | "section"
477 | "article" | "nav" | "footer" | "header" | "aside" | "main"
478 | "blockquote" | "form" | "fieldset" | "details" | "dialog"
479 | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
480 | "h3" | "h4" | "h5" | "h6"
481 );
482 if !valid {
483 return None;
484 }
485 let close = format!("</{}>", tag);
486 for (i, line) in lines.iter().enumerate().skip(1) {
487 if line.to_lowercase().contains(&close) {
488 return Some(i + 1);
489 }
490 if line.trim().is_empty() {
491 return Some(i + 1);
492 }
493 }
494 None
495}
496
497fn find_complete_indented_code_end(lines: &[&str]) -> Option<usize> {
498 let first = lines[0];
499 if !(first.starts_with(" ") || first.starts_with('\t') && first.len() > 1) {
500 return None;
501 }
502 for (i, l) in lines.iter().enumerate().skip(1) {
503 if l.starts_with(" ") || (l.starts_with('\t') && l.len() > 1) {
504 continue;
505 }
506 if l.is_empty() {
507 continue;
508 }
509 return Some(i);
510 }
511 None
512}
513
514fn find_complete_list_end(lines: &[&str]) -> Option<usize> {
515 let first = lines[0].trim();
516 let is_unordered = first.starts_with("* ") || first.starts_with("- ") || first.starts_with("+ ");
517 let is_task = first.starts_with("- [ ] ") || first.starts_with("- [x] ") || first.starts_with("- [X] ")
518 || first.starts_with("* [ ] ") || first.starts_with("* [x] ") || first.starts_with("* [X] ");
519 let is_ordered = first.find(". ").is_some_and(|pos| first[..pos].parse::<u64>().is_ok());
520
521 if !is_unordered && !is_task && !is_ordered {
522 return None;
523 }
524
525 for (i, tmp) in lines.iter().enumerate().skip(1) {
526 let tmp = tmp.trim();
527 if tmp.is_empty() {
528 return Some(i + 1);
529 }
530
531 if is_unordered || is_task {
532 let still_list = tmp.starts_with("* ") || tmp.starts_with("- ") || tmp.starts_with("+ ")
533 || (is_task && (tmp.starts_with("- [ ] ") || tmp.starts_with("- [x] ") || tmp.starts_with("- [X] ")
534 || tmp.starts_with("* [ ] ") || tmp.starts_with("* [x] ") || tmp.starts_with("* [X] ")));
535 if !still_list {
536 return Some(i);
537 }
538 }
539 if is_ordered
540 && tmp.find(". ").is_none_or(|pos| tmp[..pos].parse::<u64>().is_err()) {
541 return Some(i);
542 }
543 }
544 None
545}
546
547fn find_complete_footnote_end(lines: &[&str]) -> Option<usize> {
548 let first = lines[0].trim();
549 if !first.starts_with("[^") {
550 return None;
551 }
552 let close_br = first.find("]:")?;
553 if close_br <= 2 {
554 return None;
555 }
556 for (i, tmp) in lines.iter().enumerate().skip(1) {
557 if tmp.trim().is_empty() {
558 return Some(i + 1);
560 }
561 if !tmp.starts_with(" ") {
562 return Some(i);
563 }
564 }
565 None
566}
567
568fn is_definition_list_term(line: &str) -> bool {
569 let l = line.trim();
570 !l.starts_with('#') && !l.starts_with('>') && !l.starts_with('|')
571 && !l.starts_with('-') && !l.starts_with('*') && !l.starts_with('`')
572 && !l.is_empty()
573}
574
575fn is_html_block_tag(line: &str) -> bool {
576 let l = line.trim();
577 if !l.starts_with('<') {
578 return false;
579 }
580 let rest = &l[1..];
581 let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace());
582 let Some(tag_end) = tag_end else { return false };
583 let tag = &rest[..tag_end];
584 let lower = tag.to_lowercase();
585 matches!(
586 lower.as_str(),
587 "div" | "pre" | "table" | "script" | "style" | "section"
588 | "article" | "nav" | "footer" | "header" | "aside" | "main"
589 | "blockquote" | "form" | "fieldset" | "details" | "dialog"
590 | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
591 | "h3" | "h4" | "h5" | "h6"
592 )
593}
594
595fn is_any_list_item(line: &str) -> bool {
596 let l = line.trim();
597 if l.starts_with("* ") || l.starts_with("- ") || l.starts_with("+ ") {
599 return true;
600 }
601 if l.starts_with("- [ ] ") || l.starts_with("- [x] ") || l.starts_with("- [X] ")
603 || l.starts_with("* [ ] ") || l.starts_with("* [x] ") || l.starts_with("* [X] ")
604 {
605 return true;
606 }
607 l.find(". ").is_some_and(|pos| l[..pos].parse::<u64>().is_ok())
609}
610
611fn is_footnote_line(line: &str) -> bool {
612 let l = line.trim();
613 if !l.starts_with("[^") {
614 return false;
615 }
616 let close = l.find("]:");
617 close.is_some_and(|c| c > 2)
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
625 fn test_split_at_blank_line() {
626 let (complete, remaining) = split_at_complete_boundary("hello\n\nworld");
627 assert_eq!(complete, "hello");
628 assert_eq!(remaining, "world");
629 }
630
631 #[test]
632 fn test_split_no_boundary() {
633 let (complete, remaining) = split_at_complete_boundary("hello world");
634 assert_eq!(complete, "");
635 assert_eq!(remaining, "hello world");
636 }
637
638 #[test]
639 fn test_split_trailing_newline() {
640 let (complete, remaining) = split_at_complete_boundary("hello\n");
641 assert_eq!(complete, "hello\n");
642 assert_eq!(remaining, "");
643 }
644
645 #[test]
646 fn test_split_complete_fenced_block() {
647 let input = "```rust\nlet x = 1;\n```\nsome text";
648 let (complete, remaining) = split_at_complete_boundary(input);
649 assert!(complete.contains("```"));
650 assert!(complete.contains("```"));
651 assert_eq!(remaining, "some text");
652 }
653
654 #[test]
655 fn test_split_incomplete_fenced_block() {
656 let input = "```rust\nlet x = 1;\nstill writing";
657 let (complete, remaining) = split_at_complete_boundary(input);
658 assert_eq!(complete, "");
659 assert_eq!(remaining, input);
660 }
661
662 #[test]
663 fn test_split_complete_table() {
664 let input = "| a | b |\n|---|---|\n| 1 | 2 |\nnext";
665 let (complete, remaining) = split_at_complete_boundary(input);
666 assert!(complete.contains("| a"));
667 assert!(!complete.ends_with('\n'));
668 assert_eq!(remaining, "next");
669 }
670
671 #[test]
672 fn test_split_complete_heading() {
673 let (complete, remaining) = split_at_complete_boundary("### Hello\nmore");
674 assert_eq!(complete, "### Hello\n");
675 assert_eq!(remaining, "more");
676 }
677
678 #[test]
679 fn test_stream_renderer_paragraph_then_flush() {
680 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
681 let lines = sr.push("Hello world.");
682 assert!(lines.is_empty(), "unterminated paragraph should buffer");
683 let remaining = sr.flush_remaining();
684 assert!(!remaining.is_empty());
685 }
686
687 #[test]
688 fn test_stream_renderer_incremental() {
689 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
690 let lines1 = sr.push("First paragraph.");
691 assert!(lines1.is_empty() || lines1.iter().any(|l| l.contains("First")));
692 let lines2 = sr.push("\n\nSecond paragraph.");
693 assert!(!lines2.is_empty());
694 let final_lines = sr.flush_remaining();
695 assert!(!final_lines.is_empty() || lines2.iter().any(|l| l.contains("Second")));
696 }
697
698 #[test]
699 fn test_stream_renderer_fenced_block() {
700 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
701 let lines1 = sr.push("```rust\nlet x = 1;\n```\n");
702 assert!(!lines1.is_empty());
703 let remaining = sr.flush_remaining();
704 assert!(remaining.is_empty());
705 }
706
707 #[test]
708 fn test_stream_renderer_table() {
709 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
710 sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
711 let lines = sr.flush_remaining();
712 assert!(!lines.is_empty());
713 assert!(lines.iter().any(|l| l.contains('│') || l.contains('+')));
714 }
715
716 #[test]
717 fn test_stream_renderer_ascii_borders() {
718 let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_ascii_table_borders(true);
719 sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
720 let lines = sr.flush_remaining();
721 assert!(lines.iter().any(|l| l.contains('+')));
722 }
723
724 #[test]
725 fn test_stream_renderer_code_theme() {
726 let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_code_theme("base16-ocean.dark");
727 let lines = sr.push("```rust\nlet x = 1;\n```\n");
728 assert!(!lines.is_empty());
729 }
730}