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