use crate::{ColumnStyle, HorizontalAlignment, Table, VerticalAlignment};
pub(crate) const MAX_TABLE_COLUMNS: usize = 100;
pub(crate) const MAX_TABLE_ROWS: usize = 1_000;
struct CellPart {
content: String,
start: usize,
}
fn split_escaped(line: &str, separator: char) -> Vec<CellPart> {
let mut parts = Vec::new();
let mut current_content = String::new();
let mut part_start = 0;
let mut chars = line.char_indices().peekable();
while let Some((byte_idx, ch)) = chars.next() {
if ch == '\\' {
if let Some(&(_, next_ch)) = chars.peek()
&& next_ch == separator
{
current_content.push(separator);
chars.next();
continue;
}
current_content.push(ch);
} else if ch == separator {
parts.push(CellPart {
content: std::mem::take(&mut current_content),
start: part_start,
});
part_start = byte_idx + ch.len_utf8();
} else {
current_content.push(ch);
}
}
parts.push(CellPart {
content: current_content,
start: part_start,
});
parts
}
fn split_row_line(line: &str, separator: &str) -> Vec<CellPart> {
match separator.chars().next() {
Some(sep_char) if separator.len() == 1 => split_escaped(line, sep_char),
Some(_) => split_multi_char(line, separator),
None => vec![CellPart {
content: line.to_string(),
start: 0,
}],
}
}
fn split_multi_char(line: &str, separator: &str) -> Vec<CellPart> {
let mut parts = Vec::new();
let mut last_end = 0;
for (idx, _) in line.match_indices(separator) {
parts.push(CellPart {
content: line.get(last_end..idx).unwrap_or("").to_string(),
start: last_end,
});
last_end = idx + separator.len();
}
parts.push(CellPart {
content: line.get(last_end..).unwrap_or("").to_string(),
start: last_end,
});
parts
}
fn parse_csv_table(text: &str, base_offset: usize, delimiter: u8) -> Vec<Vec<CellPart>> {
let text_bytes = text.as_bytes();
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true) .delimiter(delimiter)
.from_reader(text_bytes);
let mut rows = Vec::new();
for result in reader.records() {
let Ok(record) = result else {
continue;
};
let record_start = record
.position()
.map_or(0, |p| usize::try_from(p.byte()).unwrap_or(0));
let mut cells = Vec::new();
let mut scan_pos = record_start;
for field in &record {
let (field_content_start, next_pos) =
find_csv_field_position(text_bytes, scan_pos, field, delimiter);
cells.push(CellPart {
content: field.to_string(),
start: base_offset + field_content_start,
});
scan_pos = next_pos;
}
rows.push(cells);
}
rows
}
fn find_csv_field_position(
text: &[u8],
start: usize,
expected_content: &str,
delimiter: u8,
) -> (usize, usize) {
let Some(&first_byte) = text.get(start) else {
return (start, start);
};
if first_byte == b'"' {
let content_start = start + 1;
let end_pos = find_closing_quote(text, start + 1);
let next_pos = skip_to_next_field(text, end_pos, delimiter);
(content_start, next_pos)
} else {
let content_start = start;
let end_pos = find_unquoted_field_end(text, start, expected_content.len(), delimiter);
let next_pos = skip_to_next_field(text, end_pos, delimiter);
(content_start, next_pos)
}
}
fn find_closing_quote(text: &[u8], start: usize) -> usize {
let mut pos = start;
while let Some(&byte) = text.get(pos) {
if byte == b'"' {
if text.get(pos + 1) == Some(&b'"') {
pos += 2;
} else {
return pos;
}
} else {
pos += 1;
}
}
text.len()
}
fn find_unquoted_field_end(text: &[u8], start: usize, content_len: usize, delimiter: u8) -> usize {
let mut pos = start;
let mut remaining = content_len;
while let Some(&byte) = text.get(pos) {
if byte == delimiter || byte == b'\n' || byte == b'\r' {
return pos;
}
if remaining == 0 {
return pos;
}
remaining = remaining.saturating_sub(1);
pos += 1;
}
text.len()
}
fn skip_to_next_field(text: &[u8], pos: usize, delimiter: u8) -> usize {
let mut pos = pos;
if text.get(pos) == Some(&b'"') {
pos += 1;
}
while let Some(&byte) = text.get(pos) {
if byte == delimiter {
return pos + 1;
}
if byte == b'\r' || byte == b'\n' {
if byte == b'\r' && text.get(pos + 1) == Some(&b'\n') {
return pos + 2;
}
return pos + 1;
}
pos += 1;
}
pos
}
fn csv_style_delimiter(separator: &str) -> Option<u8> {
match separator {
"," => Some(b','),
"\t" => Some(b'\t'),
_ => None,
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CellSpecifier {
pub colspan: usize,
pub rowspan: usize,
pub halign: Option<HorizontalAlignment>,
pub valign: Option<VerticalAlignment>,
pub style: Option<ColumnStyle>,
pub is_duplication: bool,
pub duplication_count: usize,
}
impl Default for CellSpecifier {
fn default() -> Self {
Self {
colspan: 1,
rowspan: 1,
halign: None,
valign: None,
style: None,
is_duplication: false,
duplication_count: 1,
}
}
}
fn parse_style_byte(byte: u8) -> Option<ColumnStyle> {
match byte {
b'a' => Some(ColumnStyle::AsciiDoc),
b'd' => Some(ColumnStyle::Default),
b'e' => Some(ColumnStyle::Emphasis),
b'h' => Some(ColumnStyle::Header),
b'l' => Some(ColumnStyle::Literal),
b'm' => Some(ColumnStyle::Monospace),
b's' => Some(ColumnStyle::Strong),
_ => None,
}
}
impl CellSpecifier {
#[must_use]
pub fn parse(content: &str) -> (Self, usize) {
let bytes = content.as_bytes();
let mut pos = 0;
let (halign, valign, align_end) = Self::parse_alignments(bytes, pos);
pos = align_end;
let (colspan, colspan_end) = Self::parse_number(content, bytes, pos);
pos = colspan_end;
let (rowspan, rowspan_end) = Self::parse_rowspan(content, bytes, pos);
pos = rowspan_end;
Self::build(bytes, pos, colspan, rowspan, halign, valign)
}
fn parse_alignments(
bytes: &[u8],
mut pos: usize,
) -> (
Option<HorizontalAlignment>,
Option<VerticalAlignment>,
usize,
) {
let mut halign: Option<HorizontalAlignment> = None;
let mut valign: Option<VerticalAlignment> = None;
loop {
match bytes.get(pos) {
Some(b'<') => {
halign = Some(HorizontalAlignment::Left);
pos += 1;
}
Some(b'^') => {
halign = Some(HorizontalAlignment::Center);
pos += 1;
}
Some(b'>') => {
halign = Some(HorizontalAlignment::Right);
pos += 1;
}
Some(b'.') => {
match bytes.get(pos + 1) {
Some(b'<') => {
valign = Some(VerticalAlignment::Top);
pos += 2;
}
Some(b'^') => {
valign = Some(VerticalAlignment::Middle);
pos += 2;
}
Some(b'>') => {
valign = Some(VerticalAlignment::Bottom);
pos += 2;
}
_ => break, }
}
_ => break,
}
}
(halign, valign, pos)
}
fn parse_number(content: &str, bytes: &[u8], mut pos: usize) -> (Option<usize>, usize) {
let start = pos;
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
let value = if pos > start {
content
.get(start..pos)
.and_then(|s| s.parse::<usize>().ok())
} else {
None
};
(value, pos)
}
fn parse_rowspan(content: &str, bytes: &[u8], mut pos: usize) -> (Option<usize>, usize) {
if bytes.get(pos) != Some(&b'.') {
return (None, pos);
}
let dot_pos = pos;
pos += 1;
let start = pos;
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
if pos > start {
let value = content
.get(start..pos)
.and_then(|s| s.parse::<usize>().ok());
(value, pos)
} else {
(None, dot_pos)
}
}
fn build(
bytes: &[u8],
mut pos: usize,
colspan: Option<usize>,
rowspan: Option<usize>,
halign: Option<HorizontalAlignment>,
valign: Option<VerticalAlignment>,
) -> (Self, usize) {
let has_span_or_dup = colspan.is_some() || rowspan.is_some();
let is_duplication = bytes.get(pos) == Some(&b'*');
let is_span = bytes.get(pos) == Some(&b'+');
if (is_span || is_duplication) && has_span_or_dup {
pos += 1;
let (after_span_horizontal, after_span_vertical, alignment_end) =
Self::parse_alignments(bytes, pos);
pos = alignment_end;
let halign = after_span_horizontal.or(halign);
let valign = after_span_vertical.or(valign);
let style = bytes.get(pos).and_then(|&b| parse_style_byte(b));
if style.is_some() {
pos += 1;
}
let spec = if is_duplication {
Self {
colspan: 1,
rowspan: 1,
halign,
valign,
style,
is_duplication: true,
duplication_count: colspan.unwrap_or(1),
}
} else {
Self {
colspan: colspan.unwrap_or(1),
rowspan: rowspan.unwrap_or(1),
halign,
valign,
style,
is_duplication: false,
duplication_count: 1,
}
};
(spec, pos)
} else if halign.is_some() || valign.is_some() {
let style = bytes.get(pos).and_then(|&b| parse_style_byte(b));
if style.is_some() {
pos += 1;
}
(
Self {
colspan: 1,
rowspan: 1,
halign,
valign,
style,
is_duplication: false,
duplication_count: 1,
},
pos,
)
} else if let Some(style) = bytes.get(pos).and_then(|&b| parse_style_byte(b)) {
pos += 1;
(
Self {
colspan: 1,
rowspan: 1,
halign: None,
valign: None,
style: Some(style),
is_duplication: false,
duplication_count: 1,
},
pos,
)
} else {
(Self::default(), 0)
}
}
}
#[derive(Debug)]
pub(crate) struct ParsedCell {
pub content: String,
pub start: usize,
pub content_start: usize,
pub end: usize,
pub spec: CellSpecifier,
}
#[derive(Debug)]
pub(crate) struct TableLimitViolation {
pub(crate) resource: &'static str,
pub(crate) requested: usize,
pub(crate) limit: usize,
pub(crate) start: usize,
pub(crate) end: usize,
}
impl TableLimitViolation {
const fn new(
resource: &'static str,
requested: usize,
limit: usize,
start: usize,
end: usize,
) -> Self {
Self {
resource,
requested,
limit,
start,
end,
}
}
}
fn detect_header_after_first_row(lines: &[&str], start_idx: usize, separator: &str) -> bool {
for &line in lines.iter().skip(start_idx) {
let trimmed = line.trim_end();
if !trimmed.is_empty() {
return trimmed.contains(separator);
}
}
false
}
fn separator_uses_specifiers(separator: &str) -> bool {
matches!(separator, "|" | "!")
}
fn extract_trailing_spec(content: &str, allow_at_start: bool) -> (&str, Option<CellSpecifier>) {
if content.is_empty() || content.ends_with([' ', '\t']) {
return (content, None);
}
let token_start = content.rfind([' ', '\t', '\n']).map_or(0, |i| i + 1);
let candidate = &content[token_start..];
let (spec, spec_len) = CellSpecifier::parse(candidate);
if spec_len == 0 || spec_len != candidate.len() {
return (content, None);
}
if token_start > 0 || allow_at_start {
(&content[..token_start], Some(spec))
} else {
(content, None)
}
}
fn scan_cells(text: &str, separator: &str, base_offset: usize) -> Vec<ParsedCell> {
let Some(sep_char) = separator.chars().next() else {
return Vec::new();
};
let uses_spec = separator_uses_specifiers(separator);
let parts = split_escaped(text, sep_char);
let last_idx = parts.len().saturating_sub(1);
let mut cells = Vec::new();
let mut carried_spec: Option<CellSpecifier> = None;
for (i, part) in parts.iter().enumerate() {
let (kept, trailing_spec) = if uses_spec && i < last_idx {
extract_trailing_spec(&part.content, i == 0)
} else {
(part.content.as_str(), None)
};
if uses_spec && i == 0 {
carried_spec = trailing_spec;
continue;
}
let leading_ws = kept.len() - kept.trim_start().len();
let content = kept.trim().to_string();
let cell_start = base_offset + part.start;
let content_start = cell_start + leading_ws;
let end = if content.is_empty() {
content_start
} else {
content_start + content.len().saturating_sub(1)
};
cells.push(ParsedCell {
spec: carried_spec.take().unwrap_or_default(),
content,
start: cell_start,
content_start,
end,
});
carried_spec = trailing_spec;
}
cells
}
fn first_line_column_count(text: &str, separator: &str) -> usize {
let Some(sep_char) = separator.chars().next() else {
return 0;
};
let first_line = text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
let parts = split_escaped(first_line, sep_char);
if separator_uses_specifiers(separator) {
parts.len().saturating_sub(1)
} else {
parts.len()
}
}
fn validate_cell_specifiers(cells: &[ParsedCell]) -> Result<(), TableLimitViolation> {
for cell in cells {
if cell.spec.colspan > MAX_TABLE_COLUMNS {
return Err(TableLimitViolation::new(
"column span",
cell.spec.colspan,
MAX_TABLE_COLUMNS,
cell.start,
cell.end,
));
}
if cell.spec.rowspan > MAX_TABLE_ROWS {
return Err(TableLimitViolation::new(
"row span",
cell.spec.rowspan,
MAX_TABLE_ROWS,
cell.start,
cell.end,
));
}
if cell.spec.duplication_count > MAX_TABLE_COLUMNS {
return Err(TableLimitViolation::new(
"cell duplication",
cell.spec.duplication_count,
MAX_TABLE_COLUMNS,
cell.start,
cell.end,
));
}
}
Ok(())
}
fn validate_row_widths(rows: &[Vec<ParsedCell>]) -> Result<(), TableLimitViolation> {
if rows.len() > MAX_TABLE_ROWS {
let (start, end) = rows
.get(MAX_TABLE_ROWS)
.and_then(|row| row.first().zip(row.last()))
.map_or((0, 0), |(first, last)| (first.start, last.end));
return Err(TableLimitViolation::new(
"row count",
rows.len(),
MAX_TABLE_ROWS,
start,
end,
));
}
for row in rows {
if row.len() > MAX_TABLE_COLUMNS {
let (start, end) = row
.first()
.zip(row.last())
.map_or((0, 0), |(first, last)| (first.start, last.end));
return Err(TableLimitViolation::new(
"column count",
row.len(),
MAX_TABLE_COLUMNS,
start,
end,
));
}
}
Ok(())
}
fn group_cells_into_rows(
cells: Vec<ParsedCell>,
ncols: usize,
dropped: &mut Option<(usize, usize)>,
) -> Result<Vec<Vec<ParsedCell>>, TableLimitViolation> {
let ncols = ncols.max(1);
let mut rows = Vec::new();
let mut active: Vec<(usize, usize, usize)> = Vec::new(); let mut iter = cells.into_iter();
let mut next = iter.next();
while next.is_some() {
let mut row = Vec::new();
let mut new_spans: Vec<(usize, usize, usize)> = Vec::new();
let mut col = 0;
let mut filled = false;
while col < ncols {
if let Some(&(_, _, width)) = active
.iter()
.find(|(pos, _, width)| col >= *pos && col < pos + width)
{
col += width;
continue;
}
let Some(cell) = next.take() else {
break;
};
next = iter.next();
let occupied = if cell.spec.is_duplication {
cell.spec.duplication_count.max(1) * cell.spec.colspan.max(1)
} else {
cell.spec.colspan.max(1)
};
if cell.spec.rowspan > 1 {
new_spans.push((col, cell.spec.rowspan - 1, occupied));
}
row.push(cell);
col += occupied;
filled = col >= ncols;
}
if !filled && next.is_none() {
if let (Some(first), Some(last)) = (row.first(), row.last()) {
*dropped = Some((first.start, last.end));
}
break;
}
if rows.len() == MAX_TABLE_ROWS {
let (start, end) = row
.first()
.zip(row.last())
.map_or((0, 0), |(first, last)| (first.start, last.end));
return Err(TableLimitViolation::new(
"row count",
rows.len() + 1,
MAX_TABLE_ROWS,
start,
end,
));
}
rows.push(row);
active.retain_mut(|(_, remaining, _)| {
*remaining -= 1;
*remaining > 0
});
active.extend(new_spans);
}
Ok(rows)
}
impl Table<'_> {
pub(crate) fn parse_rows_with_positions(
text: &str,
separator: &str,
has_header: &mut bool,
base_offset: usize,
ncols: Option<usize>,
dropped: &mut Option<(usize, usize)>,
) -> Result<Vec<Vec<ParsedCell>>, TableLimitViolation> {
if let Some(delimiter) = csv_style_delimiter(separator) {
let rows =
Self::parse_csv_rows_with_positions(text, has_header, base_offset, delimiter);
validate_row_widths(&rows)?;
return Ok(rows);
}
let lines: Vec<&str> = text.lines().collect();
if lines.first().is_some_and(|l| l.trim().is_empty()) {
*has_header = false;
} else if lines.get(1).is_some_and(|l| l.trim().is_empty())
&& detect_header_after_first_row(&lines, 1, separator)
{
tracing::debug!("Detected table header via blank line after first row");
*has_header = true;
}
if !separator_uses_specifiers(separator) {
let rows = Self::parse_delimited_rows(&lines, separator, base_offset);
validate_row_widths(&rows)?;
return Ok(rows);
}
let ncols = ncols
.filter(|&n| n > 0)
.unwrap_or_else(|| first_line_column_count(text, separator));
if ncols > MAX_TABLE_COLUMNS {
return Err(TableLimitViolation::new(
"column count",
ncols,
MAX_TABLE_COLUMNS,
base_offset,
base_offset,
));
}
tracing::debug!(?has_header, ncols, "Starting table parsing");
let cells = scan_cells(text, separator, base_offset);
validate_cell_specifiers(&cells)?;
group_cells_into_rows(cells, ncols, dropped)
}
fn parse_delimited_rows(
lines: &[&str],
separator: &str,
base_offset: usize,
) -> Vec<Vec<ParsedCell>> {
let mut rows = Vec::new();
let mut offset = base_offset;
for line in lines {
if !line.trim().is_empty() {
let cells: Vec<ParsedCell> = split_row_line(line, separator)
.into_iter()
.map(|part| {
let leading_ws = part.content.len() - part.content.trim_start().len();
let content = part.content.trim().to_string();
let start = offset + part.start + leading_ws;
let end = if content.is_empty() {
start
} else {
start + content.len().saturating_sub(1)
};
ParsedCell {
content,
start,
content_start: start,
end,
spec: CellSpecifier::default(),
}
})
.collect();
if !cells.is_empty() {
rows.push(cells);
}
}
offset += line.len() + 1; }
rows
}
fn parse_csv_rows_with_positions(
text: &str,
has_header: &mut bool,
base_offset: usize,
delimiter: u8,
) -> Vec<Vec<ParsedCell>> {
let lines: Vec<&str> = text.lines().collect();
if lines.len() >= 2 {
if let Some(&line) = lines.get(1)
&& line.trim().is_empty()
{
*has_header = true;
}
}
let csv_rows = parse_csv_table(text, base_offset, delimiter);
let mut rows = Vec::new();
for csv_row in csv_rows {
let mut cells = Vec::new();
for part in csv_row {
let content = part.content.trim();
let start = part.start;
let end = if content.is_empty() {
start
} else {
start + content.len().saturating_sub(1)
};
cells.push(ParsedCell {
content: content.to_string(),
start,
content_start: start,
end,
spec: CellSpecifier::default(),
});
}
if !cells.is_empty() {
rows.push(cells);
}
}
rows
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::indexing_slicing)]
mod tests {
use super::*;
fn parse_rows(
input: &str,
separator: &str,
has_header: &mut bool,
ncols: Option<usize>,
) -> Vec<Vec<ParsedCell>> {
match Table::parse_rows_with_positions(input, separator, has_header, 0, ncols, &mut None) {
Ok(rows) => rows,
Err(error) => panic!("table should parse: {error:?}"),
}
}
#[test]
fn combined_span_accepts_canonical_and_legacy_alignment_order() {
for source in ["2.2+^.^e", "^.^2.2+e"] {
let (specifier, consumed) = CellSpecifier::parse(source);
assert_eq!(consumed, source.len());
assert_eq!(specifier.colspan, 2);
assert_eq!(specifier.rowspan, 2);
assert_eq!(specifier.halign, Some(HorizontalAlignment::Center));
assert_eq!(specifier.valign, Some(VerticalAlignment::Middle));
assert_eq!(specifier.style, Some(ColumnStyle::Emphasis));
assert!(!specifier.is_duplication);
}
}
#[test]
fn split_escaped_psv_no_escapes() {
let parts = split_escaped("| cell1 | cell2 |", '|');
let [p0, p1, p2, p3] = parts.as_slice() else {
panic!("expected 4 parts, got {}", parts.len());
};
assert_eq!(p0.content, "");
assert_eq!(p1.content, " cell1 ");
assert_eq!(p2.content, " cell2 ");
assert_eq!(p3.content, "");
}
#[test]
fn split_escaped_psv_with_escape() {
let parts = split_escaped(r"| cell with \| pipe | normal |", '|');
let [p0, p1, p2, p3] = parts.as_slice() else {
panic!("expected 4 parts, got {}", parts.len());
};
assert_eq!(p0.content, "");
assert_eq!(p1.content, " cell with | pipe ");
assert_eq!(p2.content, " normal ");
assert_eq!(p3.content, "");
}
#[test]
fn split_escaped_dsv_no_escapes() {
let parts = split_escaped("cell1:cell2:cell3", ':');
let [p0, p1, p2] = parts.as_slice() else {
panic!("expected 3 parts, got {}", parts.len());
};
assert_eq!(p0.content, "cell1");
assert_eq!(p1.content, "cell2");
assert_eq!(p2.content, "cell3");
}
#[test]
fn split_escaped_dsv_with_escape() {
let parts = split_escaped(r"cell with \: colon:normal", ':');
let [p0, p1] = parts.as_slice() else {
panic!("expected 2 parts, got {}", parts.len());
};
assert_eq!(p0.content, "cell with : colon");
assert_eq!(p1.content, "normal");
}
#[test]
fn split_escaped_backslash_not_before_separator() {
let parts = split_escaped(r"cell\n with backslash|next", '|');
let [p0, p1] = parts.as_slice() else {
panic!("expected 2 parts, got {}", parts.len());
};
assert_eq!(p0.content, r"cell\n with backslash");
assert_eq!(p1.content, "next");
}
#[test]
fn split_escaped_multiple_escapes() {
let parts = split_escaped(r"\|start\|middle\|end", '|');
let [p0] = parts.as_slice() else {
panic!("expected 1 part, got {}", parts.len());
};
assert_eq!(p0.content, "|start|middle|end");
}
#[test]
fn split_escaped_positions_tracked() {
let parts = split_escaped("ab|cd|ef", '|');
let [p0, p1, p2] = parts.as_slice() else {
panic!("expected 3 parts, got {}", parts.len());
};
assert_eq!(p0.start, 0);
assert_eq!(p1.start, 3); assert_eq!(p2.start, 6); }
#[test]
fn trailing_text_becomes_continuation_paragraph_of_last_cell() {
let input = "| A | B\n\nTrailing\n";
let mut has_header = false;
let rows = parse_rows(input, "|", &mut has_header, None);
let [row] = rows.as_slice() else {
panic!("expected 1 row, got {}", rows.len());
};
let [a, b] = row.as_slice() else {
panic!("expected 2 cells, got {}", row.len());
};
assert_eq!(a.content, "A");
assert_eq!(b.content, "B\n\nTrailing");
}
#[test]
fn a_cell_preserves_blank_line_inside_nested_table_content() {
let input = "a|\n!===\n! Inner A ! Inner B\n\nTrailing in inner cell\n!===\n";
let mut has_header = false;
let rows = parse_rows(input, "|", &mut has_header, Some(1));
let [row] = rows.as_slice() else {
panic!("expected 1 row, got {}", rows.len());
};
let [cell] = row.as_slice() else {
panic!("expected 1 cell, got {}", row.len());
};
assert_eq!(
cell.content,
"!===\n! Inner A ! Inner B\n\nTrailing in inner cell\n!===",
);
}
#[test]
fn multiline_cell_content_stays_in_one_cell() {
let input = "| a | b\nstill b\n| c | d\n";
let mut has_header = false;
let rows = parse_rows(input, "|", &mut has_header, Some(2));
let contents: Vec<Vec<&str>> = rows
.iter()
.map(|r| r.iter().map(|c| c.content.as_str()).collect())
.collect();
assert_eq!(contents, vec![vec!["a", "b\nstill b"], vec!["c", "d"]]);
}
#[test]
fn row_split_across_lines_groups_by_column_count() {
let input = "| a\n| b\n| c\n| d\n";
let mut has_header = false;
let rows = parse_rows(input, "|", &mut has_header, Some(2));
let contents: Vec<Vec<&str>> = rows
.iter()
.map(|r| r.iter().map(|c| c.content.as_str()).collect())
.collect();
assert_eq!(contents, vec![vec!["a", "b"], vec!["c", "d"]]);
}
#[test]
fn rowspan_aware_grouping() {
let input = ".2+| spans | b | c\n| e | f\n| g | h | i\n";
let mut has_header = false;
let rows = parse_rows(input, "|", &mut has_header, Some(3));
let contents: Vec<Vec<&str>> = rows
.iter()
.map(|r| r.iter().map(|c| c.content.as_str()).collect())
.collect();
assert_eq!(
contents,
vec![vec!["spans", "b", "c"], vec!["e", "f"], vec!["g", "h", "i"]]
);
assert_eq!(rows[0][0].spec.rowspan, 2);
}
#[test]
fn tsv_quoted_field_spans_lines() {
let input = "a\t\"line1\nline2\"\nb\tc\n";
let mut has_header = false;
let rows = parse_rows(input, "\t", &mut has_header, None);
let contents: Vec<Vec<&str>> = rows
.iter()
.map(|r| r.iter().map(|c| c.content.as_str()).collect())
.collect();
assert_eq!(contents, vec![vec!["a", "line1\nline2"], vec!["b", "c"]]);
}
}