use crate::ThemeMode;
use crate::elements::{Alignment, MarkdownElement, TableDef};
use crate::parser::parse_document;
use crate::renderer::render_element_with_options;
#[derive(Debug, Clone)]
struct TableState {
headers: Vec<String>,
alignments: Vec<Alignment>,
rows: Vec<Vec<String>>,
rendered_lines: usize,
}
impl TableState {
fn new(header: &str, separator: &str) -> Option<Self> {
let headers = split_table_row(header);
let separator_cells = split_table_row(separator);
if headers.len() < 2
|| separator_cells.len() != headers.len()
|| !is_separator_cells(&separator_cells)
{
return None;
}
let alignments = separator_cells
.iter()
.map(|cell| parse_table_alignment(cell))
.collect();
Some(TableState {
headers: headers
.into_iter()
.map(|header| header.trim().to_string())
.collect(),
alignments,
rows: Vec::new(),
rendered_lines: 0,
})
}
fn column_count(&self) -> usize {
self.headers.len()
}
fn has_rows(&self) -> bool {
!self.rows.is_empty()
}
fn add_row(&mut self, row: Vec<String>) {
self.rows.push(pad_table_row(row, self.column_count()));
}
fn render(
&self,
pending_row: Option<Vec<String>>,
width: usize,
theme_mode: ThemeMode,
code_theme: Option<&str>,
ascii_table_borders: bool,
) -> Vec<String> {
let mut rows = self.rows.clone();
if let Some(row) = pending_row {
rows.push(pad_table_row(row, self.column_count()));
}
let table = MarkdownElement::Table(TableDef {
headers: self.headers.clone(),
alignments: self.alignments.clone(),
rows,
});
render_element_with_options(&table, width, theme_mode, code_theme, ascii_table_borders)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TableBufferAction {
KeepTable,
CloseTable,
}
#[derive(Debug, Clone)]
struct TableBufferResult {
action: TableBufferAction,
pending_row: Option<Vec<String>>,
consumed_bytes: usize,
}
fn split_table_row(line: &str) -> Vec<String> {
let mut inner = line.trim();
if let Some(stripped) = inner.strip_prefix('|') {
inner = stripped;
}
if let Some(stripped) = inner.strip_suffix('|') {
inner = stripped;
}
let mut cells = Vec::new();
let mut current = String::new();
let mut escaping = false;
for ch in inner.chars() {
if escaping {
current.push(ch);
escaping = false;
} else if ch == '\\' {
escaping = true;
} else if ch == '|' {
cells.push(std::mem::take(&mut current));
} else {
current.push(ch);
}
}
cells.push(current);
cells
}
fn is_separator_cells(cells: &[String]) -> bool {
cells.iter().all(|cell| {
let trimmed = cell.trim();
!trimmed.is_empty()
&& trimmed
.chars()
.all(|ch| ch == '-' || ch == ':' || ch == ' ')
})
}
fn parse_table_alignment(cell: &str) -> Alignment {
let trimmed = cell.trim();
match (trimmed.starts_with(':'), trimmed.ends_with(':')) {
(true, true) => Alignment::Center,
(false, true) => Alignment::Right,
_ => Alignment::Left,
}
}
fn pad_table_row(mut row: Vec<String>, column_count: usize) -> Vec<String> {
row.truncate(column_count);
while row.len() < column_count {
row.push(String::new());
}
row.into_iter()
.map(|cell| cell.trim().to_string())
.collect()
}
fn looks_like_table_row(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with('|') || trimmed.contains('|')
}
fn consumed_prefix_for_lines(text: &str, count: usize) -> Option<usize> {
let mut seen = 0;
for (idx, ch) in text.char_indices() {
if ch == '\n' {
seen += 1;
if seen == count {
return Some(idx + ch.len_utf8());
}
}
}
if text.lines().count() >= count {
Some(text.len())
} else {
None
}
}
fn consume_streamed_table_buffer(buffer: &str, table_state: &mut TableState) -> TableBufferResult {
let mut consumed_bytes = 0;
let mut rest = buffer;
while let Some(newline_idx) = rest.find('\n') {
let line = &rest[..newline_idx];
let trimmed = line.trim();
let line_bytes = newline_idx + 1;
if trimmed.is_empty() {
if table_state.has_rows() {
return TableBufferResult {
action: TableBufferAction::CloseTable,
pending_row: None,
consumed_bytes: consumed_bytes + line_bytes,
};
}
consumed_bytes += line_bytes;
rest = &rest[line_bytes..];
continue;
}
if !looks_like_table_row(trimmed) {
return TableBufferResult {
action: TableBufferAction::CloseTable,
pending_row: None,
consumed_bytes,
};
}
let cells = split_table_row(trimmed);
if cells.len() > table_state.column_count() {
return TableBufferResult {
action: TableBufferAction::CloseTable,
pending_row: None,
consumed_bytes,
};
}
table_state.add_row(cells);
consumed_bytes += line_bytes;
rest = &rest[line_bytes..];
}
let pending = rest.trim();
if pending.is_empty() {
return TableBufferResult {
action: TableBufferAction::KeepTable,
pending_row: None,
consumed_bytes,
};
}
if looks_like_table_row(pending) {
return TableBufferResult {
action: TableBufferAction::KeepTable,
pending_row: Some(split_table_row(pending)),
consumed_bytes,
};
}
TableBufferResult {
action: TableBufferAction::CloseTable,
pending_row: None,
consumed_bytes,
}
}
fn prepend_clear_lines(rendered_lines: usize, lines: Vec<String>) -> Vec<String> {
if rendered_lines == 0 {
return lines;
}
let mut output = Vec::with_capacity(lines.len());
for (index, line) in lines.into_iter().enumerate() {
if index == 0 {
output.push(format!("\x1B[{rendered_lines}A\x1B[2K{line}"));
} else {
output.push(format!("\x1B[2K{line}"));
}
}
output
}
fn render_streamed_table(
table_state: &mut TableState,
pending_row: Option<Vec<String>>,
width: usize,
theme_mode: ThemeMode,
code_theme: Option<&str>,
ascii_table_borders: bool,
) -> Vec<String> {
let old_rendered_lines = table_state.rendered_lines;
let lines = table_state.render(
pending_row,
width,
theme_mode,
code_theme,
ascii_table_borders,
);
table_state.rendered_lines = lines.len();
prepend_clear_lines(old_rendered_lines, lines)
}
fn try_start_streamed_table(buffer: &mut String) -> Option<TableState> {
let mut lines = buffer.lines();
let header = lines.next()?.trim();
let separator = lines.next()?.trim();
let table_state = TableState::new(header, separator)?;
let consumed = consumed_prefix_for_lines(buffer, 2)?;
*buffer = buffer[consumed..].to_string();
Some(table_state)
}
pub struct StreamRenderer {
buffer: String,
width: usize,
theme_mode: ThemeMode,
code_theme: Option<String>,
ascii_table_borders: bool,
rendered_count: usize,
current_table: Option<TableState>,
}
impl StreamRenderer {
pub fn new(width: usize, theme_mode: ThemeMode) -> Self {
StreamRenderer {
buffer: String::new(),
width,
theme_mode,
code_theme: None,
ascii_table_borders: false,
rendered_count: 0,
current_table: None,
}
}
pub fn with_code_theme(mut self, theme: &str) -> Self {
self.code_theme = Some(theme.to_string());
self
}
pub fn with_ascii_table_borders(mut self, ascii: bool) -> Self {
self.ascii_table_borders = ascii;
self
}
pub fn push(&mut self, text: &str) -> Vec<String> {
self.buffer.push_str(text);
if self.current_table.is_none()
&& let Some(table_state) = try_start_streamed_table(&mut self.buffer)
{
self.current_table = Some(table_state);
}
self.emit_complete()
}
pub fn flush_remaining(&mut self) -> Vec<String> {
if let Some(mut table_state) = self.current_table.take() {
let table_result = consume_streamed_table_buffer(&self.buffer, &mut table_state);
let output = render_streamed_table(
&mut table_state,
table_result.pending_row,
self.width,
self.theme_mode,
self.code_theme.as_deref(),
self.ascii_table_borders,
);
self.buffer.clear();
self.rendered_count = 0;
return output;
}
if self.buffer.trim().is_empty() {
return Vec::new();
}
if !self.buffer.ends_with('\n') {
self.buffer.push('\n');
}
let elements = parse_document(&self.buffer);
let total = elements.len();
let new_elements: Vec<_> = elements.into_iter().skip(self.rendered_count).collect();
self.rendered_count = total;
let mut output: Vec<String> = Vec::new();
for elem in &new_elements {
output.extend(render_element_with_options(
elem,
self.width,
self.theme_mode,
self.code_theme.as_deref(),
self.ascii_table_borders,
));
}
self.buffer.clear();
self.rendered_count = 0;
output
}
fn emit_complete(&mut self) -> Vec<String> {
let mut output: Vec<String> = Vec::new();
if let Some(mut table_state) = self.current_table.take() {
let table_result = consume_streamed_table_buffer(&self.buffer, &mut table_state);
if table_result.consumed_bytes > 0 {
self.buffer = self.buffer[table_result.consumed_bytes..].to_string();
}
let should_render = table_result.pending_row.is_some()
|| table_state.has_rows()
|| table_state.rendered_lines > 0
|| table_result.action == TableBufferAction::CloseTable;
if should_render {
output.extend(render_streamed_table(
&mut table_state,
table_result.pending_row.clone(),
self.width,
self.theme_mode,
self.code_theme.as_deref(),
self.ascii_table_borders,
));
}
if table_result.action == TableBufferAction::KeepTable {
self.current_table = Some(table_state);
return output;
}
}
let (complete, remaining) = split_at_complete_boundary(&self.buffer);
if complete.is_empty() {
self.buffer = remaining;
self.rendered_count = 0;
return output;
}
let elements = parse_document(&complete);
let total = elements.len();
let new_elements: Vec<_> = elements.into_iter().skip(self.rendered_count).collect();
self.rendered_count = total;
for elem in &new_elements {
output.extend(render_element_with_options(
elem,
self.width,
self.theme_mode,
self.code_theme.as_deref(),
self.ascii_table_borders,
));
}
self.buffer = remaining;
self.rendered_count = 0;
output
}
}
fn split_at_complete_boundary(text: &str) -> (String, String) {
if text.is_empty() {
return (String::new(), String::new());
}
if let Some(pos) = text.rfind("\n\n") {
let prefix = &text[..pos];
if let Some(last_line) = prefix.lines().last()
&& is_table_separator(last_line.trim())
{
return (String::new(), text.to_string());
}
return (prefix.to_string(), trim_leading_newlines(&text[pos + 2..]));
}
let lines: Vec<&str> = text.lines().collect();
if lines.len() >= 2 {
let first = lines[0];
if (first.starts_with("```") || first.starts_with("~~~")) && first.len() >= 3 {
let fence = &first[..3];
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim().starts_with(fence)
&& line.trim().len() >= 3
&& line
.trim()
.chars()
.take(3)
.all(|c| c == fence.chars().next().unwrap())
{
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(i + 1)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
}
return (String::new(), text.to_string());
}
}
if let Some(table_end) = find_complete_table_end(&lines) {
let end_pos = if table_end == lines.len() {
text.len()
} else {
text.char_indices()
.nth(
text.lines()
.take(table_end)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len())
};
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
if lines.len() >= 2 {
let h = lines[0].trim();
let s = lines[1].trim();
let hc: Vec<&str> = h.split('|').filter(|c| !c.is_empty()).collect();
let sc: Vec<&str> = s.split('|').filter(|c| !c.is_empty()).collect();
if hc.len() >= 2
&& sc.len() >= 2
&& sc.len() == hc.len()
&& sc
.iter()
.all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
{
return (String::new(), text.to_string());
}
}
if let Some(def_end) = find_complete_definition_list_end(&lines) {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(def_end)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
if lines.len() >= 2
&& is_definition_list_term(lines[0].trim())
&& !lines[1].trim().starts_with(": ")
{
return (String::new(), text.to_string());
}
if let Some(html_end) = find_complete_html_block_end(&lines) {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(html_end)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
if is_html_block_tag(lines[0].trim()) {
return (String::new(), text.to_string());
}
if let Some(code_end) = find_complete_indented_code_end(&lines) {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(code_end)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
if (lines[0].starts_with(" ") || (lines[0].starts_with('\t') && lines[0].len() > 1))
&& lines.len() == 1
{
return (String::new(), text.to_string());
}
if let Some(list_end) = find_complete_list_end(&lines) {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(list_end)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
if is_any_list_item(lines[0].trim()) {
return (String::new(), text.to_string());
}
if let Some(fn_end) = find_complete_footnote_end(&lines) {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(fn_end)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (
text[..end_pos].to_string(),
trim_leading_newlines(&text[end_pos..]),
);
}
if is_footnote_line(lines[0].trim()) {
return (String::new(), text.to_string());
}
if let Some(last) = lines.last() {
let trimmed = last.trim();
if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.as_bytes().get(1) == Some(&b' ')
{
if lines.len() > 1 {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(lines.len() - 1)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (text[..end_pos].to_string(), text[end_pos..].to_string());
}
return (text.to_string(), String::new());
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
return (text.to_string(), String::new());
}
if trimmed.starts_with('>') {
if lines.len() > 1 {
let end_pos = text
.char_indices()
.nth(
text.lines()
.take(lines.len() - 1)
.map(|l| l.len() + 1)
.sum::<usize>()
.saturating_sub(1),
)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
return (text[..end_pos].to_string(), text[end_pos..].to_string());
}
return (text.to_string(), String::new());
}
}
if lines.len() == 1 {
let trimmed = lines[0].trim();
if trimmed.starts_with('|') && trimmed.ends_with('|') {
return (String::new(), text.to_string());
}
}
if text.ends_with('\n') {
return (text.to_string(), String::new());
}
if let Some(last_nl) = text.rfind('\n') {
let prefix = &text[..last_nl];
let pre_lines: Vec<&str> = prefix.lines().collect();
if let Some(pre_last) = pre_lines.last()
&& is_standalone_line(pre_last)
{
return (
text[..last_nl + 1].to_string(),
text[last_nl + 1..].to_string(),
);
}
}
(String::new(), text.to_string())
}
fn is_standalone_line(line: &str) -> bool {
let line = line.trim();
if line.starts_with('#') {
let level = line.chars().take_while(|&c| c == '#').count();
return level <= 6 && line.len() > level && line.as_bytes().get(level) == Some(&b' ');
}
line == "---" || line == "***" || line == "___" || line.starts_with('>')
}
fn trim_leading_newlines(s: &str) -> String {
s.trim_start_matches('\n').to_string()
}
fn is_table_separator(line: &str) -> bool {
let l = line.trim();
let cells: Vec<&str> = l.split('|').filter(|s| !s.is_empty()).collect();
if cells.is_empty() {
return false;
}
cells
.iter()
.all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
}
fn find_complete_table_end(lines: &[&str]) -> Option<usize> {
if lines.len() < 2 {
return None;
}
let header = lines[0].trim();
let sep = lines[1].trim();
let header_cells: Vec<&str> = header.split('|').filter(|s| !s.is_empty()).collect();
let sep_cells: Vec<&str> = sep.split('|').filter(|s| !s.is_empty()).collect();
if header_cells.len() < 2 || sep_cells.len() != header_cells.len() {
return None;
}
let is_valid_sep = sep_cells
.iter()
.all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '));
if !is_valid_sep {
return None;
}
let header_cols = header_cells.len();
let mut seen_data = false;
for (i, tmp) in lines.iter().enumerate().skip(2) {
let tmp = tmp.trim();
if tmp.is_empty() {
if seen_data {
return Some(i + 1);
}
continue;
}
seen_data = true;
let row_cells: Vec<&str> = tmp.split('|').filter(|s| !s.is_empty()).collect();
if row_cells.is_empty() {
return Some(i);
}
if row_cells.len() != header_cols {
return Some(i);
}
}
if seen_data { Some(lines.len()) } else { None }
}
fn find_complete_definition_list_end(lines: &[&str]) -> Option<usize> {
if lines.len() < 2 {
return None;
}
let first = lines[0].trim();
if first.starts_with('#')
|| first.starts_with('>')
|| first.starts_with('|')
|| first.starts_with('-')
|| first.starts_with('*')
|| first.starts_with('`')
|| first.is_empty()
{
return None;
}
if !lines[1].trim().starts_with(": ") {
return None;
}
let mut i = 2;
while i < lines.len() {
let tmp = lines[i].trim();
if tmp.starts_with(": ") {
i += 1;
} else if tmp.is_empty() {
return Some(i + 1);
} else {
return Some(i);
}
}
None
}
fn find_complete_html_block_end(lines: &[&str]) -> Option<usize> {
let first = lines[0].trim();
if !first.starts_with('<') {
return None;
}
let rest = &first[1..];
let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace())?;
let tag = &rest[..tag_end];
let lower = tag.to_lowercase();
let valid = matches!(
lower.as_str(),
"div"
| "pre"
| "table"
| "script"
| "style"
| "section"
| "article"
| "nav"
| "footer"
| "header"
| "aside"
| "main"
| "blockquote"
| "form"
| "fieldset"
| "details"
| "dialog"
| "figure"
| "figcaption"
| "dl"
| "ol"
| "ul"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
);
if !valid {
return None;
}
let close = format!("</{}>", tag);
for (i, line) in lines.iter().enumerate().skip(1) {
if line.to_lowercase().contains(&close) {
return Some(i + 1);
}
if line.trim().is_empty() {
return Some(i + 1);
}
}
None
}
fn find_complete_indented_code_end(lines: &[&str]) -> Option<usize> {
let first = lines[0];
if !(first.starts_with(" ") || first.starts_with('\t') && first.len() > 1) {
return None;
}
for (i, l) in lines.iter().enumerate().skip(1) {
if l.starts_with(" ") || (l.starts_with('\t') && l.len() > 1) {
continue;
}
if l.is_empty() {
continue;
}
return Some(i);
}
None
}
fn find_complete_list_end(lines: &[&str]) -> Option<usize> {
let first = lines[0].trim();
let is_unordered =
first.starts_with("* ") || first.starts_with("- ") || first.starts_with("+ ");
let is_task = first.starts_with("- [ ] ")
|| first.starts_with("- [x] ")
|| first.starts_with("- [X] ")
|| first.starts_with("* [ ] ")
|| first.starts_with("* [x] ")
|| first.starts_with("* [X] ");
let is_ordered = first
.find(". ")
.is_some_and(|pos| first[..pos].parse::<u64>().is_ok());
if !is_unordered && !is_task && !is_ordered {
return None;
}
for (i, tmp) in lines.iter().enumerate().skip(1) {
let tmp = tmp.trim();
if tmp.is_empty() {
return Some(i + 1);
}
if is_unordered || is_task {
let still_list = tmp.starts_with("* ")
|| tmp.starts_with("- ")
|| tmp.starts_with("+ ")
|| (is_task
&& (tmp.starts_with("- [ ] ")
|| tmp.starts_with("- [x] ")
|| tmp.starts_with("- [X] ")
|| tmp.starts_with("* [ ] ")
|| tmp.starts_with("* [x] ")
|| tmp.starts_with("* [X] ")));
if !still_list {
return Some(i);
}
}
if is_ordered
&& tmp
.find(". ")
.is_none_or(|pos| tmp[..pos].parse::<u64>().is_err())
{
return Some(i);
}
}
None
}
fn find_complete_footnote_end(lines: &[&str]) -> Option<usize> {
let first = lines[0].trim();
if !first.starts_with("[^") {
return None;
}
let close_br = first.find("]:")?;
if close_br <= 2 {
return None;
}
for (i, tmp) in lines.iter().enumerate().skip(1) {
if tmp.trim().is_empty() {
return Some(i + 1);
}
if !tmp.starts_with(" ") {
return Some(i);
}
}
None
}
fn is_definition_list_term(line: &str) -> bool {
let l = line.trim();
!l.starts_with('#')
&& !l.starts_with('>')
&& !l.starts_with('|')
&& !l.starts_with('-')
&& !l.starts_with('*')
&& !l.starts_with('`')
&& !l.is_empty()
}
fn is_html_block_tag(line: &str) -> bool {
let l = line.trim();
if !l.starts_with('<') {
return false;
}
let rest = &l[1..];
let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace());
let Some(tag_end) = tag_end else { return false };
let tag = &rest[..tag_end];
let lower = tag.to_lowercase();
matches!(
lower.as_str(),
"div"
| "pre"
| "table"
| "script"
| "style"
| "section"
| "article"
| "nav"
| "footer"
| "header"
| "aside"
| "main"
| "blockquote"
| "form"
| "fieldset"
| "details"
| "dialog"
| "figure"
| "figcaption"
| "dl"
| "ol"
| "ul"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
)
}
fn is_any_list_item(line: &str) -> bool {
let l = line.trim();
if l.starts_with("* ") || l.starts_with("- ") || l.starts_with("+ ") {
return true;
}
if l.starts_with("- [ ] ")
|| l.starts_with("- [x] ")
|| l.starts_with("- [X] ")
|| l.starts_with("* [ ] ")
|| l.starts_with("* [x] ")
|| l.starts_with("* [X] ")
{
return true;
}
l.find(". ")
.is_some_and(|pos| l[..pos].parse::<u64>().is_ok())
}
fn is_footnote_line(line: &str) -> bool {
let l = line.trim();
if !l.starts_with("[^") {
return false;
}
let close = l.find("]:");
close.is_some_and(|c| c > 2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_at_blank_line() {
let (complete, remaining) = split_at_complete_boundary("hello\n\nworld");
assert_eq!(complete, "hello");
assert_eq!(remaining, "world");
}
#[test]
fn test_split_no_boundary() {
let (complete, remaining) = split_at_complete_boundary("hello world");
assert_eq!(complete, "");
assert_eq!(remaining, "hello world");
}
#[test]
fn test_split_trailing_newline() {
let (complete, remaining) = split_at_complete_boundary("hello\n");
assert_eq!(complete, "hello\n");
assert_eq!(remaining, "");
}
#[test]
fn test_split_complete_fenced_block() {
let input = "```rust\nlet x = 1;\n```\nsome text";
let (complete, remaining) = split_at_complete_boundary(input);
assert!(complete.contains("```"));
assert!(complete.contains("```"));
assert_eq!(remaining, "some text");
}
#[test]
fn test_split_incomplete_fenced_block() {
let input = "```rust\nlet x = 1;\nstill writing";
let (complete, remaining) = split_at_complete_boundary(input);
assert_eq!(complete, "");
assert_eq!(remaining, input);
}
#[test]
fn test_split_complete_table() {
let input = "| a | b |\n|---|---|\n| 1 | 2 |\nnext";
let (complete, remaining) = split_at_complete_boundary(input);
assert!(complete.contains("| a"));
assert!(!complete.ends_with('\n'));
assert_eq!(remaining, "next");
}
#[test]
fn test_split_complete_heading() {
let (complete, remaining) = split_at_complete_boundary("### Hello\nmore");
assert_eq!(complete, "### Hello\n");
assert_eq!(remaining, "more");
}
#[test]
fn test_stream_renderer_paragraph_then_flush() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
let lines = sr.push("Hello world.");
assert!(lines.is_empty(), "unterminated paragraph should buffer");
let remaining = sr.flush_remaining();
assert!(!remaining.is_empty());
}
#[test]
fn test_stream_renderer_incremental() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
let lines1 = sr.push("First paragraph.");
assert!(lines1.is_empty() || lines1.iter().any(|l| l.contains("First")));
let lines2 = sr.push("\n\nSecond paragraph.");
assert!(!lines2.is_empty());
let final_lines = sr.flush_remaining();
assert!(!final_lines.is_empty() || lines2.iter().any(|l| l.contains("Second")));
}
#[test]
fn test_stream_renderer_fenced_block() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
let lines1 = sr.push("```rust\nlet x = 1;\n```\n");
assert!(!lines1.is_empty());
let remaining = sr.flush_remaining();
assert!(remaining.is_empty());
}
#[test]
fn test_stream_renderer_table() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
assert!(
sr.push("| a | b |\n").is_empty(),
"header alone should buffer"
);
assert!(
sr.push("|---|---|\n").is_empty(),
"header+sep should buffer"
);
let lines = sr.push("| 1 | 2 |\n");
assert!(!lines.is_empty(), "table with data rows should emit");
assert!(lines.iter().any(|l| l.contains('│') || l.contains('+')));
}
#[test]
fn test_stream_renderer_table_partial_row_streams_cells() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
assert!(sr.push("| Lorem | Ipsum | Dolor |\n").is_empty());
assert!(
sr.push("|-------|-------|-------|").is_empty(),
"header and separator should not render an empty table"
);
let partial_first_cell = sr.push("\n| Lor");
assert!(
partial_first_cell.iter().any(|line| line.contains("Lor")),
"partial first cell should render as soon as row content arrives"
);
let partial_second_cell = sr.push("em ipsum | Sed");
assert!(
partial_second_cell
.iter()
.any(|line| line.contains("Lorem ipsum") && line.contains("Sed")),
"partial second cell should render before the row is complete"
);
let complete_row = sr.push(" do | Ut enim |\n");
assert!(
complete_row
.iter()
.any(|line| line.contains("Lorem ipsum") && line.contains("Ut enim")),
"complete row should remain rendered inside the table"
);
}
#[test]
fn test_stream_renderer_table_raw_llm_chunk_shape() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
let chunks = [
"|", " Lorem", " |", " I", "psum", " |", " D", "olor", " |", "\n|", "-------", "|",
"-------", "|", "-------", "|", "\n|", " Lorem", " ipsum", " dolor", " |", " Sed",
" do", " |", " Ut", " enim", " |",
];
let mut rendered = Vec::new();
for chunk in chunks {
rendered.extend(sr.push(chunk));
}
assert!(
rendered
.iter()
.any(|line| line.contains("Lorem ipsum dolor")),
"first streamed cell should appear before the final newline"
);
assert!(
rendered.iter().any(|line| line.contains("Sed do")),
"second streamed cell should appear before the final newline"
);
assert!(
rendered.iter().any(|line| line.contains("Ut enim")),
"third streamed cell should appear before the final newline"
);
}
#[test]
fn test_stream_renderer_ascii_borders() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_ascii_table_borders(true);
sr.push("| a | b |\n");
sr.push("|---|---|\n");
let lines = sr.push("| 1 | 2 |\n");
assert!(lines.iter().any(|l| l.contains('+')));
}
#[test]
fn test_stream_renderer_code_theme() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_code_theme("base16-ocean.dark");
let lines = sr.push("```rust\nlet x = 1;\n```\n");
assert!(!lines.is_empty());
}
#[test]
fn test_stream_renderer_table_updates() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
assert!(sr.push("| a | b |\n").is_empty());
assert!(sr.push("|---|---|\n").is_empty());
let lines1 = sr.push("| 1 | 2 |\n");
assert!(!lines1.is_empty());
assert!(lines1.iter().any(|l| l.contains('│')));
let lines2 = sr.push("| 3 | 4 |\n");
assert!(!lines2.is_empty());
assert!(
lines2
.first()
.is_some_and(|line| line.starts_with("\x1B[5A\x1B[2K")),
"refresh should move up and clear on the first rendered line"
);
assert!(lines2.iter().any(|l| l.contains('│')));
}
#[test]
fn test_stream_renderer_table_refresh_has_no_standalone_clear_lines() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
assert!(sr.push("| a | b |\n").is_empty());
assert!(sr.push("|---|---|\n").is_empty());
assert!(!sr.push("| 1 | 2 |\n").is_empty());
let lines = sr.push("| 3 | 4 |\n");
assert!(
!lines.iter().any(|line| line == "\x1B[A\x1B[2K"),
"standalone clear lines do not work with println-based callers"
);
assert!(
lines.iter().all(|line| !line.trim().is_empty()),
"refresh output should contain rendered table lines only"
);
}
#[test]
fn test_stream_renderer_table_trailing_content() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
assert!(sr.push("| a | b |\n").is_empty());
assert!(sr.push("|---|---|\n").is_empty());
sr.push("| 1 | 2 |\n");
let lines = sr.push("| 3 | 4 |\nsome trailing text");
assert!(!lines.is_empty());
assert!(lines.iter().any(|l| l.contains('│')));
let plain_text: Vec<_> = lines
.iter()
.filter(|l| !l.starts_with('\x1B') && !l.trim().is_empty())
.collect();
for l in plain_text {
assert!(
!l.contains("trailing text"),
"trailing text should not appear yet: {l}"
);
}
let final_lines = sr.push("\n");
assert!(
final_lines.iter().any(|l| l.contains("trailing text")),
"trailing text should render after a terminator"
);
assert!(sr.current_table.is_none(), "table should be closed");
}
#[test]
fn test_stream_renderer_table_data_row_then_flush() {
let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
assert!(sr.push("| Header | Header2 |\n").is_empty());
assert!(sr.push("|---|---|\n").is_empty());
let lines = sr.push("| data1 | data2 |\n");
assert!(!lines.is_empty(), "data row should trigger table emit");
assert!(lines.iter().any(|l| l.contains('│')));
let flushed = sr.flush_remaining();
let all_rendered: Vec<_> = lines.into_iter().chain(flushed).collect();
let raw_data = all_rendered
.iter()
.any(|l| l.contains("data1") && !l.contains('│') && !l.starts_with('\x1B'));
assert!(
!raw_data,
"data row should only appear inside rendered table"
);
}
}