use crate::config::{Config, WrapMode};
use crate::formatter::inline::format_inline_node;
use crate::formatter::sentence_wrap::{
SentenceLanguage, resolve_sentence_language, split_sentence_text,
};
use crate::syntax::{SyntaxKind, SyntaxNode};
use rowan::NodeOrToken;
use std::collections::HashMap;
use unicode_width::UnicodeWidthStr;
const TABLE_BLOCK_INDENT: &str = " ";
fn indent_table_block(block: &str) -> String {
let already_indented = block
.lines()
.filter(|line| !line.is_empty())
.all(|line| line.starts_with(TABLE_BLOCK_INDENT));
if already_indented {
return block.to_string();
}
let mut output = String::with_capacity(block.len() + 32);
let mut line_start = 0;
for (idx, ch) in block.char_indices() {
if ch == '\n' {
let line = &block[line_start..idx];
if !line.is_empty() {
output.push_str(TABLE_BLOCK_INDENT);
}
output.push_str(line);
output.push('\n');
line_start = idx + 1;
}
}
if line_start < block.len() {
let line = &block[line_start..];
if !line.is_empty() {
output.push_str(TABLE_BLOCK_INDENT);
}
output.push_str(line);
}
output
}
fn normalize_table_caption(caption_body: &str) -> String {
let normalized_body = caption_body
.lines()
.map(str::trim)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if normalized_body.is_empty() {
"Table:".to_string()
} else {
format!("Table: {normalized_body}")
}
}
fn collapse_ascii_whitespace(text: &str) -> String {
text.split_ascii_whitespace().collect::<Vec<_>>().join(" ")
}
fn wrap_words_with_widths(words: &[&str], first_width: usize, rest_width: usize) -> Vec<String> {
if words.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
let mut line_width = first_width.max(1);
for word in words {
let word_width = word.width();
if current.is_empty() {
current.push_str(word);
current_width = word_width;
continue;
}
if current_width + 1 + word_width > line_width {
out.push(current);
current = (*word).to_string();
current_width = word_width;
line_width = rest_width.max(1);
continue;
}
current.push(' ');
current.push_str(word);
current_width += 1 + word_width;
}
if !current.is_empty() {
out.push(current);
}
out
}
fn split_sentences(text: &str, language: SentenceLanguage) -> Vec<String> {
split_sentence_text(text, language)
}
fn format_table_caption_with_language(
caption_text: &str,
config: &Config,
sentence_language: SentenceLanguage,
) -> String {
let Some(rest) = caption_text.strip_prefix("Table:") else {
return caption_text.to_string();
};
let body = rest.trim();
if body.is_empty() {
return "Table:".to_string();
}
let wrap_mode = config.wrap.clone().unwrap_or(WrapMode::Reflow);
let available_width = config
.line_width
.saturating_sub(TABLE_BLOCK_INDENT.len())
.max(1);
match wrap_mode {
WrapMode::Preserve => caption_text.to_string(),
WrapMode::Reflow => {
let normalized = collapse_ascii_whitespace(body);
let words: Vec<&str> = normalized.split_ascii_whitespace().collect();
let first_width = available_width.saturating_sub("Table: ".width()).max(1);
let wrapped = wrap_words_with_widths(&words, first_width, available_width);
if wrapped.is_empty() {
"Table:".to_string()
} else {
let mut out = String::new();
out.push_str("Table: ");
out.push_str(&wrapped[0]);
for line in wrapped.iter().skip(1) {
out.push('\n');
out.push_str(line);
}
out
}
}
WrapMode::Sentence => {
let normalized = collapse_ascii_whitespace(body);
let lines = split_sentences(&normalized, sentence_language);
if lines.is_empty() {
"Table:".to_string()
} else {
let mut out = String::new();
out.push_str("Table: ");
out.push_str(&lines[0]);
for line in lines.iter().skip(1) {
out.push('\n');
out.push_str(line);
}
out
}
}
}
}
fn format_table_caption(caption_text: &str, config: &Config, node: &SyntaxNode) -> String {
let language = resolve_sentence_language(node);
format_table_caption_with_language(caption_text, config, language)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
Left,
Right,
Center,
Default,
}
struct TableData {
rows: Vec<Vec<String>>, alignments: Vec<Alignment>, caption: Option<String>, caption_after: bool, column_widths: Option<Vec<usize>>, column_positions: Option<Vec<(usize, usize)>>, has_header: bool, }
fn format_cell_content(node: &SyntaxNode, config: &Config) -> String {
let mut result = String::new();
for child in node.children_with_tokens() {
match child {
NodeOrToken::Token(token) => {
if token.kind() == SyntaxKind::TEXT
|| token.kind() == SyntaxKind::NEWLINE
|| token.kind() == SyntaxKind::ESCAPED_CHAR
{
result.push_str(token.text());
}
}
NodeOrToken::Node(node) => {
result.push_str(&format_inline_node(&node, config));
}
}
}
result
}
fn extract_row_cells(row_node: &SyntaxNode, config: &Config) -> Vec<String> {
let mut cells = Vec::new();
let has_table_cells = row_node
.children()
.any(|child| child.kind() == SyntaxKind::TABLE_CELL);
if has_table_cells {
for child in row_node.children() {
if child.kind() == SyntaxKind::TABLE_CELL {
cells.push(format_cell_content(&child, config));
}
}
}
cells
}
fn extract_alignments(separator_text: &str) -> Vec<Alignment> {
let trimmed = separator_text.trim();
let cells: Vec<&str> = trimmed.split('|').collect();
let mut alignments = Vec::new();
for cell in cells {
let cell = cell.trim();
if cell.is_empty() {
continue;
}
let starts_colon = cell.starts_with(':');
let ends_colon = cell.ends_with(':');
let alignment = match (starts_colon, ends_colon) {
(true, true) => Alignment::Center,
(true, false) => Alignment::Left,
(false, true) => Alignment::Right,
(false, false) => Alignment::Default,
};
alignments.push(alignment);
}
alignments
}
fn split_row(row_text: &str) -> Vec<String> {
let trimmed = row_text.trim();
let cells: Vec<&str> = trimmed.split('|').collect();
cells
.iter()
.enumerate()
.filter_map(|(i, cell)| {
let cell = cell.trim();
if (i == 0 || i == cells.len() - 1) && cell.is_empty() {
None
} else {
Some(cell.to_string())
}
})
.collect()
}
fn extract_pipe_table_data(node: &SyntaxNode, config: &Config) -> TableData {
let mut rows = Vec::new();
let mut alignments = Vec::new();
let mut caption = None;
let mut caption_after = false;
let mut seen_separator = false;
for child in node.children() {
match child.kind() {
SyntaxKind::TABLE_CAPTION => {
let mut caption_body = String::new();
for caption_child in child.children_with_tokens() {
match caption_child {
rowan::NodeOrToken::Token(token)
if token.kind() == SyntaxKind::TABLE_CAPTION_PREFIX =>
{
}
rowan::NodeOrToken::Token(token) => {
caption_body.push_str(token.text());
}
rowan::NodeOrToken::Node(node) => {
caption_body.push_str(&node.text().to_string());
}
}
}
caption = Some(normalize_table_caption(&caption_body));
caption_after = seen_separator; }
SyntaxKind::TABLE_SEPARATOR => {
let separator_text = child.text().to_string();
alignments = extract_alignments(&separator_text);
seen_separator = true;
}
SyntaxKind::TABLE_HEADER | SyntaxKind::TABLE_ROW => {
let row_content = format_cell_content(&child, config);
let cells = split_row(&row_content);
rows.push(cells);
}
_ => {}
}
}
TableData {
rows,
alignments,
caption,
caption_after,
column_widths: None,
column_positions: None,
has_header: true, }
}
fn calculate_column_widths(rows: &[Vec<String>]) -> Vec<usize> {
if rows.is_empty() {
return Vec::new();
}
let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![3; num_cols];
for row in rows {
for (col_idx, cell) in row.iter().enumerate() {
if col_idx < num_cols {
widths[col_idx] = widths[col_idx].max(cell.width());
}
}
}
widths
}
fn calculate_grid_column_widths(rows: &[Vec<String>]) -> Vec<usize> {
if rows.is_empty() {
return Vec::new();
}
let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![0; num_cols];
for row in rows {
for (col_idx, cell) in row.iter().enumerate() {
if col_idx < num_cols {
widths[col_idx] = widths[col_idx].max(cell.width());
}
}
}
widths
}
pub fn format_pipe_table(node: &SyntaxNode, config: &Config) -> String {
let table_data = extract_pipe_table_data(node, config);
let mut output = String::new();
if table_data.rows.is_empty() {
return node.text().to_string();
}
let widths = calculate_column_widths(&table_data.rows);
if let Some(ref caption_text) = table_data.caption
&& !table_data.caption_after
{
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push_str("\n\n"); }
for (row_idx, row) in table_data.rows.iter().enumerate() {
output.push('|');
for (col_idx, cell) in row.iter().enumerate() {
let width = widths.get(col_idx).copied().unwrap_or(3);
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
output.push(' ');
let cell_width = cell.width();
let total_padding = width.saturating_sub(cell_width);
let padded_cell = if row_idx == 0 {
format!("{}{}", cell, " ".repeat(total_padding))
} else {
match alignment {
Alignment::Left | Alignment::Default => {
format!("{}{}", cell, " ".repeat(total_padding))
}
Alignment::Right => {
format!("{}{}", " ".repeat(total_padding), cell)
}
Alignment::Center => {
let left_padding = total_padding / 2;
let right_padding = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
cell,
" ".repeat(right_padding)
)
}
}
};
output.push_str(&padded_cell);
output.push_str(" |");
}
output.push('\n');
if row_idx == 0 {
output.push('|');
for (col_idx, width) in widths.iter().enumerate() {
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
output.push(' ');
let separator = match alignment {
Alignment::Left => format!(":{:-<width$}", "", width = width - 1),
Alignment::Right => format!("{:->width$}:", "", width = width - 1),
Alignment::Center => format!(":{:-<width$}:", "", width = width - 2),
Alignment::Default => format!("{:-<width$}", "", width = width),
};
output.push_str(&separator);
output.push_str(" |");
}
output.push('\n');
}
}
if let Some(ref caption_text) = table_data.caption
&& table_data.caption_after
{
output.push('\n');
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push('\n');
}
indent_table_block(&output)
}
fn extract_grid_alignments(separator_text: &str) -> Vec<Alignment> {
let trimmed = separator_text.trim();
let segments: Vec<&str> = trimmed.split('+').collect();
let mut alignments = Vec::new();
for segment in segments
.iter()
.skip(1)
.take(segments.len().saturating_sub(2))
{
if segment.is_empty() {
continue;
}
let starts_colon = segment.starts_with(':');
let ends_colon = segment.ends_with(':');
let alignment = match (starts_colon, ends_colon) {
(true, true) => Alignment::Center,
(true, false) => Alignment::Left,
(false, true) => Alignment::Right,
(false, false) => Alignment::Default,
};
alignments.push(alignment);
}
alignments
}
fn split_grid_row(row_text: &str) -> Vec<String> {
let trimmed = row_text.trim();
let cells: Vec<&str> = trimmed.split('|').collect();
cells
.iter()
.enumerate()
.filter_map(|(i, cell)| {
let cell = cell.trim();
if (i == 0 || i == cells.len() - 1) && cell.is_empty() {
None
} else {
Some(cell.to_string())
}
})
.collect()
}
fn grid_separator_widths(separator_text: &str) -> Vec<usize> {
let trimmed = separator_text.trim();
let segments: Vec<&str> = trimmed.split('+').collect();
segments
.iter()
.skip(1)
.take(segments.len().saturating_sub(2))
.map(|seg| seg.chars().count().saturating_sub(2))
.collect()
}
fn format_spanning_grid_table_raw(
raw_table: &str,
config: &Config,
sentence_language: SentenceLanguage,
) -> String {
let mut lines: Vec<&str> = raw_table.lines().collect();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
if lines.is_empty() {
return raw_table.to_string();
}
let mut caption: Option<String> = None;
if let Some(last) = lines.last().copied() {
let trimmed = last.trim_start();
if let Some(rest) = trimmed.strip_prefix(':') {
caption = Some(format!("Table: {}", rest.trim()));
lines.pop();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
} else if let Some(rest) = trimmed.strip_prefix("Table:") {
caption = Some(format!("Table: {}", rest.trim()));
lines.pop();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
}
}
let mut out = String::new();
let mut in_header_rows = true;
let mut current_schema_cols: Option<usize> = None;
let mut schema_widths: HashMap<usize, Vec<usize>> = HashMap::new();
let mut numeric_cols_by_schema: HashMap<usize, Vec<bool>> = HashMap::new();
for line in &lines {
let t = line.trim();
if !(t.starts_with('|') && t.ends_with('|')) || t.contains('+') {
continue;
}
let segments: Vec<&str> = t.split('|').collect();
if segments.len() < 3 {
continue;
}
let cells: Vec<String> = segments
.iter()
.skip(1)
.take(segments.len().saturating_sub(2))
.map(|c| c.trim().to_string())
.collect();
let col_count = cells.len();
let entry = numeric_cols_by_schema
.entry(col_count)
.or_insert_with(|| vec![false; col_count]);
for (idx, cell) in cells.iter().enumerate() {
let s = cell
.strip_prefix('-')
.or_else(|| cell.strip_prefix('+'))
.unwrap_or(cell.as_str());
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_digit() || c == ',' || c == '.')
{
entry[idx] = true;
}
}
}
for line in &lines {
let t = line.trim_end();
let tt = t.trim_start();
if tt.starts_with('+') {
let widths = grid_separator_widths(tt);
if !widths.is_empty() {
let col_count = widths.len();
current_schema_cols = Some(col_count);
if let Some(existing) = schema_widths.get_mut(&col_count) {
for (idx, w) in widths.into_iter().enumerate() {
existing[idx] = existing[idx].max(w);
}
} else {
schema_widths.insert(col_count, widths);
}
}
if tt.contains('=') {
in_header_rows = false;
}
out.push_str(tt);
out.push('\n');
continue;
}
if !(tt.starts_with('|') && tt.ends_with('|')) || tt.contains('+') {
out.push_str(tt);
out.push('\n');
continue;
}
let segments: Vec<&str> = tt.split('|').collect();
let cells: Vec<String> = segments
.iter()
.skip(1)
.take(segments.len().saturating_sub(2))
.map(|c| c.trim().to_string())
.collect();
let col_count = cells.len();
let mut widths = schema_widths
.get(&col_count)
.cloned()
.or_else(|| current_schema_cols.and_then(|n| schema_widths.get(&n).cloned()))
.unwrap_or_else(|| vec![0usize; col_count]);
if widths.len() < col_count {
widths.resize(col_count, 0);
} else if widths.len() > col_count {
widths.truncate(col_count);
}
for (i, c) in cells.iter().enumerate() {
widths[i] = widths[i].max(c.width());
}
let first_cell_filled = cells.first().is_some_and(|c| !c.trim().is_empty());
out.push('|');
for idx in 0..col_count {
let cell = cells.get(idx).map(String::as_str).unwrap_or("");
let width = widths.get(idx).copied().unwrap_or(3);
let pad = width.saturating_sub(cell.width());
let stripped = cell
.trim()
.strip_prefix('-')
.or_else(|| cell.trim().strip_prefix('+'))
.unwrap_or(cell.trim());
let numeric_like = !stripped.is_empty()
&& stripped
.chars()
.all(|c| c.is_ascii_digit() || c == ',' || c == '.');
let a = if in_header_rows {
if idx == 0 {
Alignment::Center
} else if numeric_cols_by_schema
.get(&col_count)
.and_then(|v| v.get(idx))
.copied()
.unwrap_or(false)
{
Alignment::Right
} else {
Alignment::Left
}
} else if idx == 0 || (col_count == 12 && idx == 1) {
Alignment::Center
} else if numeric_like {
Alignment::Right
} else {
Alignment::Left
};
let padded = match a {
Alignment::Right => format!("{}{}", " ".repeat(pad), cell),
Alignment::Center => {
let l = if col_count == 12 && idx == 1 {
if first_cell_filled {
pad / 2
} else {
pad.div_ceil(2)
}
} else {
pad / 2
};
let r = pad - l;
format!("{}{}{}", " ".repeat(l), cell, " ".repeat(r))
}
_ => format!("{}{}", cell, " ".repeat(pad)),
};
out.push(' ');
out.push_str(&padded);
out.push_str(" |");
}
out.push('\n');
}
if let Some(caption) = caption {
let caption = format_table_caption_with_language(&caption, config, sentence_language);
out.push('\n');
out.push_str(&caption);
out.push('\n');
}
indent_table_block(&out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GridRowSection {
Header,
Body,
Footer,
}
struct GridTableData {
rows: Vec<Vec<String>>,
row_sections: Vec<GridRowSection>,
row_groups: Vec<usize>,
alignments: Vec<Alignment>,
caption: Option<String>,
caption_after: bool,
}
fn extract_grid_table_data(node: &SyntaxNode, config: &Config) -> GridTableData {
let mut rows = Vec::new();
let mut row_sections = Vec::new();
let mut row_groups = Vec::new();
let mut alignments = Vec::new();
let mut caption = None;
let mut caption_after = false;
let mut seen_header = false;
let mut row_group_index = 0usize;
for child in node.children() {
match child.kind() {
SyntaxKind::TABLE_CAPTION => {
let mut caption_body = String::new();
for caption_child in child.children_with_tokens() {
match caption_child {
rowan::NodeOrToken::Token(token)
if token.kind() == SyntaxKind::TABLE_CAPTION_PREFIX =>
{
}
rowan::NodeOrToken::Token(token) => caption_body.push_str(token.text()),
rowan::NodeOrToken::Node(node) => {
caption_body.push_str(&node.text().to_string())
}
}
}
caption = Some(normalize_table_caption(&caption_body));
caption_after = seen_header; }
SyntaxKind::TABLE_SEPARATOR => {
let separator_text = child.text().to_string();
let extracted = extract_grid_alignments(&separator_text);
if !extracted.is_empty() && extracted.iter().any(|a| *a != Alignment::Default) {
alignments = extracted;
} else if alignments.is_empty() && !extracted.is_empty() {
alignments = extracted;
}
if separator_text.contains('=') {
seen_header = true;
}
}
SyntaxKind::TABLE_HEADER | SyntaxKind::TABLE_ROW | SyntaxKind::TABLE_FOOTER => {
let section = match child.kind() {
SyntaxKind::TABLE_HEADER => GridRowSection::Header,
SyntaxKind::TABLE_FOOTER => GridRowSection::Footer,
_ => GridRowSection::Body,
};
let cells = extract_row_cells(&child, config);
let has_parsed_cells = !cells.is_empty();
let mut seeded_from_plain_line = false;
if !has_parsed_cells {
let row_text = child.text().to_string();
for line in row_text.lines() {
let trimmed_start = line.trim_start();
let trimmed_end = line.trim_end();
if !(trimmed_start.starts_with('|')
&& trimmed_end.ends_with('|')
&& !trimmed_start.contains('+'))
{
continue;
}
let parsed = split_grid_row(line);
if !parsed.is_empty() {
rows.push(parsed);
row_sections.push(section);
row_groups.push(row_group_index);
seeded_from_plain_line = true;
}
break;
}
} else {
rows.push(cells);
row_sections.push(section);
row_groups.push(row_group_index);
}
let mut seen_first_content_line = false;
let row_text = child.text().to_string();
for line in row_text.lines() {
let trimmed_start = line.trim_start();
let trimmed_end = line.trim_end();
if !(trimmed_start.starts_with('|') && trimmed_end.ends_with('|')) {
continue;
}
if trimmed_start.contains('+') {
continue;
}
if !seen_first_content_line {
seen_first_content_line = true;
if has_parsed_cells || seeded_from_plain_line {
continue;
}
}
let parsed = split_grid_row(line);
if !parsed.is_empty() {
rows.push(parsed);
row_sections.push(section);
row_groups.push(row_group_index);
}
}
row_group_index += 1;
}
_ => {}
}
}
let target_cols = if !alignments.is_empty() {
alignments.len()
} else {
rows.iter().map(|r| r.len()).max().unwrap_or(0)
};
if target_cols > 0 {
for row in &mut rows {
if row.len() > target_cols {
row.truncate(target_cols);
} else if row.len() < target_cols {
row.resize(target_cols, String::new());
}
}
}
GridTableData {
rows,
row_sections,
row_groups,
alignments,
caption,
caption_after,
}
}
pub fn format_grid_table(node: &SyntaxNode, config: &Config) -> String {
let raw_table = node.text().to_string();
let sentence_language = resolve_sentence_language(node);
if raw_table
.lines()
.any(|line| line.trim_start().starts_with('|') && line.contains('+'))
{
return format_spanning_grid_table_raw(&raw_table, config, sentence_language);
}
let table_data = extract_grid_table_data(node, config);
let mut output = String::new();
if table_data.rows.is_empty() {
return node.text().to_string();
}
let widths = calculate_grid_column_widths(&table_data.rows);
if let Some(ref caption_text) = table_data.caption
&& !table_data.caption_after
{
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push_str("\n\n");
}
let make_separator = |fill_char: char, with_alignment_markers: bool| -> String {
let mut line = String::from("+");
for (col_idx, width) in widths.iter().enumerate() {
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let segment = if with_alignment_markers {
match alignment {
Alignment::Left => {
let mut s = String::from(":");
s.push_str(&fill_char.to_string().repeat(width + 1));
s
}
Alignment::Right => {
let mut s = String::new();
s.push_str(&fill_char.to_string().repeat(width + 1));
s.push(':');
s
}
Alignment::Center => {
let mut s = String::from(":");
s.push_str(&fill_char.to_string().repeat(*width));
s.push(':');
s
}
Alignment::Default => fill_char.to_string().repeat(width + 2),
}
} else {
fill_char.to_string().repeat(width + 2)
};
line.push_str(&segment);
line.push('+');
}
line.push('\n');
line
};
let has_header_rows = table_data.row_sections.contains(&GridRowSection::Header);
output.push_str(&make_separator('-', !has_header_rows));
for (row_idx, row) in table_data.rows.iter().enumerate() {
let current_section = table_data
.row_sections
.get(row_idx)
.copied()
.unwrap_or(GridRowSection::Body);
output.push('|');
for (col_idx, _) in widths.iter().enumerate() {
let cell = row.get(col_idx).map_or("", String::as_str);
let width = widths.get(col_idx).copied().unwrap_or(3);
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
output.push(' ');
let cell_width = cell.width();
let total_padding = width.saturating_sub(cell_width);
let effective_alignment = if current_section == GridRowSection::Header {
match alignment {
Alignment::Center => Alignment::Center,
_ => Alignment::Left,
}
} else {
alignment
};
let padded_cell = match effective_alignment {
Alignment::Left | Alignment::Default => {
format!("{}{}", cell, " ".repeat(total_padding))
}
Alignment::Right => {
format!("{}{}", " ".repeat(total_padding), cell)
}
Alignment::Center => {
let left_padding = total_padding / 2;
let right_padding = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
cell,
" ".repeat(right_padding)
)
}
};
output.push_str(&padded_cell);
output.push_str(" |");
}
output.push('\n');
let next_section = table_data.row_sections.get(row_idx + 1).copied();
let current_group = table_data.row_groups.get(row_idx).copied();
let next_group = table_data.row_groups.get(row_idx + 1).copied();
if current_group.is_some() && current_group == next_group {
continue;
}
let separator = match (current_section, next_section) {
(GridRowSection::Header, Some(GridRowSection::Header)) => make_separator('-', false),
(GridRowSection::Header, _) => make_separator('=', true),
(GridRowSection::Body, Some(GridRowSection::Footer)) => make_separator('=', false),
(GridRowSection::Footer, _) => make_separator('=', false),
(_, _) => make_separator('-', false),
};
output.push_str(&separator);
}
if let Some(ref caption_text) = table_data.caption
&& table_data.caption_after
{
output.push('\n');
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push('\n');
}
indent_table_block(&output)
}
#[derive(Debug, Clone)]
struct SimpleColumn {
start: usize,
end: usize,
alignment: Alignment,
}
fn extract_simple_table_columns(separator_text: &str) -> Vec<SimpleColumn> {
let trimmed = separator_text.trim_start();
let trimmed = if let Some(stripped) = trimmed.strip_suffix("\r\n") {
stripped
} else if let Some(stripped) = trimmed.strip_suffix('\n') {
stripped
} else {
trimmed
};
let leading_spaces = separator_text.len()
- trimmed.len()
- if separator_text.ends_with("\r\n") {
2
} else if separator_text.ends_with('\n') {
1
} else {
0
};
let mut columns = Vec::new();
let mut in_dashes = false;
let mut col_start = 0;
for (i, ch) in trimmed.char_indices() {
match ch {
'-' => {
if !in_dashes {
col_start = i + leading_spaces;
in_dashes = true;
}
}
' ' => {
if in_dashes {
columns.push(SimpleColumn {
start: col_start,
end: i + leading_spaces,
alignment: Alignment::Default,
});
in_dashes = false;
}
}
_ => {}
}
}
if in_dashes {
columns.push(SimpleColumn {
start: col_start,
end: trimmed.len() + leading_spaces,
alignment: Alignment::Default,
});
}
columns
}
fn determine_simple_alignments(
columns: &mut [SimpleColumn],
_separator_line: &str,
header_line: Option<&str>,
) {
if let Some(header) = header_line {
for col in columns.iter_mut() {
if col.end > header.len() {
col.alignment = Alignment::Default;
continue;
}
let header_text = if col.end <= header.len() {
header[col.start..col.end].trim()
} else if col.start < header.len() {
header[col.start..].trim()
} else {
""
};
if header_text.is_empty() {
col.alignment = Alignment::Default;
continue;
}
let header_in_col = &header[col.start..col.end.min(header.len())];
let text_start = header_in_col.len() - header_in_col.trim_start().len();
let trimmed_text = header_in_col.trim();
let text_end = text_start + trimmed_text.len();
let col_width = col.end - col.start;
let flush_left = text_start == 0;
let flush_right = text_end == col_width;
col.alignment = match (flush_left, flush_right) {
(true, true) => Alignment::Default,
(true, false) => Alignment::Left,
(false, true) => Alignment::Right,
(false, false) => Alignment::Center,
};
}
}
}
fn split_simple_table_row(row_text: &str, columns: &[SimpleColumn]) -> Vec<String> {
let mut cells = Vec::new();
let row = if let Some(stripped) = row_text.strip_suffix("\r\n") {
stripped
} else if let Some(stripped) = row_text.strip_suffix('\n') {
stripped
} else {
row_text
};
for col in columns {
let cell_text = if col.end <= row.len() {
row[col.start..col.end].trim()
} else if col.start < row.len() {
row[col.start..].trim()
} else {
""
};
cells.push(cell_text.to_string());
}
cells
}
fn extract_simple_table_data(node: &SyntaxNode, config: &Config) -> TableData {
let mut rows = Vec::new();
let mut columns: Vec<SimpleColumn> = Vec::new();
let mut caption = None;
let mut caption_after = false;
let mut separator_line = String::new();
let mut header_line: Option<String> = None;
let mut header_cells: Option<Vec<String>> = None;
let mut seen_separator = false;
for child in node.children() {
match child.kind() {
SyntaxKind::TABLE_CAPTION => {
let mut caption_body = String::new();
for caption_child in child.children_with_tokens() {
match caption_child {
rowan::NodeOrToken::Token(token)
if token.kind() == SyntaxKind::TABLE_CAPTION_PREFIX =>
{
}
rowan::NodeOrToken::Token(token) => {
caption_body.push_str(token.text());
}
rowan::NodeOrToken::Node(node) => {
caption_body.push_str(&node.text().to_string());
}
}
}
caption = Some(normalize_table_caption(&caption_body));
caption_after = seen_separator;
}
SyntaxKind::TABLE_SEPARATOR => {
separator_line = child.text().to_string();
seen_separator = true;
columns = extract_simple_table_columns(&separator_line);
}
SyntaxKind::TABLE_HEADER => {
let raw_text = child.text().to_string();
header_line = Some(raw_text);
let cells = extract_row_cells(&child, config);
if !cells.is_empty() {
header_cells = Some(cells);
} else {
header_cells = None;
}
}
SyntaxKind::TABLE_ROW => {
if !columns.is_empty() {
let cells = extract_row_cells(&child, config);
if !cells.is_empty() {
let is_separator = cells
.iter()
.all(|cell| cell.trim().chars().all(|c| c == '-'));
if !is_separator {
rows.push(cells);
}
} else {
let row_content = format_cell_content(&child, config);
let is_separator = row_content
.trim()
.chars()
.all(|c| c == '-' || c.is_whitespace());
if !is_separator {
let cells = split_simple_table_row(&row_content, &columns);
rows.push(cells);
}
}
}
}
_ => {}
}
}
if !columns.is_empty() {
determine_simple_alignments(&mut columns, &separator_line, header_line.as_deref());
}
let has_header = header_line.is_some() || header_cells.is_some();
if let Some(cells) = header_cells {
rows.insert(0, cells);
} else if let Some(header) = header_line {
let header_cells = split_simple_table_row(&header, &columns);
rows.insert(0, header_cells);
}
let alignments = columns.iter().map(|c| c.alignment).collect();
let column_widths: Vec<usize> = columns.iter().map(|c| c.end - c.start).collect();
let base_offset = columns.first().map(|c| c.start).unwrap_or(0);
let column_positions: Vec<(usize, usize)> = columns
.iter()
.map(|c| (c.start - base_offset, c.end - base_offset))
.collect();
TableData {
rows,
alignments,
caption,
caption_after,
column_widths: Some(column_widths),
column_positions: Some(column_positions),
has_header, }
}
pub fn format_simple_table(node: &SyntaxNode, config: &Config) -> String {
if !node.text().to_string().is_ascii() {
return node.text().to_string();
}
let table_data = extract_simple_table_data(node, config);
let mut output = String::new();
if table_data.rows.is_empty() {
return node.text().to_string();
}
let content_widths = calculate_column_widths(&table_data.rows);
let has_header = table_data.has_header;
let widths = if let Some(ref widths) = table_data.column_widths {
widths.clone()
} else {
content_widths.clone()
};
let normalized_positions = if let Some(ref positions) = table_data.column_positions {
let mut out = Vec::with_capacity(positions.len());
for (col_idx, &(start, end)) in positions.iter().enumerate() {
let original_width = end.saturating_sub(start);
if has_header {
let content_width = content_widths.get(col_idx).copied().unwrap_or(3);
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let preferred_width = content_width
+ match alignment {
Alignment::Center => 4,
Alignment::Left | Alignment::Right => 2,
Alignment::Default => 0,
};
let clamped_width = original_width.min(preferred_width).max(content_width);
out.push((start, start + clamped_width));
} else {
out.push((start, end));
}
}
Some(out)
} else {
None
};
if let Some(ref caption_text) = table_data.caption
&& !table_data.caption_after
{
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push_str("\n\n");
}
if !has_header
&& normalized_positions.is_some()
&& let Some(ref positions) = normalized_positions
{
let last_col_end = positions.last().map(|(_, end)| *end).unwrap_or(0);
let mut sep_chars: Vec<char> = vec![' '; last_col_end];
for &(col_start, col_end) in positions.iter() {
for i in col_start..col_end {
if i < sep_chars.len() {
sep_chars[i] = '-';
}
}
}
output.push_str(&sep_chars.iter().collect::<String>());
output.push('\n');
}
if has_header {
if let Some(ref positions) = normalized_positions {
let last_col_end = positions.last().map(|(_, end)| *end).unwrap_or(0);
let mut line_chars: Vec<char> = vec![' '; last_col_end];
for (col_idx, cell) in table_data.rows[0].iter().enumerate() {
if let Some(&(col_start, col_end)) = positions.get(col_idx) {
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let col_width = col_end - col_start;
let cell_chars: Vec<char> = cell.chars().collect();
let cell_width = cell.width();
let total_padding = col_width.saturating_sub(cell_width);
let text_start_in_col = match alignment {
Alignment::Left | Alignment::Default => 0,
Alignment::Right => total_padding,
Alignment::Center => total_padding / 2,
};
let mut char_pos = 0;
for &ch in &cell_chars {
let target_pos = col_start + text_start_in_col + char_pos;
if target_pos < line_chars.len() {
line_chars[target_pos] = ch;
char_pos += 1;
}
}
}
}
output.push_str(line_chars.iter().collect::<String>().trim_end());
output.push('\n');
let mut sep_chars: Vec<char> = vec![' '; last_col_end];
for &(col_start, col_end) in positions {
for i in col_start..col_end {
if i < sep_chars.len() {
sep_chars[i] = '-';
}
}
}
output.push_str(&sep_chars.iter().collect::<String>());
output.push('\n');
} else {
for (col_idx, cell) in table_data.rows[0].iter().enumerate() {
let width = widths.get(col_idx).copied().unwrap_or(3);
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let cell_width = cell.width();
let total_padding = width.saturating_sub(cell_width);
let padded_cell = match alignment {
Alignment::Left | Alignment::Default => {
format!("{}{}", cell, " ".repeat(total_padding))
}
Alignment::Right => {
format!("{}{}", " ".repeat(total_padding), cell)
}
Alignment::Center => {
let left_padding = total_padding / 2;
let right_padding = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
cell,
" ".repeat(right_padding)
)
}
};
output.push_str(&padded_cell);
if col_idx < table_data.rows[0].len() - 1 {
output.push(' ');
}
}
output.push('\n');
for (col_idx, width) in widths.iter().enumerate() {
output.push_str(&"-".repeat(*width));
if col_idx < widths.len() - 1 {
output.push(' ');
}
}
output.push('\n');
}
}
for row in table_data.rows.iter().skip(if has_header { 1 } else { 0 }) {
if let Some(ref positions) = normalized_positions {
let last_col_end = positions.last().map(|(_, end)| *end).unwrap_or(0);
let mut line_chars: Vec<char> = vec![' '; last_col_end];
for (col_idx, cell) in row.iter().enumerate() {
if let Some(&(col_start, col_end)) = positions.get(col_idx) {
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let col_width = col_end - col_start;
let cell_chars: Vec<char> = cell.chars().collect();
let cell_width = cell.width();
let total_padding = col_width.saturating_sub(cell_width);
let text_start_in_col = match alignment {
Alignment::Left | Alignment::Default => 0,
Alignment::Right => total_padding,
Alignment::Center => total_padding / 2,
};
let mut char_pos = 0;
for &ch in &cell_chars {
let target_pos = col_start + text_start_in_col + char_pos;
if target_pos < line_chars.len() {
line_chars[target_pos] = ch;
char_pos += 1;
}
}
}
}
output.push_str(line_chars.iter().collect::<String>().trim_end());
output.push('\n');
} else {
for (col_idx, cell) in row.iter().enumerate() {
let width = widths.get(col_idx).copied().unwrap_or(3);
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let cell_width = cell.width();
let total_padding = width.saturating_sub(cell_width);
let padded_cell = match alignment {
Alignment::Left | Alignment::Default => {
format!("{}{}", cell, " ".repeat(total_padding))
}
Alignment::Right => {
format!("{}{}", " ".repeat(total_padding), cell)
}
Alignment::Center => {
let left_padding = total_padding / 2;
let right_padding = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
cell,
" ".repeat(right_padding)
)
}
};
output.push_str(&padded_cell);
if col_idx < row.len() - 1 {
output.push(' ');
}
}
output.push('\n');
}
}
if !has_header
&& normalized_positions.is_some()
&& let Some(ref positions) = normalized_positions
{
let last_col_end = positions.last().map(|(_, end)| *end).unwrap_or(0);
let mut sep_chars: Vec<char> = vec![' '; last_col_end];
for &(col_start, col_end) in positions.iter() {
for i in col_start..col_end {
if i < sep_chars.len() {
sep_chars[i] = '-';
}
}
}
output.push_str(&sep_chars.iter().collect::<String>());
output.push('\n');
}
if let Some(ref caption_text) = table_data.caption
&& table_data.caption_after
{
output.push('\n');
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push('\n');
}
indent_table_block(&output)
}
fn extract_multiline_columns(separator_line: &str) -> Vec<(usize, usize)> {
let line = separator_line.trim_end();
let mut columns = Vec::new();
let mut in_dashes = false;
let mut col_start = 0;
for (i, ch) in line.char_indices() {
match ch {
'-' => {
if !in_dashes {
col_start = i;
in_dashes = true;
}
}
' ' => {
if in_dashes {
columns.push((col_start, i));
in_dashes = false;
}
}
_ => {}
}
}
if in_dashes {
columns.push((col_start, line.len()));
}
columns
}
fn determine_multiline_alignment(header_text: &str, col_start: usize, col_end: usize) -> Alignment {
if header_text.is_empty() {
return Alignment::Default;
}
let first_line = header_text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
let header_in_col = if col_end <= first_line.len() {
&first_line[col_start..col_end]
} else if col_start < first_line.len() {
&first_line[col_start..]
} else {
return Alignment::Default;
};
let text_start = header_in_col.len() - header_in_col.trim_start().len();
let trimmed_text = header_in_col.trim();
let text_end = text_start + trimmed_text.len();
let col_width = col_end - col_start;
let flush_left = text_start == 0;
let flush_right = text_end == col_width;
match (flush_left, flush_right) {
(true, true) => Alignment::Default,
(true, false) => Alignment::Left,
(false, true) => Alignment::Right,
(false, false) => Alignment::Center,
}
}
struct MultilineTableData {
rows: Vec<Vec<Vec<String>>>,
alignments: Vec<Alignment>,
caption: Option<String>,
column_positions: Vec<(usize, usize)>,
has_header: bool,
}
fn extract_multiline_cells(text: &str, column_positions: &[(usize, usize)]) -> Vec<Vec<String>> {
let lines: Vec<&str> = text.lines().collect();
let num_cols = column_positions.len();
let mut cells: Vec<Vec<String>> = vec![Vec::new(); num_cols];
for line in lines {
for (col_idx, &(col_start, col_end)) in column_positions.iter().enumerate() {
let cell_line = if col_end <= line.len() {
&line[col_start..col_end]
} else if col_start < line.len() {
&line[col_start..]
} else {
""
};
cells[col_idx].push(cell_line.trim().to_string());
}
}
cells
}
fn extract_cells_from_table_cell_nodes(
row: &SyntaxNode,
config: &Config,
column_positions: &[(usize, usize)],
) -> Vec<Vec<String>> {
let mut formatted_text = String::new();
for child in row.children_with_tokens() {
match child {
rowan::NodeOrToken::Token(token) => {
formatted_text.push_str(token.text());
}
rowan::NodeOrToken::Node(node) => {
if node.kind() == SyntaxKind::TABLE_CELL {
formatted_text.push_str(&format_cell_content(&node, config));
} else {
formatted_text.push_str(&node.text().to_string());
}
}
}
}
extract_multiline_cells(&formatted_text, column_positions)
}
fn extract_multiline_table_data(node: &SyntaxNode, config: &Config) -> MultilineTableData {
let mut rows: Vec<Vec<Vec<String>>> = Vec::new();
let mut column_positions: Vec<(usize, usize)> = Vec::new();
let mut alignments = Vec::new();
let mut caption = None;
let mut has_header = false;
let mut header_text = String::new();
let mut separator_count = 0;
for child in node.children() {
match child.kind() {
SyntaxKind::TABLE_CAPTION => {
let mut caption_body = String::new();
for caption_child in child.children_with_tokens() {
match caption_child {
rowan::NodeOrToken::Token(token)
if token.kind() == SyntaxKind::TABLE_CAPTION_PREFIX =>
{
}
rowan::NodeOrToken::Token(token) => {
caption_body.push_str(token.text());
}
rowan::NodeOrToken::Node(node) => {
caption_body.push_str(&node.text().to_string());
}
}
}
caption = Some(normalize_table_caption(&caption_body));
}
SyntaxKind::TABLE_SEPARATOR => {
separator_count += 1;
let sep_text = child.text().to_string();
if separator_count == 1 || (separator_count == 2 && has_header) {
column_positions = extract_multiline_columns(&sep_text);
}
}
SyntaxKind::TABLE_HEADER => {
has_header = true;
header_text = child.text().to_string();
}
SyntaxKind::TABLE_ROW => {
if child.children().any(|c| c.kind() == SyntaxKind::TABLE_CELL) {
let cells =
extract_cells_from_table_cell_nodes(&child, config, &column_positions);
rows.push(cells);
} else {
let row_content = format_cell_content(&child, config);
let cells = extract_multiline_cells(&row_content, &column_positions);
rows.push(cells);
}
}
_ => {}
}
}
if has_header && !column_positions.is_empty() {
let header_node = node
.children()
.find(|c| c.kind() == SyntaxKind::TABLE_HEADER);
let header_cells = if let Some(hdr) = header_node {
if hdr.children().any(|c| c.kind() == SyntaxKind::TABLE_CELL) {
extract_cells_from_table_cell_nodes(&hdr, config, &column_positions)
} else {
extract_multiline_cells(&header_text, &column_positions)
}
} else {
extract_multiline_cells(&header_text, &column_positions)
};
rows.insert(0, header_cells);
for &(col_start, col_end) in &column_positions {
let alignment = determine_multiline_alignment(&header_text, col_start, col_end);
alignments.push(alignment);
}
} else if !rows.is_empty() && !column_positions.is_empty() {
let first_row_node = node
.children()
.find(|c| c.kind() == SyntaxKind::TABLE_ROW)
.unwrap();
let first_row_text = first_row_node.text().to_string();
for &(col_start, col_end) in &column_positions {
let alignment = determine_multiline_alignment(&first_row_text, col_start, col_end);
alignments.push(alignment);
}
} else {
alignments = vec![Alignment::Default; column_positions.len()];
}
MultilineTableData {
rows,
alignments,
caption,
column_positions,
has_header,
}
}
pub fn format_multiline_table(node: &SyntaxNode, config: &Config) -> String {
if !node.text().to_string().is_ascii() {
return node.text().to_string();
}
let table_data = extract_multiline_table_data(node, config);
let mut output = String::new();
if table_data.rows.is_empty() || table_data.column_positions.is_empty() {
return node.text().to_string();
}
let base_offset = table_data
.column_positions
.first()
.map(|(start, _)| *start)
.unwrap_or(0);
let positions: Vec<(usize, usize)> = table_data
.column_positions
.iter()
.map(|(start, end)| {
(
start.saturating_sub(base_offset),
end.saturating_sub(base_offset),
)
})
.collect();
let last_col_end = positions.last().map(|(_, end)| *end).unwrap_or(0);
if let Some(ref caption_text) = table_data.caption {
let formatted_caption = format_table_caption(caption_text, config, node);
output.push_str(&formatted_caption);
output.push_str("\n\n"); }
if table_data.has_header {
output.push_str(&"-".repeat(last_col_end));
output.push('\n');
} else {
let mut sep_chars: Vec<char> = vec![' '; last_col_end];
for &(col_start, col_end) in &positions {
for item in sep_chars.iter_mut().take(col_end).skip(col_start) {
*item = '-';
}
}
output.push_str(&sep_chars.iter().collect::<String>());
output.push('\n');
}
if table_data.has_header && !table_data.rows.is_empty() {
let header_row = &table_data.rows[0];
let max_lines = header_row.iter().map(|cell| cell.len()).max().unwrap_or(0);
for line_idx in 0..max_lines {
let mut line_chars: Vec<char> = vec![' '; last_col_end];
for (col_idx, cell_lines) in header_row.iter().enumerate() {
if let Some(&(col_start, col_end)) = positions.get(col_idx) {
let cell_text = cell_lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let col_width = col_end - col_start;
let cell_width = cell_text.trim_end().width();
let total_padding = col_width.saturating_sub(cell_width);
let text_start_in_col = match alignment {
Alignment::Left | Alignment::Default => 0,
Alignment::Right => total_padding,
Alignment::Center => total_padding / 2,
};
for (i, ch) in cell_text.trim_end().chars().enumerate() {
let target_pos = col_start + text_start_in_col + i;
if target_pos < line_chars.len() {
line_chars[target_pos] = ch;
}
}
}
}
output.push_str(line_chars.iter().collect::<String>().trim_end());
output.push('\n');
}
let mut sep_chars: Vec<char> = vec![' '; last_col_end];
for &(col_start, col_end) in &positions {
for item in sep_chars.iter_mut().take(col_end).skip(col_start) {
*item = '-';
}
}
output.push_str(&sep_chars.iter().collect::<String>());
output.push('\n');
}
let start_row = if table_data.has_header { 1 } else { 0 };
for (row_idx, row) in table_data.rows.iter().enumerate().skip(start_row) {
let max_lines = row.iter().map(|cell| cell.len()).max().unwrap_or(0);
for line_idx in 0..max_lines {
let mut line_chars: Vec<char> = vec![' '; last_col_end];
for (col_idx, cell_lines) in row.iter().enumerate() {
if let Some(&(col_start, col_end)) = positions.get(col_idx) {
let cell_text = cell_lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
let alignment = table_data
.alignments
.get(col_idx)
.copied()
.unwrap_or(Alignment::Default);
let col_width = col_end - col_start;
let cell_width = cell_text.trim_end().width();
let total_padding = col_width.saturating_sub(cell_width);
let text_start_in_col = match alignment {
Alignment::Left | Alignment::Default => 0,
Alignment::Right => total_padding,
Alignment::Center => total_padding / 2,
};
for (i, ch) in cell_text.trim_end().chars().enumerate() {
let target_pos = col_start + text_start_in_col + i;
if target_pos < line_chars.len() {
line_chars[target_pos] = ch;
}
}
}
}
output.push_str(line_chars.iter().collect::<String>().trim_end());
output.push('\n');
}
if row_idx < table_data.rows.len() - 1 {
output.push('\n');
}
}
let num_body_rows = table_data.rows.len() - if table_data.has_header { 1 } else { 0 };
if num_body_rows == 1 && table_data.has_header {
output.push('\n');
}
if table_data.has_header {
output.push_str(&"-".repeat(last_col_end));
output.push('\n');
} else {
let mut sep_chars: Vec<char> = vec![' '; last_col_end];
for &(col_start, col_end) in &positions {
for item in sep_chars.iter_mut().take(col_end).skip(col_start) {
*item = '-';
}
}
output.push_str(&sep_chars.iter().collect::<String>());
output.push('\n');
}
indent_table_block(&output)
}