use std::ops::Range;
pub(crate) fn recover_missing_separators(content: &str) -> String {
let lines: Vec<&str> = content.split_inclusive('\n').collect();
let mut out = String::with_capacity(content.len());
let mut in_fence = false;
let mut prev_blank = true; let mut i = 0;
while i < lines.len() {
let raw = lines[i];
let text = trim_terminator(raw);
let at_boundary = prev_blank;
prev_blank = text.trim().is_empty();
if is_fence_delimiter(text) {
let was_in_fence = in_fence;
in_fence = !in_fence;
out.push_str(raw);
i += 1;
if was_in_fence {
prev_blank = true;
}
continue;
}
if in_fence {
out.push_str(raw);
i += 1;
continue;
}
let Some(header_cells) = pipe_row_cell_count(text) else {
out.push_str(raw);
i += 1;
continue;
};
let next_line = lines.get(i + 1);
let next_text = next_line.map(|l| trim_terminator(l));
let next_matches = next_text.and_then(pipe_row_cell_count) == Some(header_cells);
let next_is_delimiter = next_text.map(is_delimiter_row).unwrap_or(false);
let next_has_escape = next_text.map(contains_escaped_pipe).unwrap_or(false);
let has_real_separator = next_matches && next_is_delimiter;
let can_synthesize = next_matches
&& !next_is_delimiter
&& at_boundary
&& header_cells >= 2
&& !is_delimiter_row(text)
&& !contains_escaped_pipe(text)
&& !next_has_escape;
if !has_real_separator && !can_synthesize {
out.push_str(raw);
i += 1;
continue;
}
out.push_str(raw);
i += 1;
if has_real_separator {
out.push_str(lines[i]);
i += 1;
} else {
out.push_str(&synthetic_separator(header_cells, line_terminator(raw)));
}
consume_pipe_row_body(&lines, &mut i, &mut out);
prev_blank = false; }
out
}
fn consume_pipe_row_body(lines: &[&str], i: &mut usize, out: &mut String) {
while *i < lines.len() && pipe_row_cell_count(trim_terminator(lines[*i])).is_some() {
out.push_str(lines[*i]);
*i += 1;
}
}
pub(crate) fn widen_ragged_tables(content: &str) -> String {
let spans = find_ragged_tables(content);
if spans.is_empty() {
return content.to_string();
}
let mut out = content.to_string();
for span in spans.iter().rev() {
let header_text = &content[span.header_range.clone()];
let header_line = trim_terminator(header_text);
let header_terminator = line_terminator(header_text);
let widened_header = format!(
"{}{header_terminator}",
rebuild_row(header_line, span.max_cells, "")
);
let sep_start = span.header_range.end;
let sep_end = content[sep_start..]
.find('\n')
.map(|p| sep_start + p + 1)
.unwrap_or(content.len());
let sep_text = &content[sep_start..sep_end];
let sep_line = trim_terminator(sep_text);
let sep_terminator = line_terminator(sep_text);
let widened_sep = format!(
"{}{sep_terminator}",
rebuild_row(sep_line, span.max_cells, "---")
);
out.replace_range(
span.header_range.start..sep_end,
&format!("{widened_header}{widened_sep}"),
);
}
out
}
struct RaggedTable {
header_range: Range<usize>,
max_cells: usize,
}
fn find_ragged_tables(content: &str) -> Vec<RaggedTable> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let parser_options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
let mut spans = Vec::new();
let mut header_cells = 0usize;
let mut header_range: Option<Range<usize>> = None;
let mut max_cells = 0usize;
for (event, range) in Parser::new_ext(content, parser_options).into_offset_iter() {
match event {
Event::Start(Tag::Table(alignments)) => {
header_cells = alignments.len();
max_cells = header_cells;
header_range = None;
}
Event::Start(Tag::TableHead) => {
header_range = Some(range.clone());
let row_text = trim_terminator(&content[range]);
max_cells = max_cells.max(pipe_row_cell_count(row_text).unwrap_or(0));
}
Event::Start(Tag::TableRow) => {
let row_text = trim_terminator(&content[range]);
max_cells = max_cells.max(pipe_row_cell_count(row_text).unwrap_or(0));
}
Event::End(TagEnd::Table) => {
if let Some(header_range) = header_range.take() {
if max_cells > header_cells {
spans.push(RaggedTable {
header_range,
max_cells,
});
}
}
}
_ => {}
}
}
spans
}
fn rebuild_row(text: &str, target_cells: usize, pad_cell: &str) -> String {
let mut cells: Vec<&str> = split_cells(text).unwrap_or_default();
let mut rendered: Vec<&str> = Vec::with_capacity(target_cells);
rendered.append(&mut cells);
while rendered.len() < target_cells {
rendered.push(pad_cell);
}
format!("| {} |", rendered.join(" | "))
}
fn trim_terminator(line: &str) -> &str {
line.trim_end_matches(['\n', '\r'])
}
fn line_terminator(line: &str) -> &'static str {
if line.ends_with("\r\n") {
"\r\n"
} else if line.ends_with('\n') {
"\n"
} else {
""
}
}
fn is_fence_delimiter(text: &str) -> bool {
let t = text.trim_start();
t.starts_with("```") || t.starts_with("~~~")
}
fn split_unescaped_pipes(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut parts = Vec::new();
let mut start = 0;
let mut backslashes = 0;
for (idx, &b) in bytes.iter().enumerate() {
if b == b'\\' {
backslashes += 1;
continue;
}
if b == b'|' && backslashes % 2 == 0 {
parts.push(&text[start..idx]);
start = idx + 1;
}
backslashes = 0;
}
parts.push(&text[start..]);
parts
}
fn split_cells(text: &str) -> Option<Vec<&str>> {
let mut parts = split_unescaped_pipes(text.trim());
if parts.len() < 2 {
return None;
}
if parts.first() == Some(&"") {
parts.remove(0);
}
if parts.last() == Some(&"") {
parts.pop();
}
if parts.is_empty() {
return None;
}
Some(parts.into_iter().map(str::trim).collect())
}
fn contains_escaped_pipe(text: &str) -> bool {
text.contains("\\|")
}
fn pipe_row_cell_count(text: &str) -> Option<usize> {
split_cells(text).map(|cells| cells.len())
}
fn is_delimiter_row(text: &str) -> bool {
match split_cells(text) {
Some(cells) if !cells.is_empty() => cells.iter().copied().all(is_delimiter_cell),
_ => false,
}
}
fn is_delimiter_cell(cell: &str) -> bool {
let core = cell.strip_prefix(':').unwrap_or(cell);
let core = core.strip_suffix(':').unwrap_or(core);
!core.is_empty() && core.bytes().all(|b| b == b'-')
}
fn synthetic_separator(cell_count: usize, terminator: &str) -> String {
let cells = vec!["---"; cell_count].join(" | ");
format!("| {cells} |{terminator}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inserts_separator_between_header_and_data_row() {
let input = "| a | b |\n| 1 | 2 |\n";
let output = recover_missing_separators(input);
assert_eq!(output, "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
}
#[test]
fn leaves_a_well_formed_table_untouched() {
let input = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn does_not_touch_pipes_inside_a_fenced_code_block() {
let input = "```\n| not | a | table |\n| still | not |\n```\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn ignores_a_lone_pipe_row_with_no_matching_second_row() {
let input = "| a | b |\nplain text\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn ignores_single_column_rows() {
let input = "| a |\n| 1 |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn requires_matching_cell_counts_to_fire() {
let input = "| a | b |\n| 1 | 2 | 3 |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn recovers_a_multi_row_table_body_in_one_pass() {
let input = "| a | b |\n| 1 | 2 |\n| 3 | 4 |\n";
let output = recover_missing_separators(input);
assert_eq!(output, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n");
}
#[test]
fn preserves_crlf_line_endings_in_the_synthetic_row() {
let input = "| a | b |\r\n| 1 | 2 |\r\n";
let output = recover_missing_separators(input);
assert_eq!(output, "| a | b |\r\n| --- | --- |\r\n| 1 | 2 |\r\n");
}
#[test]
fn does_not_re_insert_a_separator_inside_an_already_well_formed_multi_row_table() {
let input = "| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn is_idempotent_on_output_it_just_recovered() {
let input = "| a | b |\n| 1 | 2 |\n| 3 | 4 |\n";
let once = recover_missing_separators(input);
let twice = recover_missing_separators(&once);
assert_eq!(once, twice);
}
#[test]
fn does_not_fire_on_pipe_rows_lazily_continuing_a_list_item() {
let input = "- hello\n| hello | hello |\n| hello | hello |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn fires_after_a_blank_line_following_other_content() {
let input = "- hello\n\n| a | b |\n| 1 | 2 |\n";
let output = recover_missing_separators(input);
assert_eq!(output, "- hello\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n");
}
#[test]
fn does_not_fire_on_lines_carrying_an_escaped_pipe() {
let input = "# hello\n\n\\| hello | hello |\n\\| hello | hello |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn counts_cells_correctly_when_content_has_an_escaped_pipe() {
let input = "| a\\|b | c |\n| --- | --- |\n| 1 | 2 |\n";
assert_eq!(recover_missing_separators(input), input);
}
#[test]
fn widen_ragged_tables_expands_header_and_separator() {
let input = "| a | b |\n| --- | --- |\n| 1 | 2 | hello |\n";
let output = widen_ragged_tables(input);
assert_eq!(
output,
"| a | b | |\n| --- | --- | --- |\n| 1 | 2 | hello |\n"
);
}
#[test]
fn widen_ragged_tables_leaves_non_ragged_tables_untouched() {
let input = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
assert_eq!(widen_ragged_tables(input), input);
}
#[test]
fn widen_ragged_tables_recognizes_a_table_interrupting_a_paragraph() {
let input = "para\n| a | b |\n| --- | --- |\n| 1 | 2 | hello |\n";
let output = widen_ragged_tables(input);
assert!(output.contains("hello"), "excess cell lost: {output:?}");
}
#[test]
fn widen_ragged_tables_leaves_a_list_items_lazy_text_alone() {
let input = "- x\n| a | b |\n| --- | --- |\n| 1 | 2 | hello |\n";
assert_eq!(widen_ragged_tables(input), input);
}
#[test]
fn widen_ragged_tables_is_idempotent() {
let input = "| a | b |\n| --- | --- |\n| 1 | 2 | hello |\n";
let once = widen_ragged_tables(input);
let twice = widen_ragged_tables(&once);
assert_eq!(once, twice);
}
}