use super::ast::{Align, Inline, Table};
use super::inline::parse_inlines;
use regex::Regex;
use std::sync::LazyLock;
static COLLAPSED_ROW_BOUNDARY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\|[ \t]*\|").expect("collapsed-table boundary regex"));
pub(crate) fn reflow_collapsed_tables(text: &str) -> String {
if !text.contains('|') {
return text.to_string();
}
let mut out: Vec<String> = Vec::new();
for line in text.lines() {
if is_collapsed_table_line(line) {
let pipe = line.find('|').unwrap_or(0);
let (prefix, rest) = line.split_at(pipe);
if !prefix.trim().is_empty() {
out.push(prefix.trim_end().to_string());
}
out.push(
COLLAPSED_ROW_BOUNDARY
.replace_all(rest, "|\n|")
.into_owned(),
);
} else {
out.push(line.to_string());
}
}
out.join("\n")
}
fn is_collapsed_table_line(line: &str) -> bool {
let mut has_dash = false;
let mut has_content = false;
for cell in line.split('|') {
let c = cell.trim();
if c.is_empty() {
continue;
}
let core = c.trim_start_matches(':').trim_end_matches(':');
if core.len() >= 2 && core.chars().all(|ch| ch == '-') {
has_dash = true;
} else {
has_content = true;
}
if has_dash && has_content {
return true;
}
}
false
}
pub(crate) fn ensure_blank_line_before_tables(text: &str) -> String {
if !text.contains('|') {
return text.to_string();
}
let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 1);
let mut in_fence = false;
let mut i = 0;
while i < lines.len() {
let line = &lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
out.push(line.clone());
i += 1;
continue;
}
if !in_fence && let Some((_, next)) = try_parse(&lines, i) {
let prev_is_text = out.last().is_some_and(|prev| !prev.trim().is_empty());
if prev_is_text {
out.push(String::new());
}
while i < next {
out.push(lines[i].clone());
i += 1;
}
continue;
}
out.push(line.clone());
i += 1;
}
let mut result = out.join("\n");
if text.ends_with('\n') {
result.push('\n');
}
result
}
pub(super) fn try_parse(lines: &[String], start: usize) -> Option<(Table, usize)> {
let header_line = lines.get(start)?;
let sep_line = lines.get(start + 1)?;
if !looks_like_row(header_line) || !is_separator(sep_line) {
return None;
}
let header: Vec<Vec<Inline>> = split_cells(header_line)
.into_iter()
.map(|c| parse_inlines(c.trim()))
.collect();
let align = parse_alignment(sep_line, header.len());
let mut rows = Vec::new();
let mut i = start + 2;
while i < lines.len() && looks_like_row(&lines[i]) {
let row: Vec<Vec<Inline>> = split_cells(&lines[i])
.into_iter()
.map(|c| parse_inlines(c.trim()))
.collect();
rows.push(row);
i += 1;
}
Some((
Table {
align,
header,
rows,
},
i,
))
}
fn looks_like_row(line: &str) -> bool {
let t = line.trim();
t.contains('|') && !t.is_empty()
}
fn is_separator(line: &str) -> bool {
let cells = split_cells(line);
if cells.is_empty() {
return false;
}
cells.iter().all(|c| {
let c = c.trim();
let core = c.trim_start_matches(':').trim_end_matches(':');
!core.is_empty() && core.chars().all(|ch| ch == '-')
})
}
fn split_cells(line: &str) -> Vec<&str> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = t.strip_suffix('|').unwrap_or(t);
t.split('|').collect()
}
fn parse_alignment(sep: &str, cols: usize) -> Vec<Align> {
let mut align: Vec<Align> = split_cells(sep)
.into_iter()
.map(|c| {
let c = c.trim();
let left = c.starts_with(':');
let right = c.ends_with(':');
match (left, right) {
(true, true) => Align::Center,
(true, false) => Align::Left,
(false, true) => Align::Right,
(false, false) => Align::None,
}
})
.collect();
align.resize(cols, Align::None);
align
}
pub(crate) fn normalize_tables(text: &str) -> String {
let balanced = balance_code_fences(text);
let shielded = shield_bare_leading_hashes(&balanced);
let reflowed = reflow_collapsed_tables(&shielded);
let inferred = infer_missing_table_separators(&reflowed);
ensure_blank_line_before_tables(&inferred)
}
pub(crate) fn balance_code_fences(text: &str) -> String {
if !text.contains("```") && !text.contains("~~~") {
return text.to_string();
}
let mut out: Vec<String> = Vec::new();
let mut in_fence = false;
let mut fence_char = ' ';
let mut fence_len = 0;
let mut fence_has_lang = false;
let lines: Vec<&str> = text.split('\n').collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let ch = trimmed.chars().next().unwrap();
let count = trimmed.chars().take_while(|&c| c == ch).count();
if count >= 3 {
let rest = trimmed[count..].trim();
let has_backtick_in_info = ch == '`' && rest.contains('`');
if !has_backtick_in_info {
if !in_fence {
in_fence = true;
fence_char = ch;
fence_len = count;
fence_has_lang = !rest.is_empty();
out.push(line.to_string());
i += 1;
continue;
} else if ch == fence_char && count >= fence_len && rest.is_empty() {
in_fence = false;
fence_has_lang = false;
out.push(line.to_string());
i += 1;
continue;
}
}
}
}
if in_fence && !fence_has_lang {
let is_heading = trimmed.starts_with('#') && super::detect::is_atx_heading(trimmed);
let is_table_sep = is_separator(trimmed);
let is_table_start = if i + 1 < lines.len() {
looks_like_row(trimmed) && is_separator(lines[i + 1].trim_start())
} else {
false
};
if is_heading || is_table_sep || is_table_start {
out.push(fence_char.to_string().repeat(fence_len));
in_fence = false;
out.push(line.to_string());
i += 1;
continue;
}
}
out.push(line.to_string());
i += 1;
}
if in_fence {
out.push(fence_char.to_string().repeat(fence_len));
}
let mut result = out.join("\n");
if text.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
result
}
pub(crate) fn infer_missing_table_separators(text: &str) -> String {
if !text.contains('|') {
return text.to_string();
}
let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 2);
let mut in_fence = false;
let mut fence_char = ' ';
let mut fence_len = 0;
let mut i = 0;
while i < lines.len() {
let line = &lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let ch = trimmed.chars().next().unwrap();
let count = trimmed.chars().take_while(|&c| c == ch).count();
if !in_fence {
in_fence = true;
fence_char = ch;
fence_len = count;
out.push(line.clone());
i += 1;
continue;
} else if ch == fence_char && count >= fence_len {
in_fence = false;
out.push(line.clone());
i += 1;
continue;
}
}
if in_fence {
out.push(line.clone());
i += 1;
continue;
}
if let Some((_, next)) = try_parse(&lines, i) {
while i < next {
out.push(lines[i].clone());
i += 1;
}
continue;
}
if looks_like_row(line) && !is_separator(line) && i + 1 < lines.len() {
let next_line = &lines[i + 1];
if looks_like_row(next_line) && !is_separator(next_line) {
let h_cells = split_cells(line);
let n_cells = split_cells(next_line);
if h_cells.len() >= 2 && n_cells.len() >= 2 {
out.push(line.clone());
let sep = format!("|{}|", vec!["---"; h_cells.len()].join("|"));
out.push(sep);
i += 1;
while i < lines.len() && looks_like_row(&lines[i]) && !is_separator(&lines[i]) {
out.push(lines[i].clone());
i += 1;
}
continue;
}
}
}
out.push(line.clone());
i += 1;
}
let mut result = out.join("\n");
if text.ends_with('\n') {
result.push('\n');
}
result
}
pub(crate) fn shield_bare_leading_hashes(text: &str) -> String {
if !text.contains('#') {
return text.to_string();
}
let mut out = String::with_capacity(text.len() + 16);
let mut in_fence = false;
let mut fence_char = ' ';
let mut fence_len = 0;
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let ch = trimmed.chars().next().unwrap();
let count = trimmed.chars().take_while(|&c| c == ch).count();
if !in_fence {
in_fence = true;
fence_char = ch;
fence_len = count;
out.push_str(line);
continue;
} else if ch == fence_char && count >= fence_len {
in_fence = false;
out.push_str(line);
continue;
}
}
if in_fence {
out.push_str(line);
continue;
}
if let Some(prefix_len) = find_bare_hash_offset(line) {
out.push_str(&line[..prefix_len]);
out.push('\\');
out.push_str(&line[prefix_len..]);
} else {
out.push_str(line);
}
}
out
}
fn find_bare_hash_offset(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
if trimmed.is_empty() {
return None;
}
let mut rest = trimmed;
while let Some(after) = rest.strip_prefix('>') {
rest = after.trim_start();
}
if let Some(after) = rest
.strip_prefix("- ")
.or_else(|| rest.strip_prefix("* "))
.or_else(|| rest.strip_prefix("+ "))
{
rest = after.trim_start();
if let Some(after_cb) = rest
.strip_prefix("[ ] ")
.or_else(|| rest.strip_prefix("[x] "))
.or_else(|| rest.strip_prefix("[X] "))
{
rest = after_cb.trim_start();
}
} else if let Some(after_num) = strip_ordered_list_prefix(rest) {
rest = after_num;
if let Some(after_cb) = rest
.strip_prefix("[ ] ")
.or_else(|| rest.strip_prefix("[x] "))
.or_else(|| rest.strip_prefix("[X] "))
{
rest = after_cb.trim_start();
}
}
if rest.starts_with('#') && !super::is_atx_heading(rest) && !rest.starts_with("\\#") {
let prefix_len = line.len() - rest.len();
Some(prefix_len)
} else {
None
}
}
fn strip_ordered_list_prefix(s: &str) -> Option<&str> {
let digits = s.chars().take_while(|c| c.is_ascii_digit()).count();
if (1..=9).contains(&digits) {
let after_digits = &s[digits..];
let after_delim = after_digits
.strip_prefix('.')
.or_else(|| after_digits.strip_prefix(')'))?;
if after_delim.starts_with(' ') || after_delim.starts_with('\t') {
return Some(after_delim.trim_start());
}
}
None
}