1use crate::ThemeMode;
2use crate::elements::{Alignment, MarkdownElement, TableDef};
3use crate::parser::parse_document;
4use crate::renderer::render_element_with_options;
5
6#[derive(Debug, Clone)]
8struct TableState {
9 headers: Vec<String>,
10 alignments: Vec<Alignment>,
11 rows: Vec<Vec<String>>,
12 rendered_lines: usize,
13}
14
15impl TableState {
16 fn new(header: &str, separator: &str) -> Option<Self> {
17 let headers = split_table_row(header);
18 let separator_cells = split_table_row(separator);
19 if headers.len() < 2
20 || separator_cells.len() != headers.len()
21 || !is_separator_cells(&separator_cells)
22 {
23 return None;
24 }
25
26 let alignments = separator_cells
27 .iter()
28 .map(|cell| parse_table_alignment(cell))
29 .collect();
30
31 Some(TableState {
32 headers: headers
33 .into_iter()
34 .map(|header| header.trim().to_string())
35 .collect(),
36 alignments,
37 rows: Vec::new(),
38 rendered_lines: 0,
39 })
40 }
41
42 fn column_count(&self) -> usize {
43 self.headers.len()
44 }
45
46 fn has_rows(&self) -> bool {
47 !self.rows.is_empty()
48 }
49
50 fn add_row(&mut self, row: Vec<String>) {
51 self.rows.push(pad_table_row(row, self.column_count()));
52 }
53
54 fn render(
55 &self,
56 pending_row: Option<Vec<String>>,
57 width: usize,
58 theme_mode: ThemeMode,
59 code_theme: Option<&str>,
60 ascii_table_borders: bool,
61 ) -> Vec<String> {
62 let mut rows = self.rows.clone();
63 if let Some(row) = pending_row {
64 rows.push(pad_table_row(row, self.column_count()));
65 }
66
67 let table = MarkdownElement::Table(TableDef {
68 headers: self.headers.clone(),
69 alignments: self.alignments.clone(),
70 rows,
71 });
72
73 render_element_with_options(&table, width, theme_mode, code_theme, ascii_table_borders)
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum TableBufferAction {
79 KeepTable,
80 CloseTable,
81}
82
83#[derive(Debug, Clone)]
84struct TableBufferResult {
85 action: TableBufferAction,
86 pending_row: Option<Vec<String>>,
87 consumed_bytes: usize,
88}
89
90fn split_table_row(line: &str) -> Vec<String> {
91 let mut inner = line.trim();
92 if let Some(stripped) = inner.strip_prefix('|') {
93 inner = stripped;
94 }
95 if let Some(stripped) = inner.strip_suffix('|') {
96 inner = stripped;
97 }
98
99 let mut cells = Vec::new();
100 let mut current = String::new();
101 let mut escaping = false;
102
103 for ch in inner.chars() {
104 if escaping {
105 current.push(ch);
106 escaping = false;
107 } else if ch == '\\' {
108 escaping = true;
109 } else if ch == '|' {
110 cells.push(std::mem::take(&mut current));
111 } else {
112 current.push(ch);
113 }
114 }
115 cells.push(current);
116 cells
117}
118
119fn is_separator_cells(cells: &[String]) -> bool {
120 cells.iter().all(|cell| {
121 let trimmed = cell.trim();
122 !trimmed.is_empty()
123 && trimmed
124 .chars()
125 .all(|ch| ch == '-' || ch == ':' || ch == ' ')
126 })
127}
128
129fn parse_table_alignment(cell: &str) -> Alignment {
130 let trimmed = cell.trim();
131 match (trimmed.starts_with(':'), trimmed.ends_with(':')) {
132 (true, true) => Alignment::Center,
133 (false, true) => Alignment::Right,
134 _ => Alignment::Left,
135 }
136}
137
138fn pad_table_row(mut row: Vec<String>, column_count: usize) -> Vec<String> {
139 row.truncate(column_count);
140 while row.len() < column_count {
141 row.push(String::new());
142 }
143 row.into_iter()
144 .map(|cell| cell.trim().to_string())
145 .collect()
146}
147
148fn looks_like_table_row(line: &str) -> bool {
149 let trimmed = line.trim();
150 trimmed.starts_with('|') || trimmed.contains('|')
151}
152
153fn consumed_prefix_for_lines(text: &str, count: usize) -> Option<usize> {
154 let mut seen = 0;
155 for (idx, ch) in text.char_indices() {
156 if ch == '\n' {
157 seen += 1;
158 if seen == count {
159 return Some(idx + ch.len_utf8());
160 }
161 }
162 }
163
164 if text.lines().count() >= count {
165 Some(text.len())
166 } else {
167 None
168 }
169}
170
171fn consume_streamed_table_buffer(buffer: &str, table_state: &mut TableState) -> TableBufferResult {
172 let mut consumed_bytes = 0;
173 let mut rest = buffer;
174
175 while let Some(newline_idx) = rest.find('\n') {
176 let line = &rest[..newline_idx];
177 let trimmed = line.trim();
178 let line_bytes = newline_idx + 1;
179
180 if trimmed.is_empty() {
181 if table_state.has_rows() {
182 return TableBufferResult {
183 action: TableBufferAction::CloseTable,
184 pending_row: None,
185 consumed_bytes: consumed_bytes + line_bytes,
186 };
187 }
188 consumed_bytes += line_bytes;
189 rest = &rest[line_bytes..];
190 continue;
191 }
192
193 if !looks_like_table_row(trimmed) {
194 return TableBufferResult {
195 action: TableBufferAction::CloseTable,
196 pending_row: None,
197 consumed_bytes,
198 };
199 }
200
201 let cells = split_table_row(trimmed);
202 if cells.len() > table_state.column_count() {
203 return TableBufferResult {
204 action: TableBufferAction::CloseTable,
205 pending_row: None,
206 consumed_bytes,
207 };
208 }
209
210 table_state.add_row(cells);
211 consumed_bytes += line_bytes;
212 rest = &rest[line_bytes..];
213 }
214
215 let pending = rest.trim();
216 if pending.is_empty() {
217 return TableBufferResult {
218 action: TableBufferAction::KeepTable,
219 pending_row: None,
220 consumed_bytes,
221 };
222 }
223
224 if looks_like_table_row(pending) {
225 return TableBufferResult {
226 action: TableBufferAction::KeepTable,
227 pending_row: Some(split_table_row(pending)),
228 consumed_bytes,
229 };
230 }
231
232 TableBufferResult {
233 action: TableBufferAction::CloseTable,
234 pending_row: None,
235 consumed_bytes,
236 }
237}
238
239fn prepend_clear_lines(rendered_lines: usize, lines: Vec<String>) -> Vec<String> {
240 if rendered_lines == 0 {
241 return lines;
242 }
243
244 let mut output = Vec::with_capacity(lines.len());
245 for (index, line) in lines.into_iter().enumerate() {
246 if index == 0 {
247 output.push(format!("\x1B[{rendered_lines}A\x1B[2K{line}"));
248 } else {
249 output.push(format!("\x1B[2K{line}"));
250 }
251 }
252 output
253}
254
255fn render_streamed_table(
256 table_state: &mut TableState,
257 pending_row: Option<Vec<String>>,
258 width: usize,
259 theme_mode: ThemeMode,
260 code_theme: Option<&str>,
261 ascii_table_borders: bool,
262) -> Vec<String> {
263 let old_rendered_lines = table_state.rendered_lines;
264 let lines = table_state.render(
265 pending_row,
266 width,
267 theme_mode,
268 code_theme,
269 ascii_table_borders,
270 );
271 table_state.rendered_lines = lines.len();
272 prepend_clear_lines(old_rendered_lines, lines)
273}
274
275fn try_start_streamed_table(buffer: &mut String) -> Option<TableState> {
276 let mut lines = buffer.lines();
277 let header = lines.next()?.trim();
278 let separator = lines.next()?.trim();
279 let table_state = TableState::new(header, separator)?;
280 let consumed = consumed_prefix_for_lines(buffer, 2)?;
281 *buffer = buffer[consumed..].to_string();
282 Some(table_state)
283}
284
285pub struct StreamRenderer {
320 buffer: String,
321 width: usize,
322 theme_mode: ThemeMode,
323 code_theme: Option<String>,
324 ascii_table_borders: bool,
325 rendered_count: usize,
326 current_table: Option<TableState>,
328}
329
330impl StreamRenderer {
331 pub fn new(width: usize, theme_mode: ThemeMode) -> Self {
336 StreamRenderer {
337 buffer: String::new(),
338 width,
339 theme_mode,
340 code_theme: None,
341 ascii_table_borders: false,
342 rendered_count: 0,
343 current_table: None,
344 }
345 }
346
347 pub fn with_code_theme(mut self, theme: &str) -> Self {
351 self.code_theme = Some(theme.to_string());
352 self
353 }
354
355 pub fn with_ascii_table_borders(mut self, ascii: bool) -> Self {
361 self.ascii_table_borders = ascii;
362 self
363 }
364
365 pub fn push(&mut self, text: &str) -> Vec<String> {
371 self.buffer.push_str(text);
372
373 if self.current_table.is_none()
374 && let Some(table_state) = try_start_streamed_table(&mut self.buffer)
375 {
376 self.current_table = Some(table_state);
377 }
378
379 self.emit_complete()
380 }
381
382 pub fn flush_remaining(&mut self) -> Vec<String> {
387 if let Some(mut table_state) = self.current_table.take() {
388 let table_result = consume_streamed_table_buffer(&self.buffer, &mut table_state);
389 let output = render_streamed_table(
390 &mut table_state,
391 table_result.pending_row,
392 self.width,
393 self.theme_mode,
394 self.code_theme.as_deref(),
395 self.ascii_table_borders,
396 );
397 self.buffer.clear();
398 self.rendered_count = 0;
399 return output;
400 }
401
402 if self.buffer.trim().is_empty() {
403 return Vec::new();
404 }
405 if !self.buffer.ends_with('\n') {
406 self.buffer.push('\n');
407 }
408 let elements = parse_document(&self.buffer);
409 let total = elements.len();
410 let new_elements: Vec<_> = elements.into_iter().skip(self.rendered_count).collect();
411 self.rendered_count = total;
412
413 let mut output: Vec<String> = Vec::new();
414 for elem in &new_elements {
415 output.extend(render_element_with_options(
416 elem,
417 self.width,
418 self.theme_mode,
419 self.code_theme.as_deref(),
420 self.ascii_table_borders,
421 ));
422 }
423 self.buffer.clear();
424 self.rendered_count = 0;
425 output
426 }
427
428 fn emit_complete(&mut self) -> Vec<String> {
429 let mut output: Vec<String> = Vec::new();
430
431 if let Some(mut table_state) = self.current_table.take() {
432 let table_result = consume_streamed_table_buffer(&self.buffer, &mut table_state);
433 if table_result.consumed_bytes > 0 {
434 self.buffer = self.buffer[table_result.consumed_bytes..].to_string();
435 }
436
437 let should_render = table_result.pending_row.is_some()
438 || table_state.has_rows()
439 || table_state.rendered_lines > 0
440 || table_result.action == TableBufferAction::CloseTable;
441
442 if should_render {
443 output.extend(render_streamed_table(
444 &mut table_state,
445 table_result.pending_row.clone(),
446 self.width,
447 self.theme_mode,
448 self.code_theme.as_deref(),
449 self.ascii_table_borders,
450 ));
451 }
452
453 if table_result.action == TableBufferAction::KeepTable {
454 self.current_table = Some(table_state);
455 return output;
456 }
457 }
458
459 let (complete, remaining) = split_at_complete_boundary(&self.buffer);
460 if complete.is_empty() {
461 self.buffer = remaining;
462 self.rendered_count = 0;
463 return output;
464 }
465
466 let elements = parse_document(&complete);
467 let total = elements.len();
468 let new_elements: Vec<_> = elements.into_iter().skip(self.rendered_count).collect();
469 self.rendered_count = total;
470
471 for elem in &new_elements {
472 output.extend(render_element_with_options(
473 elem,
474 self.width,
475 self.theme_mode,
476 self.code_theme.as_deref(),
477 self.ascii_table_borders,
478 ));
479 }
480
481 self.buffer = remaining;
482 self.rendered_count = 0;
483 output
484 }
485}
486
487fn split_at_complete_boundary(text: &str) -> (String, String) {
491 if text.is_empty() {
492 return (String::new(), String::new());
493 }
494
495 if let Some(pos) = text.rfind("\n\n") {
499 let prefix = &text[..pos];
500 if let Some(last_line) = prefix.lines().last()
501 && is_table_separator(last_line.trim())
502 {
503 return (String::new(), text.to_string());
504 }
505 return (prefix.to_string(), trim_leading_newlines(&text[pos + 2..]));
506 }
507
508 let lines: Vec<&str> = text.lines().collect();
510 if lines.len() >= 2 {
511 let first = lines[0];
512 if (first.starts_with("```") || first.starts_with("~~~")) && first.len() >= 3 {
513 let fence = &first[..3];
514 for (i, line) in lines.iter().enumerate().skip(1) {
515 if line.trim().starts_with(fence)
516 && line.trim().len() >= 3
517 && line
518 .trim()
519 .chars()
520 .take(3)
521 .all(|c| c == fence.chars().next().unwrap())
522 {
523 let end_pos = text
524 .char_indices()
525 .nth(
526 text.lines()
527 .take(i + 1)
528 .map(|l| l.len() + 1)
529 .sum::<usize>()
530 .saturating_sub(1),
531 )
532 .map(|(idx, _)| idx)
533 .unwrap_or(text.len());
534 return (
535 text[..end_pos].to_string(),
536 trim_leading_newlines(&text[end_pos..]),
537 );
538 }
539 }
540 return (String::new(), text.to_string());
542 }
543 }
544
545 if let Some(table_end) = find_complete_table_end(&lines) {
549 let end_pos = if table_end == lines.len() {
550 text.len()
551 } else {
552 text.char_indices()
553 .nth(
554 text.lines()
555 .take(table_end)
556 .map(|l| l.len() + 1)
557 .sum::<usize>()
558 .saturating_sub(1),
559 )
560 .map(|(idx, _)| idx)
561 .unwrap_or(text.len())
562 };
563 return (
564 text[..end_pos].to_string(),
565 trim_leading_newlines(&text[end_pos..]),
566 );
567 }
568
569 if lines.len() >= 2 {
572 let h = lines[0].trim();
573 let s = lines[1].trim();
574 let hc: Vec<&str> = h.split('|').filter(|c| !c.is_empty()).collect();
575 let sc: Vec<&str> = s.split('|').filter(|c| !c.is_empty()).collect();
576 if hc.len() >= 2
577 && sc.len() >= 2
578 && sc.len() == hc.len()
579 && sc
580 .iter()
581 .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
582 {
583 return (String::new(), text.to_string());
584 }
585 }
586
587 if let Some(def_end) = find_complete_definition_list_end(&lines) {
589 let end_pos = text
590 .char_indices()
591 .nth(
592 text.lines()
593 .take(def_end)
594 .map(|l| l.len() + 1)
595 .sum::<usize>()
596 .saturating_sub(1),
597 )
598 .map(|(idx, _)| idx)
599 .unwrap_or(text.len());
600 return (
601 text[..end_pos].to_string(),
602 trim_leading_newlines(&text[end_pos..]),
603 );
604 }
605
606 if lines.len() >= 2
608 && is_definition_list_term(lines[0].trim())
609 && !lines[1].trim().starts_with(": ")
610 {
611 return (String::new(), text.to_string());
612 }
613
614 if let Some(html_end) = find_complete_html_block_end(&lines) {
616 let end_pos = text
617 .char_indices()
618 .nth(
619 text.lines()
620 .take(html_end)
621 .map(|l| l.len() + 1)
622 .sum::<usize>()
623 .saturating_sub(1),
624 )
625 .map(|(idx, _)| idx)
626 .unwrap_or(text.len());
627 return (
628 text[..end_pos].to_string(),
629 trim_leading_newlines(&text[end_pos..]),
630 );
631 }
632
633 if is_html_block_tag(lines[0].trim()) {
635 return (String::new(), text.to_string());
636 }
637
638 if let Some(code_end) = find_complete_indented_code_end(&lines) {
640 let end_pos = text
641 .char_indices()
642 .nth(
643 text.lines()
644 .take(code_end)
645 .map(|l| l.len() + 1)
646 .sum::<usize>()
647 .saturating_sub(1),
648 )
649 .map(|(idx, _)| idx)
650 .unwrap_or(text.len());
651 return (
652 text[..end_pos].to_string(),
653 trim_leading_newlines(&text[end_pos..]),
654 );
655 }
656
657 if (lines[0].starts_with(" ") || (lines[0].starts_with('\t') && lines[0].len() > 1))
659 && lines.len() == 1
660 {
661 return (String::new(), text.to_string());
662 }
663
664 if let Some(list_end) = find_complete_list_end(&lines) {
666 let end_pos = text
667 .char_indices()
668 .nth(
669 text.lines()
670 .take(list_end)
671 .map(|l| l.len() + 1)
672 .sum::<usize>()
673 .saturating_sub(1),
674 )
675 .map(|(idx, _)| idx)
676 .unwrap_or(text.len());
677 return (
678 text[..end_pos].to_string(),
679 trim_leading_newlines(&text[end_pos..]),
680 );
681 }
682
683 if is_any_list_item(lines[0].trim()) {
685 return (String::new(), text.to_string());
686 }
687
688 if let Some(fn_end) = find_complete_footnote_end(&lines) {
690 let end_pos = text
691 .char_indices()
692 .nth(
693 text.lines()
694 .take(fn_end)
695 .map(|l| l.len() + 1)
696 .sum::<usize>()
697 .saturating_sub(1),
698 )
699 .map(|(idx, _)| idx)
700 .unwrap_or(text.len());
701 return (
702 text[..end_pos].to_string(),
703 trim_leading_newlines(&text[end_pos..]),
704 );
705 }
706
707 if is_footnote_line(lines[0].trim()) {
709 return (String::new(), text.to_string());
710 }
711
712 if let Some(last) = lines.last() {
715 let trimmed = last.trim();
716 if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.as_bytes().get(1) == Some(&b' ')
717 {
718 if lines.len() > 1 {
720 let end_pos = text
721 .char_indices()
722 .nth(
723 text.lines()
724 .take(lines.len() - 1)
725 .map(|l| l.len() + 1)
726 .sum::<usize>()
727 .saturating_sub(1),
728 )
729 .map(|(idx, _)| idx)
730 .unwrap_or(text.len());
731 return (text[..end_pos].to_string(), text[end_pos..].to_string());
732 }
733 return (text.to_string(), String::new());
734 }
735 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
736 return (text.to_string(), String::new());
737 }
738 if trimmed.starts_with('>') {
739 if lines.len() > 1 {
741 let end_pos = text
742 .char_indices()
743 .nth(
744 text.lines()
745 .take(lines.len() - 1)
746 .map(|l| l.len() + 1)
747 .sum::<usize>()
748 .saturating_sub(1),
749 )
750 .map(|(idx, _)| idx)
751 .unwrap_or(text.len());
752 return (text[..end_pos].to_string(), text[end_pos..].to_string());
753 }
754 return (text.to_string(), String::new());
755 }
756 }
757
758 if lines.len() == 1 {
760 let trimmed = lines[0].trim();
761 if trimmed.starts_with('|') && trimmed.ends_with('|') {
762 return (String::new(), text.to_string());
763 }
764 }
765
766 if text.ends_with('\n') {
768 return (text.to_string(), String::new());
769 }
770
771 if let Some(last_nl) = text.rfind('\n') {
774 let prefix = &text[..last_nl];
775 let pre_lines: Vec<&str> = prefix.lines().collect();
776 if let Some(pre_last) = pre_lines.last()
777 && is_standalone_line(pre_last)
778 {
779 return (
780 text[..last_nl + 1].to_string(),
781 text[last_nl + 1..].to_string(),
782 );
783 }
784 }
785
786 (String::new(), text.to_string())
788}
789
790fn is_standalone_line(line: &str) -> bool {
791 let line = line.trim();
792 if line.starts_with('#') {
793 let level = line.chars().take_while(|&c| c == '#').count();
794 return level <= 6 && line.len() > level && line.as_bytes().get(level) == Some(&b' ');
795 }
796 line == "---" || line == "***" || line == "___" || line.starts_with('>')
797}
798
799fn trim_leading_newlines(s: &str) -> String {
800 s.trim_start_matches('\n').to_string()
801}
802
803fn is_table_separator(line: &str) -> bool {
804 let l = line.trim();
805 let cells: Vec<&str> = l.split('|').filter(|s| !s.is_empty()).collect();
806 if cells.is_empty() {
807 return false;
808 }
809 cells
810 .iter()
811 .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
812}
813
814fn find_complete_table_end(lines: &[&str]) -> Option<usize> {
815 if lines.len() < 2 {
816 return None;
817 }
818 let header = lines[0].trim();
819 let sep = lines[1].trim();
820 let header_cells: Vec<&str> = header.split('|').filter(|s| !s.is_empty()).collect();
821 let sep_cells: Vec<&str> = sep.split('|').filter(|s| !s.is_empty()).collect();
822 if header_cells.len() < 2 || sep_cells.len() != header_cells.len() {
823 return None;
824 }
825 let is_valid_sep = sep_cells
826 .iter()
827 .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '));
828 if !is_valid_sep {
829 return None;
830 }
831 let header_cols = header_cells.len();
832 let mut seen_data = false;
833 for (i, tmp) in lines.iter().enumerate().skip(2) {
834 let tmp = tmp.trim();
835 if tmp.is_empty() {
836 if seen_data {
837 return Some(i + 1);
838 }
839 continue;
840 }
841 seen_data = true;
842 let row_cells: Vec<&str> = tmp.split('|').filter(|s| !s.is_empty()).collect();
843 if row_cells.is_empty() {
844 return Some(i);
845 }
846 if row_cells.len() != header_cols {
847 return Some(i);
848 }
849 }
850 if seen_data { Some(lines.len()) } else { None }
851}
852
853fn find_complete_definition_list_end(lines: &[&str]) -> Option<usize> {
854 if lines.len() < 2 {
855 return None;
856 }
857 let first = lines[0].trim();
858 if first.starts_with('#')
859 || first.starts_with('>')
860 || first.starts_with('|')
861 || first.starts_with('-')
862 || first.starts_with('*')
863 || first.starts_with('`')
864 || first.is_empty()
865 {
866 return None;
867 }
868 if !lines[1].trim().starts_with(": ") {
869 return None;
870 }
871 let mut i = 2;
872 while i < lines.len() {
873 let tmp = lines[i].trim();
874 if tmp.starts_with(": ") {
875 i += 1;
876 } else if tmp.is_empty() {
877 return Some(i + 1);
878 } else {
879 return Some(i);
880 }
881 }
882 None
883}
884
885fn find_complete_html_block_end(lines: &[&str]) -> Option<usize> {
886 let first = lines[0].trim();
887 if !first.starts_with('<') {
888 return None;
889 }
890 let rest = &first[1..];
891 let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace())?;
892 let tag = &rest[..tag_end];
893 let lower = tag.to_lowercase();
894 let valid = matches!(
895 lower.as_str(),
896 "div"
897 | "pre"
898 | "table"
899 | "script"
900 | "style"
901 | "section"
902 | "article"
903 | "nav"
904 | "footer"
905 | "header"
906 | "aside"
907 | "main"
908 | "blockquote"
909 | "form"
910 | "fieldset"
911 | "details"
912 | "dialog"
913 | "figure"
914 | "figcaption"
915 | "dl"
916 | "ol"
917 | "ul"
918 | "h1"
919 | "h2"
920 | "h3"
921 | "h4"
922 | "h5"
923 | "h6"
924 );
925 if !valid {
926 return None;
927 }
928 let close = format!("</{}>", tag);
929 for (i, line) in lines.iter().enumerate().skip(1) {
930 if line.to_lowercase().contains(&close) {
931 return Some(i + 1);
932 }
933 if line.trim().is_empty() {
934 return Some(i + 1);
935 }
936 }
937 None
938}
939
940fn find_complete_indented_code_end(lines: &[&str]) -> Option<usize> {
941 let first = lines[0];
942 if !(first.starts_with(" ") || first.starts_with('\t') && first.len() > 1) {
943 return None;
944 }
945 for (i, l) in lines.iter().enumerate().skip(1) {
946 if l.starts_with(" ") || (l.starts_with('\t') && l.len() > 1) {
947 continue;
948 }
949 if l.is_empty() {
950 continue;
951 }
952 return Some(i);
953 }
954 None
955}
956
957fn find_complete_list_end(lines: &[&str]) -> Option<usize> {
958 let first = lines[0].trim();
959 let is_unordered =
960 first.starts_with("* ") || first.starts_with("- ") || first.starts_with("+ ");
961 let is_task = first.starts_with("- [ ] ")
962 || first.starts_with("- [x] ")
963 || first.starts_with("- [X] ")
964 || first.starts_with("* [ ] ")
965 || first.starts_with("* [x] ")
966 || first.starts_with("* [X] ");
967 let is_ordered = first
968 .find(". ")
969 .is_some_and(|pos| first[..pos].parse::<u64>().is_ok());
970
971 if !is_unordered && !is_task && !is_ordered {
972 return None;
973 }
974
975 for (i, tmp) in lines.iter().enumerate().skip(1) {
976 let tmp = tmp.trim();
977 if tmp.is_empty() {
978 return Some(i + 1);
979 }
980
981 if is_unordered || is_task {
982 let still_list = tmp.starts_with("* ")
983 || tmp.starts_with("- ")
984 || tmp.starts_with("+ ")
985 || (is_task
986 && (tmp.starts_with("- [ ] ")
987 || tmp.starts_with("- [x] ")
988 || tmp.starts_with("- [X] ")
989 || tmp.starts_with("* [ ] ")
990 || tmp.starts_with("* [x] ")
991 || tmp.starts_with("* [X] ")));
992 if !still_list {
993 return Some(i);
994 }
995 }
996 if is_ordered
997 && tmp
998 .find(". ")
999 .is_none_or(|pos| tmp[..pos].parse::<u64>().is_err())
1000 {
1001 return Some(i);
1002 }
1003 }
1004 None
1005}
1006
1007fn find_complete_footnote_end(lines: &[&str]) -> Option<usize> {
1008 let first = lines[0].trim();
1009 if !first.starts_with("[^") {
1010 return None;
1011 }
1012 let close_br = first.find("]:")?;
1013 if close_br <= 2 {
1014 return None;
1015 }
1016 for (i, tmp) in lines.iter().enumerate().skip(1) {
1017 if tmp.trim().is_empty() {
1018 return Some(i + 1);
1020 }
1021 if !tmp.starts_with(" ") {
1022 return Some(i);
1023 }
1024 }
1025 None
1026}
1027
1028fn is_definition_list_term(line: &str) -> bool {
1029 let l = line.trim();
1030 !l.starts_with('#')
1031 && !l.starts_with('>')
1032 && !l.starts_with('|')
1033 && !l.starts_with('-')
1034 && !l.starts_with('*')
1035 && !l.starts_with('`')
1036 && !l.is_empty()
1037}
1038
1039fn is_html_block_tag(line: &str) -> bool {
1040 let l = line.trim();
1041 if !l.starts_with('<') {
1042 return false;
1043 }
1044 let rest = &l[1..];
1045 let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace());
1046 let Some(tag_end) = tag_end else { return false };
1047 let tag = &rest[..tag_end];
1048 let lower = tag.to_lowercase();
1049 matches!(
1050 lower.as_str(),
1051 "div"
1052 | "pre"
1053 | "table"
1054 | "script"
1055 | "style"
1056 | "section"
1057 | "article"
1058 | "nav"
1059 | "footer"
1060 | "header"
1061 | "aside"
1062 | "main"
1063 | "blockquote"
1064 | "form"
1065 | "fieldset"
1066 | "details"
1067 | "dialog"
1068 | "figure"
1069 | "figcaption"
1070 | "dl"
1071 | "ol"
1072 | "ul"
1073 | "h1"
1074 | "h2"
1075 | "h3"
1076 | "h4"
1077 | "h5"
1078 | "h6"
1079 )
1080}
1081
1082fn is_any_list_item(line: &str) -> bool {
1083 let l = line.trim();
1084 if l.starts_with("* ") || l.starts_with("- ") || l.starts_with("+ ") {
1086 return true;
1087 }
1088 if l.starts_with("- [ ] ")
1090 || l.starts_with("- [x] ")
1091 || l.starts_with("- [X] ")
1092 || l.starts_with("* [ ] ")
1093 || l.starts_with("* [x] ")
1094 || l.starts_with("* [X] ")
1095 {
1096 return true;
1097 }
1098 l.find(". ")
1100 .is_some_and(|pos| l[..pos].parse::<u64>().is_ok())
1101}
1102
1103fn is_footnote_line(line: &str) -> bool {
1104 let l = line.trim();
1105 if !l.starts_with("[^") {
1106 return false;
1107 }
1108 let close = l.find("]:");
1109 close.is_some_and(|c| c > 2)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 #[test]
1117 fn test_split_at_blank_line() {
1118 let (complete, remaining) = split_at_complete_boundary("hello\n\nworld");
1119 assert_eq!(complete, "hello");
1120 assert_eq!(remaining, "world");
1121 }
1122
1123 #[test]
1124 fn test_split_no_boundary() {
1125 let (complete, remaining) = split_at_complete_boundary("hello world");
1126 assert_eq!(complete, "");
1127 assert_eq!(remaining, "hello world");
1128 }
1129
1130 #[test]
1131 fn test_split_trailing_newline() {
1132 let (complete, remaining) = split_at_complete_boundary("hello\n");
1133 assert_eq!(complete, "hello\n");
1134 assert_eq!(remaining, "");
1135 }
1136
1137 #[test]
1138 fn test_split_complete_fenced_block() {
1139 let input = "```rust\nlet x = 1;\n```\nsome text";
1140 let (complete, remaining) = split_at_complete_boundary(input);
1141 assert!(complete.contains("```"));
1142 assert!(complete.contains("```"));
1143 assert_eq!(remaining, "some text");
1144 }
1145
1146 #[test]
1147 fn test_split_incomplete_fenced_block() {
1148 let input = "```rust\nlet x = 1;\nstill writing";
1149 let (complete, remaining) = split_at_complete_boundary(input);
1150 assert_eq!(complete, "");
1151 assert_eq!(remaining, input);
1152 }
1153
1154 #[test]
1155 fn test_split_complete_table() {
1156 let input = "| a | b |\n|---|---|\n| 1 | 2 |\nnext";
1157 let (complete, remaining) = split_at_complete_boundary(input);
1158 assert!(complete.contains("| a"));
1159 assert!(!complete.ends_with('\n'));
1160 assert_eq!(remaining, "next");
1161 }
1162
1163 #[test]
1164 fn test_split_complete_heading() {
1165 let (complete, remaining) = split_at_complete_boundary("### Hello\nmore");
1166 assert_eq!(complete, "### Hello\n");
1167 assert_eq!(remaining, "more");
1168 }
1169
1170 #[test]
1171 fn test_stream_renderer_paragraph_then_flush() {
1172 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1173 let lines = sr.push("Hello world.");
1174 assert!(lines.is_empty(), "unterminated paragraph should buffer");
1175 let remaining = sr.flush_remaining();
1176 assert!(!remaining.is_empty());
1177 }
1178
1179 #[test]
1180 fn test_stream_renderer_incremental() {
1181 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1182 let lines1 = sr.push("First paragraph.");
1183 assert!(lines1.is_empty() || lines1.iter().any(|l| l.contains("First")));
1184 let lines2 = sr.push("\n\nSecond paragraph.");
1185 assert!(!lines2.is_empty());
1186 let final_lines = sr.flush_remaining();
1187 assert!(!final_lines.is_empty() || lines2.iter().any(|l| l.contains("Second")));
1188 }
1189
1190 #[test]
1191 fn test_stream_renderer_fenced_block() {
1192 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1193 let lines1 = sr.push("```rust\nlet x = 1;\n```\n");
1194 assert!(!lines1.is_empty());
1195 let remaining = sr.flush_remaining();
1196 assert!(remaining.is_empty());
1197 }
1198
1199 #[test]
1200 fn test_stream_renderer_table() {
1201 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1202 assert!(
1203 sr.push("| a | b |\n").is_empty(),
1204 "header alone should buffer"
1205 );
1206 assert!(
1207 sr.push("|---|---|\n").is_empty(),
1208 "header+sep should buffer"
1209 );
1210 let lines = sr.push("| 1 | 2 |\n");
1211 assert!(!lines.is_empty(), "table with data rows should emit");
1212 assert!(lines.iter().any(|l| l.contains('│') || l.contains('+')));
1213 }
1214
1215 #[test]
1216 fn test_stream_renderer_table_partial_row_streams_cells() {
1217 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1218
1219 assert!(sr.push("| Lorem | Ipsum | Dolor |\n").is_empty());
1220 assert!(
1221 sr.push("|-------|-------|-------|").is_empty(),
1222 "header and separator should not render an empty table"
1223 );
1224
1225 let partial_first_cell = sr.push("\n| Lor");
1226 assert!(
1227 partial_first_cell.iter().any(|line| line.contains("Lor")),
1228 "partial first cell should render as soon as row content arrives"
1229 );
1230
1231 let partial_second_cell = sr.push("em ipsum | Sed");
1232 assert!(
1233 partial_second_cell
1234 .iter()
1235 .any(|line| line.contains("Lorem ipsum") && line.contains("Sed")),
1236 "partial second cell should render before the row is complete"
1237 );
1238
1239 let complete_row = sr.push(" do | Ut enim |\n");
1240 assert!(
1241 complete_row
1242 .iter()
1243 .any(|line| line.contains("Lorem ipsum") && line.contains("Ut enim")),
1244 "complete row should remain rendered inside the table"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_stream_renderer_table_raw_llm_chunk_shape() {
1250 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1251 let chunks = [
1252 "|", " Lorem", " |", " I", "psum", " |", " D", "olor", " |", "\n|", "-------", "|",
1253 "-------", "|", "-------", "|", "\n|", " Lorem", " ipsum", " dolor", " |", " Sed",
1254 " do", " |", " Ut", " enim", " |",
1255 ];
1256
1257 let mut rendered = Vec::new();
1258 for chunk in chunks {
1259 rendered.extend(sr.push(chunk));
1260 }
1261
1262 assert!(
1263 rendered
1264 .iter()
1265 .any(|line| line.contains("Lorem ipsum dolor")),
1266 "first streamed cell should appear before the final newline"
1267 );
1268 assert!(
1269 rendered.iter().any(|line| line.contains("Sed do")),
1270 "second streamed cell should appear before the final newline"
1271 );
1272 assert!(
1273 rendered.iter().any(|line| line.contains("Ut enim")),
1274 "third streamed cell should appear before the final newline"
1275 );
1276 }
1277
1278 #[test]
1279 fn test_stream_renderer_ascii_borders() {
1280 let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_ascii_table_borders(true);
1281 sr.push("| a | b |\n");
1282 sr.push("|---|---|\n");
1283 let lines = sr.push("| 1 | 2 |\n");
1284 assert!(lines.iter().any(|l| l.contains('+')));
1285 }
1286
1287 #[test]
1288 fn test_stream_renderer_code_theme() {
1289 let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_code_theme("base16-ocean.dark");
1290 let lines = sr.push("```rust\nlet x = 1;\n```\n");
1291 assert!(!lines.is_empty());
1292 }
1293
1294 #[test]
1295 fn test_stream_renderer_table_updates() {
1296 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1297
1298 assert!(sr.push("| a | b |\n").is_empty());
1300 assert!(sr.push("|---|---|\n").is_empty());
1301
1302 let lines1 = sr.push("| 1 | 2 |\n");
1304 assert!(!lines1.is_empty());
1305 assert!(lines1.iter().any(|l| l.contains('│')));
1306
1307 let lines2 = sr.push("| 3 | 4 |\n");
1309 assert!(!lines2.is_empty());
1310 assert!(
1311 lines2
1312 .first()
1313 .is_some_and(|line| line.starts_with("\x1B[5A\x1B[2K")),
1314 "refresh should move up and clear on the first rendered line"
1315 );
1316 assert!(lines2.iter().any(|l| l.contains('│')));
1317 }
1318
1319 #[test]
1320 fn test_stream_renderer_table_refresh_has_no_standalone_clear_lines() {
1321 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1322
1323 assert!(sr.push("| a | b |\n").is_empty());
1324 assert!(sr.push("|---|---|\n").is_empty());
1325 assert!(!sr.push("| 1 | 2 |\n").is_empty());
1326
1327 let lines = sr.push("| 3 | 4 |\n");
1328 assert!(
1329 !lines.iter().any(|line| line == "\x1B[A\x1B[2K"),
1330 "standalone clear lines do not work with println-based callers"
1331 );
1332 assert!(
1333 lines.iter().all(|line| !line.trim().is_empty()),
1334 "refresh output should contain rendered table lines only"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_stream_renderer_table_trailing_content() {
1340 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1341
1342 assert!(sr.push("| a | b |\n").is_empty());
1344 assert!(sr.push("|---|---|\n").is_empty());
1345
1346 sr.push("| 1 | 2 |\n");
1348
1349 let lines = sr.push("| 3 | 4 |\nsome trailing text");
1352 assert!(!lines.is_empty());
1353 assert!(lines.iter().any(|l| l.contains('│')));
1354
1355 let plain_text: Vec<_> = lines
1358 .iter()
1359 .filter(|l| !l.starts_with('\x1B') && !l.trim().is_empty())
1360 .collect();
1361 for l in plain_text {
1362 assert!(
1363 !l.contains("trailing text"),
1364 "trailing text should not appear yet: {l}"
1365 );
1366 }
1367
1368 let final_lines = sr.push("\n");
1370 assert!(
1371 final_lines.iter().any(|l| l.contains("trailing text")),
1372 "trailing text should render after a terminator"
1373 );
1374 assert!(sr.current_table.is_none(), "table should be closed");
1375 }
1376
1377 #[test]
1378 fn test_stream_renderer_table_data_row_then_flush() {
1379 let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
1380
1381 assert!(sr.push("| Header | Header2 |\n").is_empty());
1383 assert!(sr.push("|---|---|\n").is_empty());
1384 let lines = sr.push("| data1 | data2 |\n");
1385 assert!(!lines.is_empty(), "data row should trigger table emit");
1386 assert!(lines.iter().any(|l| l.contains('│')));
1387
1388 let flushed = sr.flush_remaining();
1390 let all_rendered: Vec<_> = lines.into_iter().chain(flushed).collect();
1393 let raw_data = all_rendered
1394 .iter()
1395 .any(|l| l.contains("data1") && !l.contains('│') && !l.starts_with('\x1B'));
1396 assert!(
1397 !raw_data,
1398 "data row should only appear inside rendered table"
1399 );
1400 }
1401}