use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::range_utils::calculate_line_range;
use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
use crate::utils::table_utils::TableUtils;
use unicode_width::UnicodeWidthStr;
mod md060_config;
use crate::md013_line_length::MD013Config;
pub use md060_config::ColumnAlign;
pub use md060_config::MD060Config;
#[derive(Debug, Clone, Copy, PartialEq)]
enum RowType {
Header,
Delimiter,
Body,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ColumnAlignment {
Left,
Center,
Right,
}
#[derive(Debug, Clone)]
struct TableFormatResult {
lines: Vec<String>,
auto_compacted: bool,
aligned_width: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
struct RowFormatOptions {
row_type: RowType,
compact_delimiter: bool,
column_align: ColumnAlign,
column_align_header: Option<ColumnAlign>,
column_align_body: Option<ColumnAlign>,
}
#[derive(Debug, Clone, Default)]
pub struct MD060TableFormat {
config: MD060Config,
md013_config: MD013Config,
md013_disabled: bool,
}
impl MD060TableFormat {
pub fn new(enabled: bool, style: String) -> Self {
use crate::types::LineLength;
Self {
config: MD060Config {
enabled,
style,
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
},
md013_config: MD013Config::default(),
md013_disabled: false,
}
}
pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
Self {
config,
md013_config,
md013_disabled,
}
}
fn effective_max_width(&self) -> usize {
if !self.config.max_width.is_unlimited() {
return self.config.max_width.get();
}
if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
return usize::MAX; }
self.md013_config.line_length.get()
}
fn contains_problematic_chars(text: &str) -> bool {
text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
fn calculate_cell_display_width(cell_content: &str) -> usize {
let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
masked.trim().width()
}
#[cfg(test)]
fn parse_table_row(line: &str) -> Vec<String> {
TableUtils::split_table_row(line)
}
fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
TableUtils::split_table_row_with_flavor(line, flavor)
}
fn is_delimiter_row(row: &[String]) -> bool {
if row.is_empty() {
return false;
}
row.iter().all(|cell| {
let trimmed = cell.trim();
!trimmed.is_empty()
&& trimmed.contains('-')
&& trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
})
}
fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
(&line[..m.end()], &line[m.end()..])
} else {
("", line)
}
}
fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
delimiter_row
.iter()
.map(|cell| {
let trimmed = cell.trim();
let has_left_colon = trimmed.starts_with(':');
let has_right_colon = trimmed.ends_with(':');
match (has_left_colon, has_right_colon) {
(true, true) => ColumnAlignment::Center,
(false, true) => ColumnAlignment::Right,
_ => ColumnAlignment::Left,
}
})
.collect()
}
fn calculate_column_widths(
table_lines: &[&str],
flavor: crate::config::MarkdownFlavor,
loose_last_column: bool,
) -> Vec<usize> {
let mut column_widths = Vec::new();
let mut delimiter_cells: Option<Vec<String>> = None;
let mut is_header = true;
let mut header_last_col_width: Option<usize> = None;
for line in table_lines {
let cells = Self::parse_table_row_with_flavor(line, flavor);
if Self::is_delimiter_row(&cells) {
delimiter_cells = Some(cells);
is_header = false;
continue;
}
for (i, cell) in cells.iter().enumerate() {
let width = Self::calculate_cell_display_width(cell);
if i >= column_widths.len() {
column_widths.push(width);
} else {
column_widths[i] = column_widths[i].max(width);
}
}
if is_header && !cells.is_empty() {
let last_idx = cells.len() - 1;
header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
is_header = false;
}
}
if loose_last_column
&& let Some(header_width) = header_last_col_width
&& let Some(last) = column_widths.last_mut()
{
*last = header_width;
}
let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
if let Some(delimiter_cells) = delimiter_cells {
for (i, cell) in delimiter_cells.iter().enumerate() {
if i < final_widths.len() {
let trimmed = cell.trim();
let has_left_colon = trimmed.starts_with(':');
let has_right_colon = trimmed.ends_with(':');
let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
let min_width_for_delimiter = 3 + colon_count;
final_widths[i] = final_widths[i].max(min_width_for_delimiter);
}
}
}
final_widths
}
fn format_table_row(
cells: &[String],
column_widths: &[usize],
column_alignments: &[ColumnAlignment],
options: &RowFormatOptions,
) -> String {
let formatted_cells: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, cell)| {
let target_width = column_widths.get(i).copied().unwrap_or(0);
match options.row_type {
RowType::Delimiter => {
let trimmed = cell.trim();
let has_left_colon = trimmed.starts_with(':');
let has_right_colon = trimmed.ends_with(':');
let extra_width = if options.compact_delimiter { 2 } else { 0 };
let dash_count = if has_left_colon && has_right_colon {
(target_width + extra_width).saturating_sub(2)
} else if has_left_colon || has_right_colon {
(target_width + extra_width).saturating_sub(1)
} else {
target_width + extra_width
};
let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
format!(":{dashes}:")
} else if has_left_colon {
format!(":{dashes}")
} else if has_right_colon {
format!("{dashes}:")
} else {
dashes
};
if options.compact_delimiter {
delimiter_content
} else {
format!(" {delimiter_content} ")
}
}
RowType::Header | RowType::Body => {
let trimmed = cell.trim();
let current_width = Self::calculate_cell_display_width(cell);
let padding = target_width.saturating_sub(current_width);
let effective_align = match options.row_type {
RowType::Header => options.column_align_header.unwrap_or(options.column_align),
RowType::Body => options.column_align_body.unwrap_or(options.column_align),
RowType::Delimiter => unreachable!(),
};
let alignment = match effective_align {
ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
ColumnAlign::Left => ColumnAlignment::Left,
ColumnAlign::Center => ColumnAlignment::Center,
ColumnAlign::Right => ColumnAlignment::Right,
};
match alignment {
ColumnAlignment::Left => {
format!(" {trimmed}{} ", " ".repeat(padding))
}
ColumnAlignment::Center => {
let left_padding = padding / 2;
let right_padding = padding - left_padding;
format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
}
ColumnAlignment::Right => {
format!(" {}{trimmed} ", " ".repeat(padding))
}
}
}
}
})
.collect();
format!("|{}|", formatted_cells.join("|"))
}
fn format_table_compact(cells: &[String]) -> String {
let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
format!("|{}|", formatted_cells.join("|"))
}
fn format_table_tight(cells: &[String]) -> String {
let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
format!("|{}|", formatted_cells.join("|"))
}
fn is_table_already_aligned(
table_lines: &[&str],
flavor: crate::config::MarkdownFlavor,
compact_delimiter: bool,
) -> bool {
if table_lines.len() < 2 {
return false;
}
let first_width = UnicodeWidthStr::width(table_lines[0]);
if !table_lines
.iter()
.all(|line| UnicodeWidthStr::width(*line) == first_width)
{
return false;
}
let parsed: Vec<Vec<String>> = table_lines
.iter()
.map(|line| Self::parse_table_row_with_flavor(line, flavor))
.collect();
if parsed.is_empty() {
return false;
}
let num_columns = parsed[0].len();
if !parsed.iter().all(|row| row.len() == num_columns) {
return false;
}
if let Some(delimiter_row) = parsed.get(1) {
if !Self::is_delimiter_row(delimiter_row) {
return false;
}
for cell in delimiter_row {
let trimmed = cell.trim();
let dash_count = trimmed.chars().filter(|&c| c == '-').count();
if dash_count < 1 {
return false;
}
}
let delimiter_has_spaces = delimiter_row
.iter()
.all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
if compact_delimiter && delimiter_has_spaces {
return false;
}
if !compact_delimiter && !delimiter_has_spaces {
return false;
}
}
for col_idx in 0..num_columns {
let mut widths = Vec::new();
for (row_idx, row) in parsed.iter().enumerate() {
if row_idx == 1 {
continue;
}
if let Some(cell) = row.get(col_idx) {
widths.push(cell.width());
}
}
if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
return false;
}
}
if let Some(delimiter_row) = parsed.get(1) {
let alignments = Self::parse_column_alignments(delimiter_row);
for (col_idx, alignment) in alignments.iter().enumerate() {
if *alignment == ColumnAlignment::Left {
continue;
}
for (row_idx, row) in parsed.iter().enumerate() {
if row_idx == 1 {
continue;
}
if let Some(cell) = row.get(col_idx) {
if cell.trim().is_empty() {
continue;
}
let left_pad = cell.len() - cell.trim_start().len();
let right_pad = cell.len() - cell.trim_end().len();
match alignment {
ColumnAlignment::Center => {
if left_pad.abs_diff(right_pad) > 1 {
return false;
}
}
ColumnAlignment::Right => {
if left_pad < right_pad {
return false;
}
}
ColumnAlignment::Left => unreachable!(),
}
}
}
}
}
true
}
fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
if table_lines.is_empty() {
return None;
}
let mut is_tight = true;
let mut is_compact = true;
for line in table_lines {
let cells = Self::parse_table_row_with_flavor(line, flavor);
if cells.is_empty() {
continue;
}
if Self::is_delimiter_row(&cells) {
continue;
}
let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
let row_has_single_space = cells.iter().all(|cell| {
let trimmed = cell.trim();
cell == &format!(" {trimmed} ")
});
if !row_has_no_padding {
is_tight = false;
}
if !row_has_single_space {
is_compact = false;
}
if !is_tight && !is_compact {
return Some("aligned".to_string());
}
}
if is_tight {
Some("tight".to_string())
} else if is_compact {
Some("compact".to_string())
} else {
Some("aligned".to_string())
}
}
fn fix_table_block(
&self,
lines: &[&str],
table_block: &crate::utils::table_utils::TableBlock,
flavor: crate::config::MarkdownFlavor,
) -> TableFormatResult {
let mut result = Vec::new();
let mut auto_compacted = false;
let mut aligned_width = None;
let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
.chain(std::iter::once(lines[table_block.delimiter_line]))
.chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
.collect();
if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
return TableFormatResult {
lines: table_lines.iter().map(|s| s.to_string()).collect(),
auto_compacted: false,
aligned_width: None,
};
}
let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
let list_context = &table_block.list_context;
let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
(ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
} else {
("", String::new())
};
let stripped_lines: Vec<&str> = table_lines
.iter()
.enumerate()
.map(|(i, line)| {
let after_blockquote = Self::extract_blockquote_prefix(line).1;
if list_context.is_some() {
if i == 0 {
after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
})
} else {
after_blockquote
.strip_prefix(&continuation_indent)
.unwrap_or(after_blockquote.trim_start())
}
} else {
after_blockquote
}
})
.collect();
let style = self.config.style.as_str();
match style {
"any" => {
let detected_style = Self::detect_table_style(&stripped_lines, flavor);
if detected_style.is_none() {
return TableFormatResult {
lines: table_lines.iter().map(|s| s.to_string()).collect(),
auto_compacted: false,
aligned_width: None,
};
}
let target_style = detected_style.unwrap();
let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
let column_alignments = Self::parse_column_alignments(&delimiter_cells);
for (row_idx, line) in stripped_lines.iter().enumerate() {
let cells = Self::parse_table_row_with_flavor(line, flavor);
match target_style.as_str() {
"tight" => result.push(Self::format_table_tight(&cells)),
"compact" => result.push(Self::format_table_compact(&cells)),
_ => {
let column_widths =
Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
let row_type = match row_idx {
0 => RowType::Header,
1 => RowType::Delimiter,
_ => RowType::Body,
};
let options = RowFormatOptions {
row_type,
compact_delimiter: false,
column_align: self.config.column_align,
column_align_header: self.config.column_align_header,
column_align_body: self.config.column_align_body,
};
result.push(Self::format_table_row(
&cells,
&column_widths,
&column_alignments,
&options,
));
}
}
}
}
"compact" => {
for line in &stripped_lines {
let cells = Self::parse_table_row_with_flavor(line, flavor);
result.push(Self::format_table_compact(&cells));
}
}
"tight" => {
for line in &stripped_lines {
let cells = Self::parse_table_row_with_flavor(line, flavor);
result.push(Self::format_table_tight(&cells));
}
}
"aligned" | "aligned-no-space" => {
let compact_delimiter = style == "aligned-no-space";
let needs_reformat = self.config.column_align != ColumnAlign::Auto
|| self.config.column_align_header.is_some()
|| self.config.column_align_body.is_some()
|| self.config.loose_last_column;
if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
return TableFormatResult {
lines: table_lines.iter().map(|s| s.to_string()).collect(),
auto_compacted: false,
aligned_width: None,
};
}
let column_widths =
Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
let num_columns = column_widths.len();
let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
aligned_width = Some(calc_aligned_width);
if calc_aligned_width > self.effective_max_width() {
auto_compacted = true;
for line in &stripped_lines {
let cells = Self::parse_table_row_with_flavor(line, flavor);
result.push(Self::format_table_compact(&cells));
}
} else {
let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
let column_alignments = Self::parse_column_alignments(&delimiter_cells);
for (row_idx, line) in stripped_lines.iter().enumerate() {
let cells = Self::parse_table_row_with_flavor(line, flavor);
let row_type = match row_idx {
0 => RowType::Header,
1 => RowType::Delimiter,
_ => RowType::Body,
};
let options = RowFormatOptions {
row_type,
compact_delimiter,
column_align: self.config.column_align,
column_align_header: self.config.column_align_header,
column_align_body: self.config.column_align_body,
};
result.push(Self::format_table_row(
&cells,
&column_widths,
&column_alignments,
&options,
));
}
}
}
_ => {
return TableFormatResult {
lines: table_lines.iter().map(|s| s.to_string()).collect(),
auto_compacted: false,
aligned_width: None,
};
}
}
let prefixed_result: Vec<String> = result
.into_iter()
.enumerate()
.map(|(i, line)| {
if list_context.is_some() {
if i == 0 {
format!("{blockquote_prefix}{list_prefix}{line}")
} else {
format!("{blockquote_prefix}{continuation_indent}{line}")
}
} else {
format!("{blockquote_prefix}{line}")
}
})
.collect();
TableFormatResult {
lines: prefixed_result,
auto_compacted,
aligned_width,
}
}
}
impl Rule for MD060TableFormat {
fn name(&self) -> &'static str {
"MD060"
}
fn description(&self) -> &'static str {
"Table columns should be consistently aligned"
}
fn category(&self) -> RuleCategory {
RuleCategory::Table
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
!ctx.likely_has_tables()
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let line_index = &ctx.line_index;
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let table_blocks = &ctx.table_blocks;
for table_block in table_blocks {
let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
.chain(std::iter::once(table_block.delimiter_line))
.chain(table_block.content_lines.iter().copied())
.collect();
let table_start_line = table_block.start_line + 1; let table_end_line = table_block.end_line + 1;
let mut fixed_table_lines: Vec<String> = Vec::with_capacity(table_line_indices.len());
for (i, &line_idx) in table_line_indices.iter().enumerate() {
let fixed_line = &format_result.lines[i];
if line_idx < lines.len() - 1 {
fixed_table_lines.push(format!("{fixed_line}\n"));
} else {
fixed_table_lines.push(fixed_line.clone());
}
}
let table_replacement = fixed_table_lines.concat();
let table_range = line_index.multi_line_range(table_start_line, table_end_line);
for (i, &line_idx) in table_line_indices.iter().enumerate() {
let original = lines[line_idx];
let fixed = &format_result.lines[i];
if original != fixed {
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
let message = if format_result.auto_compacted {
if let Some(width) = format_result.aligned_width {
format!(
"Table too wide for aligned formatting ({} chars > max-width: {})",
width,
self.effective_max_width()
)
} else {
"Table too wide for aligned formatting".to_string()
}
} else {
"Table columns should be aligned".to_string()
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
message,
line: start_line,
column: start_col,
end_line,
end_column: end_col,
fix: Some(crate::rule::Fix {
range: table_range.clone(),
replacement: table_replacement.clone(),
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
let lines = ctx.raw_lines();
let table_blocks = &ctx.table_blocks;
let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
for table_block in table_blocks {
let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
.chain(std::iter::once(table_block.delimiter_line))
.chain(table_block.content_lines.iter().copied())
.collect();
let any_disabled = table_line_indices
.iter()
.any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
if any_disabled {
continue;
}
for (i, &line_idx) in table_line_indices.iter().enumerate() {
result_lines[line_idx] = format_result.lines[i].clone();
}
}
let mut fixed = result_lines.join("\n");
if content.ends_with('\n') && !fixed.ends_with('\n') {
fixed.push('\n');
}
Ok(fixed)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let table = crate::rule_config_serde::config_schema_table(&MD060Config::default())?;
Some((MD060Config::RULE_NAME.to_string(), toml::Value::Table(table)))
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use crate::types::LineLength;
fn md013_with_line_length(line_length: usize) -> MD013Config {
MD013Config {
line_length: LineLength::from_const(line_length),
tables: true, ..Default::default()
}
}
#[test]
fn test_md060_align_simple_ascii_table() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
assert_eq!(fixed, expected);
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
}
#[test]
fn test_md060_cjk_characters_aligned_correctly() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Name | Age |\n|---|---|\n| 中文 | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
assert_eq!(width1, width3);
}
#[test]
fn test_md060_basic_emoji() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Status | Name |\n|---|---|\n| ✅ | Test |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("Status"));
}
#[test]
fn test_md060_zwj_emoji_skipped() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Emoji | Name |\n|---|---|\n| 👨👩👧👦 | Family |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md060_inline_code_with_escaped_pipes() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
}
#[test]
fn test_md060_compact_style() {
let rule = MD060TableFormat::new(true, "compact".to_string());
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
assert_eq!(fixed, expected);
}
#[test]
fn test_md060_tight_style() {
let rule = MD060TableFormat::new(true, "tight".to_string());
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
assert_eq!(fixed, expected);
}
#[test]
fn test_md060_aligned_no_space_style() {
let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
assert_eq!(
lines[1], "|-------|-----|",
"Delimiter should have NO spaces around dashes"
);
assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
}
#[test]
fn test_md060_aligned_no_space_preserves_alignment_indicators() {
let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
fixed.contains("|:"),
"Should have left alignment indicator adjacent to pipe"
);
assert!(
fixed.contains(":|"),
"Should have right alignment indicator adjacent to pipe"
);
assert!(
lines[1].contains(":---") && lines[1].contains("---:"),
"Should have center alignment colons"
);
}
#[test]
fn test_md060_aligned_no_space_three_column_table() {
let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
let content = "| Header 1 | Header 2 | Header 3 |\n|---|---|---|\n| Row 1, Col 1 | Row 1, Col 2 | Row 1, Col 3 |\n| Row 2, Col 1 | Row 2, Col 2 | Row 2, Col 3 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
}
#[test]
fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
let config = MD060Config {
enabled: true,
style: "aligned-no-space".to_string(),
max_width: LineLength::from_const(50),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| --- |"),
"Should be compact format when exceeding max-width"
);
}
#[test]
fn test_md060_aligned_no_space_cjk_characters() {
let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
let content = "| Name | City |\n|---|---|\n| 中文 | 東京 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
use unicode_width::UnicodeWidthStr;
assert_eq!(
lines[0].width(),
lines[1].width(),
"Header and delimiter should have same display width"
);
assert_eq!(
lines[1].width(),
lines[2].width(),
"Delimiter and content should have same display width"
);
assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
}
#[test]
fn test_md060_aligned_no_space_minimum_width() {
let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
let content = "| A | B |\n|-|-|\n| 1 | 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
}
#[test]
fn test_md060_any_style_consistency() {
let rule = MD060TableFormat::new(true, "any".to_string());
let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
assert_eq!(fixed_aligned, content_aligned);
}
#[test]
fn test_md060_empty_cells() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| A | B |\n|---|---|\n| | X |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("|"));
}
#[test]
fn test_md060_mixed_content() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Name | Age | City |\n|---|---|---|\n| 中文 | 30 | NYC |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("中文"));
assert!(fixed.contains("NYC"));
}
#[test]
fn test_md060_preserve_alignment_indicators() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(":---"), "Should contain left alignment");
assert!(fixed.contains(":----:"), "Should contain center alignment");
assert!(fixed.contains("----:"), "Should contain right alignment");
}
#[test]
fn test_md060_minimum_column_width() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| ID | Name |\n|-|-|\n| 1 | A |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
assert!(fixed.contains("ID "), "Short content should be padded");
assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
}
#[test]
fn test_md060_auto_compact_exceeds_default_threshold() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
assert!(fixed.contains("| --- | --- | --- |"));
assert!(fixed.contains("| Short | Data | Here |"));
let lines: Vec<&str> = fixed.lines().collect();
assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
}
#[test]
fn test_md060_auto_compact_exceeds_explicit_threshold() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(50),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
);
assert!(fixed.contains("| --- | --- | --- |"));
assert!(fixed.contains("| Data | Data | Data |"));
let lines: Vec<&str> = fixed.lines().collect();
assert!(lines[0].len() != lines[2].len());
}
#[test]
fn test_md060_stays_aligned_under_threshold() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(100),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
assert_eq!(fixed, expected);
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
}
#[test]
fn test_md060_width_calculation_formula() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
assert_eq!(lines[0].len(), 25);
let config_tight = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(24),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
let fixed_compact = rule_tight.fix(&ctx).unwrap();
assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
assert!(fixed_compact.contains("| --- | --- | --- |"));
}
#[test]
fn test_md060_very_wide_table_auto_compacts() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |\n|---|---|---|---|---|---|---|---|\n| A | B | C | D | E | F | G | H |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
}
#[test]
fn test_md060_inherit_from_md013_line_length() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let _fixed_80 = rule_80.fix(&ctx).unwrap();
let fixed_120 = rule_120.fix(&ctx).unwrap();
let lines_120: Vec<&str> = fixed_120.lines().collect();
assert_eq!(lines_120[0].len(), lines_120[1].len());
assert_eq!(lines_120[1].len(), lines_120[2].len());
}
#[test]
fn test_md060_edge_case_exactly_at_threshold() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(17),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[0].len(), 17);
assert_eq!(lines[0].len(), lines[1].len());
assert_eq!(lines[1].len(), lines[2].len());
let config_under = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(16),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
let fixed_compact = rule_under.fix(&ctx).unwrap();
assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
assert!(fixed_compact.contains("| --- | --- |"));
}
#[test]
fn test_md060_auto_compact_warning_message() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(50),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty(), "Should generate warnings");
let auto_compact_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("too wide for aligned formatting"))
.collect();
assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
let first_warning = auto_compact_warnings[0];
assert!(first_warning.message.contains("85 chars > max-width: 50"));
assert!(first_warning.message.contains("Table too wide for aligned formatting"));
}
#[test]
fn test_md060_issue_129_detect_style_from_all_rows() {
let rule = MD060TableFormat::new(true, "any".to_string());
let content = "| a long heading | another long heading |\n\
| -------------- | -------------------- |\n\
| a | 1 |\n\
| b b | 2 |\n\
| c c c | 3 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| a | 1 |"),
"Should preserve aligned padding in first content row"
);
assert!(
fixed.contains("| b b | 2 |"),
"Should preserve aligned padding in second content row"
);
assert!(
fixed.contains("| c c c | 3 |"),
"Should preserve aligned padding in third content row"
);
assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
}
#[test]
fn test_md060_regular_alignment_warning_message() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty(), "Should generate warnings");
assert!(warnings[0].message.contains("Table columns should be aligned"));
assert!(!warnings[0].message.contains("too wide"));
assert!(!warnings[0].message.contains("max-width"));
}
#[test]
fn test_md060_unlimited_when_md013_disabled() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let md013_config = MD013Config::default();
let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(
lines[0].len(),
lines[1].len(),
"Table should be aligned when MD013 is disabled"
);
}
#[test]
fn test_md060_unlimited_when_md013_tables_false() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let md013_config = MD013Config {
tables: false, line_length: LineLength::from_const(80),
..Default::default()
};
let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(
lines[0].len(),
lines[1].len(),
"Table should be aligned when MD013.tables=false"
);
}
#[test]
fn test_md060_unlimited_when_md013_line_length_zero() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let md013_config = MD013Config {
tables: true,
line_length: LineLength::from_const(0), ..Default::default()
};
let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(
lines[0].len(),
lines[1].len(),
"Table should be aligned when MD013.line_length=0"
);
}
#[test]
fn test_md060_explicit_max_width_overrides_md013_settings() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let md013_config = MD013Config {
tables: false, line_length: LineLength::from_const(0), ..Default::default()
};
let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| --- |"),
"Should be compact format due to explicit max_width"
);
}
#[test]
fn test_md060_inherits_md013_line_length_when_tables_enabled() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let md013_config = MD013Config {
tables: true,
line_length: LineLength::from_const(50), ..Default::default()
};
let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| --- |"),
"Should be compact format when inheriting MD013 limit"
);
}
#[test]
fn test_aligned_no_space_reformats_spaced_delimiter() {
let config = MD060Config {
enabled: true,
style: "aligned-no-space".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("| ----"),
"Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
);
assert!(
!fixed.contains("---- |"),
"Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
);
assert!(
fixed.contains("|----"),
"Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
);
}
#[test]
fn test_aligned_reformats_compact_delimiter() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
"Delimiter should have spaces around dashes. Got:\n{fixed}"
);
}
#[test]
fn test_aligned_no_space_preserves_matching_table() {
let config = MD060Config {
enabled: true,
style: "aligned-no-space".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Table already in aligned-no-space style should be preserved"
);
}
#[test]
fn test_aligned_preserves_matching_table() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Table already in aligned style should be preserved");
}
#[test]
fn test_cjk_table_display_width_consistency() {
let table_lines = vec!["| 名前 | Age |", "|------|-----|", "| 田中 | 25 |"];
let is_aligned =
MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
assert!(
!is_aligned,
"Table with uneven raw line lengths should NOT be considered aligned"
);
}
#[test]
fn test_cjk_width_calculation_in_aligned_check() {
let cjk_width = MD060TableFormat::calculate_cell_display_width("名前");
assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
let padded_cjk = MD060TableFormat::calculate_cell_display_width(" 名前 ");
assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
let mixed = MD060TableFormat::calculate_cell_display_width(" 日本語ABC ");
assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
}
#[test]
fn test_md060_column_align_left() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Left,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[2].contains("| Alice "),
"Content should be left-aligned (Alice should have trailing padding)"
);
assert!(
lines[3].contains("| Bob "),
"Content should be left-aligned (Bob should have trailing padding)"
);
}
#[test]
fn test_md060_column_align_center() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Center,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[3].contains("| Bob |"),
"Bob should be centered with padding on both sides. Got: {}",
lines[3]
);
}
#[test]
fn test_md060_column_align_right() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Right,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[3].contains("| Bob |"),
"Bob should be right-aligned with padding on left. Got: {}",
lines[3]
);
}
#[test]
fn test_md060_column_align_auto_respects_delimiter() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("| A "), "Left column should be left-aligned");
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[2].contains(" C |"),
"Right column should be right-aligned. Got: {}",
lines[2]
);
}
#[test]
fn test_md060_column_align_overrides_delimiter_indicators() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Right, column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[2].contains(" A |") || lines[2].contains(" A |"),
"Even left-indicated column should be right-aligned. Got: {}",
lines[2]
);
}
#[test]
fn test_md060_column_align_with_aligned_no_space() {
let config = MD060Config {
enabled: true,
style: "aligned-no-space".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Center,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[1].contains("|---"),
"Delimiter should have no spaces in aligned-no-space style. Got: {}",
lines[1]
);
assert!(
lines[3].contains("| Bob |"),
"Content should be centered. Got: {}",
lines[3]
);
}
#[test]
fn test_md060_column_align_config_parsing() {
let toml_str = r#"
enabled = true
style = "aligned"
column-align = "center"
"#;
let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
assert_eq!(config.column_align, ColumnAlign::Center);
let toml_str = r#"
enabled = true
style = "aligned"
column-align = "right"
"#;
let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
assert_eq!(config.column_align, ColumnAlign::Right);
let toml_str = r#"
enabled = true
style = "aligned"
column-align = "left"
"#;
let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
assert_eq!(config.column_align, ColumnAlign::Left);
let toml_str = r#"
enabled = true
style = "aligned"
column-align = "auto"
"#;
let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
assert_eq!(config.column_align, ColumnAlign::Auto);
}
#[test]
fn test_md060_column_align_default_is_auto() {
let toml_str = r#"
enabled = true
style = "aligned"
"#;
let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
assert_eq!(config.column_align, ColumnAlign::Auto);
}
#[test]
fn test_md060_column_align_reformats_already_aligned_table() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Right,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
"Already aligned table should be reformatted with right alignment. Got: {}",
lines[2]
);
assert!(
lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
"Bob should be right-aligned. Got: {}",
lines[3]
);
}
#[test]
fn test_md060_column_align_with_cjk_characters() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Center,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | City |\n|---|---|\n| Alice | 東京 |\n| Bob | LA |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("Bob"), "Table should contain Bob");
assert!(fixed.contains("東京"), "Table should contain 東京");
}
#[test]
fn test_md060_column_align_ignored_for_compact_style() {
let config = MD060Config {
enabled: true,
style: "compact".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Right, column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("| Alice |"),
"Compact style should have single space padding, not alignment. Got: {fixed}"
);
}
#[test]
fn test_md060_column_align_ignored_for_tight_style() {
let config = MD060Config {
enabled: true,
style: "tight".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Center, column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("|Alice|"),
"Tight style should have no spaces. Got: {fixed}"
);
}
#[test]
fn test_md060_column_align_with_empty_cells() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Center,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(
lines[3].contains("| |") || lines[3].contains("| |"),
"Empty cell should be padded correctly. Got: {}",
lines[3]
);
}
#[test]
fn test_md060_column_align_auto_preserves_already_aligned() {
let config = MD060Config {
enabled: true,
style: "aligned".to_string(),
max_width: LineLength::from_const(0),
column_align: ColumnAlign::Auto,
column_align_header: None,
column_align_body: None,
loose_last_column: false,
};
let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Already aligned table should be preserved with column-align=auto"
);
}
#[test]
fn test_cjk_table_display_aligned_not_flagged() {
use crate::config::MarkdownFlavor;
let table_lines: Vec<&str> = vec![
"| Header | Name |",
"| ------ | ---- |",
"| Hello | Test |",
"| 你好 | Test |",
];
let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
assert!(
result,
"Table with CJK characters that is display-aligned should be recognized as aligned"
);
}
#[test]
fn test_cjk_table_not_reformatted_when_aligned() {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| 你好 | Test |\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
}
}