use super::*;
use std::collections::HashSet;
const MAX_PREVIEW_ROWS: usize = 50;
const MAX_COLS: usize = 20;
const MAX_COL_WIDTH: usize = 30;
pub(in crate::preview) fn build_csv_preview(
text: &str,
is_tsv: bool,
type_detail: Option<&'static str>,
bytes_truncated: bool,
) -> PreviewContent {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let sep = if is_tsv { '\t' } else { ',' };
let mut records = parse_delimited(text, sep);
let detail = type_detail.unwrap_or(if is_tsv { "TSV file" } else { "CSV file" });
if records.is_empty() {
return PreviewContent::new(PreviewKind::Data, vec![Line::from("Empty file")])
.with_detail(detail);
}
let rows_truncated = records.len() > MAX_PREVIEW_ROWS;
if rows_truncated {
records.truncate(MAX_PREVIEW_ROWS);
}
let col_count = records
.iter()
.map(|r| r.len())
.max()
.unwrap_or(0)
.min(MAX_COLS);
if col_count == 0 {
return PreviewContent::new(PreviewKind::Data, vec![Line::from("No columns")])
.with_detail(detail);
}
let records: Vec<Vec<String>> = records
.into_iter()
.map(|mut r| {
r.truncate(col_count);
r.resize(col_count, String::new());
r
})
.collect();
let (headers, data_rows) =
if records.len() >= 2 && looks_like_header(&records[0], &records[1..]) {
let mut it = records.into_iter();
let h = it.next().unwrap();
(h, it.collect::<Vec<_>>())
} else {
let synthetic = (1..=col_count).map(|i| format!("col{i}")).collect();
(synthetic, records)
};
let row_count = data_rows.len();
let mut col_widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
for row in &data_rows {
for (i, cell) in row.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(cell.chars().count());
}
}
}
for w in &mut col_widths {
*w = (*w).min(MAX_COL_WIDTH);
}
let is_numeric: Vec<bool> = (0..col_count)
.map(|i| {
let has_any = data_rows.iter().any(|r| !r[i].is_empty());
has_any
&& data_rows
.iter()
.all(|r| r[i].is_empty() || r[i].trim().parse::<f64>().is_ok())
})
.collect();
let palette = theme::palette();
let mut lines = Vec::new();
lines.push(render_row(
&headers,
&col_widths,
&is_numeric,
true,
palette,
));
let sep_line: String = col_widths
.iter()
.map(|w| "─".repeat(*w))
.collect::<Vec<_>>()
.join(" ");
lines.push(Line::from(Span::styled(
sep_line,
Style::default().fg(palette.muted),
)));
for row in &data_rows {
lines.push(render_row(row, &col_widths, &is_numeric, false, palette));
}
lines.push(Line::from(""));
let footer = match (bytes_truncated, rows_truncated) {
(true, _) => format!("first {row_count} rows · {col_count} columns (truncated at 64 KiB)"),
(false, true) => {
format!("first {row_count} rows · {col_count} columns (more rows in file)")
}
(false, false) => format!("{row_count} rows · {col_count} columns"),
};
lines.push(Line::from(Span::styled(
footer,
Style::default().fg(palette.muted),
)));
PreviewContent::new(PreviewKind::Data, lines).with_detail(detail)
}
fn parse_delimited(text: &str, sep: char) -> Vec<Vec<String>> {
let mut records = Vec::new();
let mut chars = text.chars().peekable();
while chars.peek().is_some() {
if records.len() > MAX_PREVIEW_ROWS {
break;
}
let record = parse_record(&mut chars, sep);
if record.iter().any(|f| !f.is_empty()) || chars.peek().is_some() {
records.push(record);
}
}
records
}
fn parse_record(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, sep: char) -> Vec<String> {
let mut fields = Vec::new();
loop {
let (field, end_of_record) = parse_field(chars, sep);
fields.push(field);
if end_of_record || chars.peek().is_none() {
break;
}
}
fields
}
fn parse_field(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, sep: char) -> (String, bool) {
match chars.peek().copied() {
None => (String::new(), true),
Some('"') => {
chars.next();
let mut field = String::new();
loop {
match chars.next() {
None => break,
Some('"') => {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
break;
}
}
Some(c) => field.push(c),
}
}
(field, advance_past_separator(chars, sep))
}
_ => {
let mut field = String::new();
loop {
match chars.peek().copied() {
None => return (field, true),
Some(c) if c == sep => {
chars.next();
return (field, false);
}
Some('\n') => {
chars.next();
return (field, true);
}
Some('\r') => {
chars.next();
if chars.peek() == Some(&'\n') {
chars.next();
}
return (field, true);
}
Some(c) => {
chars.next();
field.push(c);
}
}
}
}
}
}
fn advance_past_separator(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, sep: char) -> bool {
loop {
match chars.peek().copied() {
None => return true,
Some(c) if c == sep => {
chars.next();
return false;
}
Some('\n') => {
chars.next();
return true;
}
Some('\r') => {
chars.next();
if chars.peek() == Some(&'\n') {
chars.next();
}
return true;
}
_ => {
chars.next();
}
}
}
}
fn looks_like_header(first: &[String], data_rows: &[Vec<String>]) -> bool {
if data_rows.is_empty() {
return false;
}
if first.iter().any(|v| v.trim().is_empty()) {
return false;
}
let mut seen = HashSet::new();
for v in first {
if !seen.insert(v.to_lowercase()) {
return false;
}
}
if first.iter().any(|v| v.trim().parse::<f64>().is_ok()) {
return false;
}
data_rows.iter().any(|row| {
row.iter()
.any(|v| !v.is_empty() && v.trim().parse::<f64>().is_ok())
})
}
fn render_row(
cells: &[String],
widths: &[usize],
is_numeric: &[bool],
is_header: bool,
palette: theme::Palette,
) -> Line<'static> {
let color = if is_header {
palette.accent
} else {
palette.text
};
let last = cells.len().saturating_sub(1);
let mut spans = Vec::new();
for (i, (cell, width)) in cells.iter().zip(widths.iter()).enumerate() {
let display = truncate_to_width(cell, *width);
let aligned = if is_numeric.get(i).copied().unwrap_or(false) {
format!("{display:>width$}", width = width)
} else {
format!("{display:<width$}", width = width)
};
spans.push(Span::styled(aligned, Style::default().fg(color)));
if i < last {
spans.push(Span::styled(
" ".to_string(),
Style::default().fg(palette.muted),
));
}
}
Line::from(spans)
}
fn truncate_to_width(s: &str, max: usize) -> String {
let char_count = s.chars().count();
if char_count <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{truncated}…")
}