Skip to main content

smart_markdown/
stream_renderer.rs

1use crate::ThemeMode;
2use crate::elements::{Alignment, MarkdownElement, TableDef};
3use crate::parser::parse_document;
4use crate::renderer::render_element_with_options;
5
6/// State for tracking a table while rows are still being streamed.
7#[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
285/// Incrementally renders markdown text chunks as they arrive.
286///
287/// `StreamRenderer` is designed for streaming LLM responses: as the model
288/// generates markdown text chunk by chunk, this renderer produces complete,
289/// renderable lines as soon as enough input has been buffered to form a
290/// complete markdown element (e.g. a paragraph ended by a blank line, a
291/// complete table, a closed fenced code block).
292///
293/// # Examples
294///
295/// ```rust
296/// use smart_markdown::{StreamRenderer, ThemeMode, is_light_terminal};
297///
298/// let width = terminal_size::terminal_size()
299///     .map(|(w, _)| w.0 as usize)
300///     .unwrap_or(80);
301/// let theme = if is_light_terminal() { ThemeMode::Light } else { ThemeMode::Dark };
302/// let mut sr = StreamRenderer::new(width, theme)
303///     .with_ascii_table_borders(true)
304///     .with_code_theme("base16-ocean.dark");
305///
306/// // Feed chunks as they arrive from the LLM
307/// for line in sr.push("# Hello\n\n") {
308///     println!("{line}");
309/// }
310/// for line in sr.push("this is **bold** text") {
311///     println!("{line}");
312/// }
313///
314/// // Flush anything still buffered at the end
315/// for line in sr.flush_remaining() {
316///     println!("{line}");
317/// }
318/// ```
319pub 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    /// Tracks the current incomplete table being streamed
327    current_table: Option<TableState>,
328}
329
330impl StreamRenderer {
331    /// Create a new stream renderer.
332    ///
333    /// - `width`: terminal width in columns (e.g. from the `terminal_size` crate).
334    /// - `theme_mode`: controls syntax highlighting theme for code blocks.
335    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    /// Set a custom syntax highlighting theme by name.
348    ///
349    /// See [`crate::highlight::list_themes`] for available theme names.
350    pub fn with_code_theme(mut self, theme: &str) -> Self {
351        self.code_theme = Some(theme.to_string());
352        self
353    }
354
355    /// Use ASCII-only table borders (`+`, `-`, `|`) instead of Unicode
356    /// box-drawing characters (`┌`, `─`, `│`, etc.).
357    ///
358    /// Useful for terminals where Unicode box-drawing renders poorly
359    /// (e.g. light-background themes without proper color inversion).
360    pub fn with_ascii_table_borders(mut self, ascii: bool) -> Self {
361        self.ascii_table_borders = ascii;
362        self
363    }
364
365    /// Push additional text chunks.
366    ///
367    /// Returns rendered complete lines as they become available.
368    /// Incomplete markdown (partial fenced blocks, tables, paragraphs)
369    /// is buffered internally.
370    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    /// Flush any remaining buffered content and return the final lines.
383    ///
384    /// Call this once at the end of the stream to emit any markdown that
385    /// hasn't been completed by a blank line or structural close.
386    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
487/// Split buffer at the last complete markdown element boundary.
488/// Returns (complete_prefix, remainder) where complete_prefix ends at a
489/// safe boundary (blank line, end of a fenced block, etc.).
490fn split_at_complete_boundary(text: &str) -> (String, String) {
491    if text.is_empty() {
492        return (String::new(), String::new());
493    }
494
495    // Find the last double-newline (blank line) boundary — safe for most elements,
496    // but must not split a table (header|sep without data rows would emit a
497    // zero-row border box, then orphan subsequent content).
498    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    // Check for completed fenced code block (``` or ~~~).
509    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            // Fenced block started but not closed — buffer it entirely
541            return (String::new(), text.to_string());
542        }
543    }
544
545    // Check for table. A table has: header line, separator line, then rows.
546    // Complete when a terminator follows data rows, or when data rows are
547    // present and the buffer ends (LLM streams don't add trailing blank lines).
548    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    // Guard: incomplete table — header + separator detected but find_complete_table_end
570    // found no terminator (blank line or non-table line). Buffer everything unconditionally.
571    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    // Check for definition list — needs the term line + at least one ": " definition line
588    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    // Guard: incomplete definition list — term present but no ": " definition line yet
607    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    // Check for HTML block — starts with a tag like <div>, needs closing </div> or blank line
615    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    // Guard: incomplete HTML block — opening tag present but no closing tag or blank line
634    if is_html_block_tag(lines[0].trim()) {
635        return (String::new(), text.to_string());
636    }
637
638    // Check for indented code block — followed by non-indented, non-empty line or blank line
639    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    // Guard: incomplete indented code block — first line is indented but no end marker yet
658    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    // Check for complete lists (ordered, unordered, task) — a list ends when a non-list line appears
665    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    // Guard: incomplete list — first line is a list item but no terminator yet
684    if is_any_list_item(lines[0].trim()) {
685        return (String::new(), text.to_string());
686    }
687
688    // Check for footnote definitions — they can be multiline (continuation lines indented)
689    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    // Guard: incomplete footnote — [^label]: line present but continuation/content still arriving
708    if is_footnote_line(lines[0].trim()) {
709        return (String::new(), text.to_string());
710    }
711
712    // Single-line elements: headings, horizontal rules, blockquotes (single line), paragraphs
713    // If the last line is a heading or HR, emit everything
714    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            // ATX heading — complete as a single line. Split off heading + preceding lines.
719            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            // Blockquote: emit everything before the blockquote line
740            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    // Guard: single-line table header — looks like table row start, buffer for future chunks
759    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 the text ends with a newline, it's a complete paragraph or set of paragraphs
767    if text.ends_with('\n') {
768        return (text.to_string(), String::new());
769    }
770
771    // Scan backwards from the last \n to find a complete element boundary.
772    // If the preceding line looks standalone (heading, HR, blockquote), split there.
773    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    // Buffer the text — more may arrive that belongs to the same paragraph
787    (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            // blank line ends footnote
1019            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    // Unordered
1085    if l.starts_with("* ") || l.starts_with("- ") || l.starts_with("+ ") {
1086        return true;
1087    }
1088    // Task
1089    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    // Ordered
1099    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        // Push table header and separator
1299        assert!(sr.push("| a | b |\n").is_empty());
1300        assert!(sr.push("|---|---|\n").is_empty());
1301
1302        // Push first data row - should render table
1303        let lines1 = sr.push("| 1 | 2 |\n");
1304        assert!(!lines1.is_empty());
1305        assert!(lines1.iter().any(|l| l.contains('│')));
1306
1307        // Push second data row - should re-render table with both rows
1308        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        // Push table header and separator
1343        assert!(sr.push("| a | b |\n").is_empty());
1344        assert!(sr.push("|---|---|\n").is_empty());
1345
1346        // Push first data row - should render table
1347        sr.push("| 1 | 2 |\n");
1348
1349        // Push second data row followed by a non-table line — this
1350        // terminates the table and renders it.
1351        let lines = sr.push("| 3 | 4 |\nsome trailing text");
1352        assert!(!lines.is_empty());
1353        assert!(lines.iter().any(|l| l.contains('│')));
1354
1355        // The trailing text is incomplete (no newline terminator)
1356        // and should not appear in this output.
1357        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        // Complete the trailing text with a newline — now it should emit.
1369        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        // Push table header, separator, and a data row
1382        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        // Flush should finalize the table and NOT leak the data row
1389        let flushed = sr.flush_remaining();
1390        // Flushed should show the already-rendered table
1391        // and not contain raw data row text outside table borders
1392        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}