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