Skip to main content

rumdl_lib/rules/
md060_table_format.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
4use crate::utils::table_utils::TableUtils;
5use unicode_width::UnicodeWidthStr;
6
7mod md060_config;
8use crate::md013_line_length::MD013Config;
9pub use md060_config::ColumnAlign;
10pub use md060_config::MD060Config;
11
12/// Identifies the type of row in a table for formatting purposes.
13#[derive(Debug, Clone, Copy, PartialEq)]
14enum RowType {
15    /// The first row containing column headers
16    Header,
17    /// The second row containing delimiter dashes (e.g., `|---|---|`)
18    Delimiter,
19    /// Data rows following the delimiter
20    Body,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24enum ColumnAlignment {
25    Left,
26    Center,
27    Right,
28}
29
30#[derive(Debug, Clone)]
31struct TableFormatResult {
32    lines: Vec<String>,
33    auto_compacted: bool,
34    aligned_width: Option<usize>,
35}
36
37/// Formatting options for a single table row.
38#[derive(Debug, Clone, Copy)]
39struct RowFormatOptions {
40    /// The type of row being formatted
41    row_type: RowType,
42    /// Whether to use compact delimiter style (no spaces around dashes)
43    compact_delimiter: bool,
44    /// Global column alignment override
45    column_align: ColumnAlign,
46    /// Header-specific column alignment (overrides column_align for header)
47    column_align_header: Option<ColumnAlign>,
48    /// Body-specific column alignment (overrides column_align for body)
49    column_align_body: Option<ColumnAlign>,
50}
51
52/// Rule MD060: Table Column Alignment
53///
54/// See [docs/md060.md](../../docs/md060.md) for full documentation, configuration, and examples.
55///
56/// This rule enforces consistent column alignment in Markdown tables for improved readability
57/// in source form. When enabled, it ensures table columns are properly aligned with appropriate
58/// padding.
59///
60/// ## Purpose
61///
62/// - **Readability**: Aligned tables are significantly easier to read in source form
63/// - **Maintainability**: Properly formatted tables are easier to edit and review
64/// - **Consistency**: Ensures uniform table formatting throughout documents
65/// - **Developer Experience**: Makes working with tables in plain text more pleasant
66///
67/// ## Configuration Options
68///
69/// The rule supports the following configuration options:
70///
71/// ```toml
72/// [MD013]
73/// line-length = 100  # MD060 inherits this by default
74///
75/// [MD060]
76/// enabled = false      # Default: opt-in for conservative adoption
77/// style = "aligned"    # Can be "aligned", "compact", "tight", or "any"
78/// max-width = 0        # Default: inherit from MD013's line-length
79/// ```
80///
81/// ### Style Options
82///
83/// - **aligned**: Columns are padded with spaces for visual alignment (default)
84/// - **compact**: Minimal spacing with single spaces
85/// - **tight**: No spacing, pipes directly adjacent to content
86/// - **any**: Preserve existing formatting style
87///
88/// ### Max Width (auto-compact threshold)
89///
90/// Controls when tables automatically switch from aligned to compact formatting:
91///
92/// - **`max-width = 0`** (default): Smart inheritance from MD013
93/// - **`max-width = N`**: Explicit threshold, independent of MD013
94///
95/// When `max-width = 0`:
96/// - If MD013 is disabled β†’ unlimited (no auto-compact)
97/// - If MD013.tables = false β†’ unlimited (no auto-compact)
98/// - If MD013.line_length = 0 β†’ unlimited (no auto-compact)
99/// - Otherwise β†’ inherits MD013's line-length
100///
101/// This matches the behavior of Prettier's table formatting.
102///
103/// #### Examples
104///
105/// ```toml
106/// # Inherit from MD013 (recommended)
107/// [MD013]
108/// line-length = 100
109///
110/// [MD060]
111/// style = "aligned"
112/// max-width = 0  # Tables exceeding 100 chars will be compacted
113/// ```
114///
115/// ```toml
116/// # Explicit threshold
117/// [MD060]
118/// style = "aligned"
119/// max-width = 120  # Independent of MD013
120/// ```
121///
122/// ## Examples
123///
124/// ### Aligned Style (Good)
125///
126/// ```markdown
127/// | Name  | Age | City      |
128/// |-------|-----|-----------|
129/// | Alice | 30  | Seattle   |
130/// | Bob   | 25  | Portland  |
131/// ```
132///
133/// ### Unaligned (Bad)
134///
135/// ```markdown
136/// | Name | Age | City |
137/// |---|---|---|
138/// | Alice | 30 | Seattle |
139/// | Bob | 25 | Portland |
140/// ```
141///
142/// ## Unicode Support
143///
144/// This rule properly handles:
145/// - **CJK Characters**: Chinese, Japanese, Korean characters are correctly measured as double-width
146/// - **Basic Emoji**: Most emoji are handled correctly
147/// - **Inline Code**: Pipes in inline code blocks are properly masked
148///
149/// ## Known Limitations
150///
151/// **Complex Unicode Sequences**: Tables containing certain Unicode characters are automatically
152/// skipped to prevent alignment corruption. These include:
153/// - Zero-Width Joiner (ZWJ) emoji: πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦, πŸ‘©β€πŸ’»
154/// - Zero-Width Space (ZWS): Invisible word break opportunities
155/// - Zero-Width Non-Joiner (ZWNJ): Ligature prevention marks
156/// - Word Joiner (WJ): Non-breaking invisible characters
157///
158/// These characters have inconsistent or zero display widths across terminals and fonts,
159/// making accurate alignment impossible. The rule preserves these tables as-is rather than
160/// risk corrupting them.
161///
162/// This is an honest limitation of terminal display technology, similar to what other tools
163/// like markdownlint experience.
164///
165/// ## Fix Behavior
166///
167/// When applying automatic fixes, this rule:
168/// - Calculates proper display width for each column using Unicode width measurements
169/// - Pads cells with trailing spaces to align columns
170/// - Preserves cell content exactly (only spacing is modified)
171/// - Respects alignment indicators in delimiter rows (`:---`, `:---:`, `---:`)
172/// - Automatically switches to compact mode for tables exceeding max_width
173/// - Skips tables with ZWJ emoji to prevent corruption
174#[derive(Debug, Clone, Default)]
175pub struct MD060TableFormat {
176    config: MD060Config,
177    md013_config: MD013Config,
178    md013_disabled: bool,
179}
180
181impl MD060TableFormat {
182    pub fn new(enabled: bool, style: String) -> Self {
183        use crate::types::LineLength;
184        Self {
185            config: MD060Config {
186                enabled,
187                style,
188                max_width: LineLength::from_const(0),
189                column_align: ColumnAlign::Auto,
190                column_align_header: None,
191                column_align_body: None,
192                loose_last_column: false,
193                aligned_delimiter: false,
194            },
195            md013_config: MD013Config::default(),
196            md013_disabled: false,
197        }
198    }
199
200    pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
201        Self {
202            config,
203            md013_config,
204            md013_disabled,
205        }
206    }
207
208    /// Get the effective max width for table formatting.
209    ///
210    /// Priority order:
211    /// 1. Explicit `max_width > 0` always takes precedence
212    /// 2. When `max_width = 0` (inherit mode), check MD013 configuration:
213    ///    - If MD013 is globally disabled β†’ unlimited
214    ///    - If `MD013.tables = false` β†’ unlimited
215    ///    - If `MD013.line_length = 0` β†’ unlimited
216    ///    - Otherwise β†’ inherit MD013's line_length
217    fn effective_max_width(&self) -> usize {
218        // Explicit max_width always takes precedence
219        if !self.config.max_width.is_unlimited() {
220            return self.config.max_width.get();
221        }
222
223        // max_width = 0 means "inherit" - but inherit UNLIMITED if:
224        // 1. MD013 is globally disabled
225        // 2. MD013.tables = false (user doesn't care about table line length)
226        // 3. MD013.line_length = 0 (no line length limit at all)
227        if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
228            return usize::MAX; // Unlimited
229        }
230
231        // Otherwise inherit MD013's line-length
232        self.md013_config.line_length.get()
233    }
234
235    /// Check if text contains characters that break Unicode width calculations
236    ///
237    /// Tables with these characters are skipped to avoid alignment corruption:
238    /// - Zero-Width Joiner (ZWJ, U+200D): Complex emoji like πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
239    /// - Zero-Width Space (ZWS, U+200B): Invisible word break opportunity
240    /// - Zero-Width Non-Joiner (ZWNJ, U+200C): Prevents ligature formation
241    /// - Word Joiner (WJ, U+2060): Prevents line breaks without taking space
242    ///
243    /// These characters have inconsistent display widths across terminals,
244    /// making accurate alignment impossible.
245    fn contains_problematic_chars(text: &str) -> bool {
246        text.contains('\u{200D}')  // ZWJ
247            || text.contains('\u{200B}')  // ZWS
248            || text.contains('\u{200C}')  // ZWNJ
249            || text.contains('\u{2060}') // Word Joiner
250    }
251
252    fn calculate_cell_display_width(cell_content: &str) -> usize {
253        let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
254        masked.trim().width()
255    }
256
257    /// Parse a table row into cells using Standard flavor (default behavior).
258    /// Used for tests and backward compatibility.
259    #[cfg(test)]
260    fn parse_table_row(line: &str) -> Vec<String> {
261        TableUtils::split_table_row(line)
262    }
263
264    /// Parse a table row into cells, respecting flavor-specific behavior.
265    ///
266    /// Pipes inside code spans are treated as content, not cell delimiters.
267    fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
268        TableUtils::split_table_row_with_flavor(line, flavor)
269    }
270
271    fn is_delimiter_row(row: &[String]) -> bool {
272        if row.is_empty() {
273            return false;
274        }
275        row.iter().all(|cell| {
276            let trimmed = cell.trim();
277            // A delimiter cell must contain at least one dash
278            // Empty cells are not delimiter cells
279            !trimmed.is_empty()
280                && trimmed.contains('-')
281                && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
282        })
283    }
284
285    /// Extract blockquote prefix from a line (e.g., "> " or ">> ").
286    /// Returns (prefix, content_without_prefix).
287    fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
288        if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
289            (&line[..m.end()], &line[m.end()..])
290        } else {
291            ("", line)
292        }
293    }
294
295    fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
296        delimiter_row
297            .iter()
298            .map(|cell| {
299                let trimmed = cell.trim();
300                let has_left_colon = trimmed.starts_with(':');
301                let has_right_colon = trimmed.ends_with(':');
302
303                match (has_left_colon, has_right_colon) {
304                    (true, true) => ColumnAlignment::Center,
305                    (false, true) => ColumnAlignment::Right,
306                    _ => ColumnAlignment::Left,
307                }
308            })
309            .collect()
310    }
311
312    fn calculate_column_widths(
313        table_lines: &[&str],
314        flavor: crate::config::MarkdownFlavor,
315        loose_last_column: bool,
316    ) -> Vec<usize> {
317        let mut column_widths = Vec::new();
318        let mut delimiter_cells: Option<Vec<String>> = None;
319        let mut is_header = true;
320        let mut header_last_col_width: Option<usize> = None;
321
322        for line in table_lines {
323            let cells = Self::parse_table_row_with_flavor(line, flavor);
324
325            // Save delimiter row for later processing, but don't use it for width calculation
326            if Self::is_delimiter_row(&cells) {
327                delimiter_cells = Some(cells);
328                is_header = false;
329                continue;
330            }
331
332            for (i, cell) in cells.iter().enumerate() {
333                let width = Self::calculate_cell_display_width(cell);
334                if i >= column_widths.len() {
335                    column_widths.push(width);
336                } else {
337                    column_widths[i] = column_widths[i].max(width);
338                }
339            }
340
341            // Record the header row's last column width
342            if is_header && !cells.is_empty() {
343                let last_idx = cells.len() - 1;
344                header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
345                is_header = false;
346            }
347        }
348
349        // When loose, cap the last column width at the header's width
350        if loose_last_column
351            && let Some(header_width) = header_last_col_width
352            && let Some(last) = column_widths.last_mut()
353        {
354            *last = header_width;
355        }
356
357        // GFM requires delimiter rows to have at least 3 dashes per column.
358        // To ensure visual alignment, all columns must be at least width 3.
359        let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
360
361        // Adjust column widths to accommodate alignment indicators (colons) in delimiter row
362        // This ensures the delimiter row has the same length as content rows
363        if let Some(delimiter_cells) = delimiter_cells {
364            for (i, cell) in delimiter_cells.iter().enumerate() {
365                if i < final_widths.len() {
366                    let trimmed = cell.trim();
367                    let has_left_colon = trimmed.starts_with(':');
368                    let has_right_colon = trimmed.ends_with(':');
369                    let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
370
371                    // Minimum width needed: 3 dashes + colons
372                    let min_width_for_delimiter = 3 + colon_count;
373                    final_widths[i] = final_widths[i].max(min_width_for_delimiter);
374                }
375            }
376        }
377
378        final_widths
379    }
380
381    fn format_table_row(
382        cells: &[String],
383        column_widths: &[usize],
384        column_alignments: &[ColumnAlignment],
385        options: &RowFormatOptions,
386    ) -> String {
387        let formatted_cells: Vec<String> = cells
388            .iter()
389            .enumerate()
390            .map(|(i, cell)| {
391                let target_width = column_widths.get(i).copied().unwrap_or(0);
392
393                match options.row_type {
394                    RowType::Delimiter => {
395                        let trimmed = cell.trim();
396                        let has_left_colon = trimmed.starts_with(':');
397                        let has_right_colon = trimmed.ends_with(':');
398
399                        // Delimiter rows use the same cell format as content rows: | content |
400                        // The "content" is dashes, possibly with colons for alignment
401                        // For compact_delimiter mode, we don't add spaces, so we need 2 extra dashes
402                        let extra_width = if options.compact_delimiter { 2 } else { 0 };
403                        let dash_count = if has_left_colon && has_right_colon {
404                            (target_width + extra_width).saturating_sub(2)
405                        } else if has_left_colon || has_right_colon {
406                            (target_width + extra_width).saturating_sub(1)
407                        } else {
408                            target_width + extra_width
409                        };
410
411                        let dashes = "-".repeat(dash_count.max(3)); // Minimum 3 dashes
412                        let delimiter_content = if has_left_colon && has_right_colon {
413                            format!(":{dashes}:")
414                        } else if has_left_colon {
415                            format!(":{dashes}")
416                        } else if has_right_colon {
417                            format!("{dashes}:")
418                        } else {
419                            dashes
420                        };
421
422                        // Add spaces around delimiter content unless compact_delimiter mode
423                        if options.compact_delimiter {
424                            delimiter_content
425                        } else {
426                            format!(" {delimiter_content} ")
427                        }
428                    }
429                    RowType::Header | RowType::Body => {
430                        let trimmed = cell.trim();
431                        let current_width = Self::calculate_cell_display_width(cell);
432                        let padding = target_width.saturating_sub(current_width);
433
434                        // Determine which alignment to use based on row type
435                        let effective_align = match options.row_type {
436                            RowType::Header => options.column_align_header.unwrap_or(options.column_align),
437                            RowType::Body => options.column_align_body.unwrap_or(options.column_align),
438                            RowType::Delimiter => unreachable!(),
439                        };
440
441                        // Apply alignment: use override if specified, otherwise use delimiter indicators
442                        let alignment = match effective_align {
443                            ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
444                            ColumnAlign::Left => ColumnAlignment::Left,
445                            ColumnAlign::Center => ColumnAlignment::Center,
446                            ColumnAlign::Right => ColumnAlignment::Right,
447                        };
448
449                        match alignment {
450                            ColumnAlignment::Left => {
451                                // Left: content on left, padding on right
452                                format!(" {trimmed}{} ", " ".repeat(padding))
453                            }
454                            ColumnAlignment::Center => {
455                                // Center: split padding on both sides
456                                let left_padding = padding / 2;
457                                let right_padding = padding - left_padding;
458                                format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
459                            }
460                            ColumnAlignment::Right => {
461                                // Right: padding on left, content on right
462                                format!(" {}{trimmed} ", " ".repeat(padding))
463                            }
464                        }
465                    }
466                }
467            })
468            .collect();
469
470        format!("|{}|", formatted_cells.join("|"))
471    }
472
473    fn format_table_compact(cells: &[String]) -> String {
474        // An empty compact cell is a single space between pipes (`| |`),
475        // matching mdformat's canonical form. This keeps rumdl's output stable
476        // when both tools format the same file.
477        let formatted_cells: Vec<String> = cells
478            .iter()
479            .map(|cell| match cell.trim() {
480                "" => " ".to_string(),
481                trimmed => format!(" {trimmed} "),
482            })
483            .collect();
484        format!("|{}|", formatted_cells.join("|"))
485    }
486
487    fn format_table_tight(cells: &[String]) -> String {
488        let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
489        format!("|{}|", formatted_cells.join("|"))
490    }
491
492    /// Format a delimiter row whose pipe positions align with the header pipe
493    /// positions, used by `compact` / `tight` styles when `aligned_delimiter`
494    /// is enabled. Body rows remain unchanged (compact or tight).
495    ///
496    /// `header_widths` is the display width of each header cell's trimmed
497    /// content. Each delimiter cell receives that many dashes (minus one for
498    /// each colon present), preserving `:---`, `---:`, `:---:` markers.
499    /// `compact` controls whether to surround the dashes with single spaces.
500    fn format_delimiter_aligned_to_header(delim_cells: &[String], header_widths: &[usize], compact: bool) -> String {
501        let formatted_cells: Vec<String> = delim_cells
502            .iter()
503            .enumerate()
504            .map(|(i, cell)| {
505                let target_width = header_widths.get(i).copied().unwrap_or(0);
506                let trimmed = cell.trim();
507                let has_left_colon = trimmed.starts_with(':');
508                let has_right_colon = trimmed.ends_with(':');
509                let colon_count = usize::from(has_left_colon) + usize::from(has_right_colon);
510
511                // GFM minimum: at least one dash per delimiter cell.
512                let dash_count = target_width.saturating_sub(colon_count).max(1);
513                let dashes = "-".repeat(dash_count);
514                let delimiter_content = match (has_left_colon, has_right_colon) {
515                    (true, true) => format!(":{dashes}:"),
516                    (true, false) => format!(":{dashes}"),
517                    (false, true) => format!("{dashes}:"),
518                    (false, false) => dashes,
519                };
520                if compact {
521                    format!(" {delimiter_content} ")
522                } else {
523                    delimiter_content
524                }
525            })
526            .collect();
527
528        format!("|{}|", formatted_cells.join("|"))
529    }
530
531    /// Returns display widths of each header cell's trimmed content.
532    /// Used when `aligned_delimiter` is on to size the delimiter row.
533    fn header_cell_widths(header_cells: &[String]) -> Vec<usize> {
534        header_cells
535            .iter()
536            .map(|c| Self::calculate_cell_display_width(c))
537            .collect()
538    }
539
540    /// Checks if a table is already aligned with consistent column widths
541    /// and the delimiter row style matches the target style.
542    ///
543    /// A table is considered "already aligned" if:
544    /// 1. All rows have the same display length
545    /// 2. Each column has consistent cell width across all rows
546    /// 3. The delimiter row has valid minimum widths (at least 3 chars per cell)
547    /// 4. The delimiter row style matches the target style (compact_delimiter parameter)
548    ///
549    /// The `compact_delimiter` parameter indicates whether the target style is "aligned-no-space"
550    /// (true = no spaces around dashes, false = spaces around dashes).
551    fn is_table_already_aligned(
552        table_lines: &[&str],
553        flavor: crate::config::MarkdownFlavor,
554        compact_delimiter: bool,
555    ) -> bool {
556        if table_lines.len() < 2 {
557            return false;
558        }
559
560        // Check 1: All rows must have the same display width
561        // Use .width() instead of .len() to handle CJK characters correctly
562        // (CJK chars are 3 bytes but 2 display columns)
563        let first_width = UnicodeWidthStr::width(table_lines[0]);
564        if !table_lines
565            .iter()
566            .all(|line| UnicodeWidthStr::width(*line) == first_width)
567        {
568            return false;
569        }
570
571        // Parse all rows and check column count consistency
572        let parsed: Vec<Vec<String>> = table_lines
573            .iter()
574            .map(|line| Self::parse_table_row_with_flavor(line, flavor))
575            .collect();
576
577        if parsed.is_empty() {
578            return false;
579        }
580
581        let num_columns = parsed[0].len();
582        if !parsed.iter().all(|row| row.len() == num_columns) {
583            return false;
584        }
585
586        // Check delimiter row has valid minimum widths (3 chars: at least one dash + optional colons)
587        // Delimiter row is always at index 1
588        if let Some(delimiter_row) = parsed.get(1) {
589            if !Self::is_delimiter_row(delimiter_row) {
590                return false;
591            }
592            // Check each delimiter cell has at least one dash (minimum valid is "---" or ":--" etc)
593            for cell in delimiter_row {
594                let trimmed = cell.trim();
595                let dash_count = trimmed.chars().filter(|&c| c == '-').count();
596                if dash_count < 1 {
597                    return false;
598                }
599            }
600
601            // Check if delimiter row style matches the target style
602            // compact_delimiter=true means "aligned-no-space" (no spaces around dashes)
603            // compact_delimiter=false means "aligned" (spaces around dashes)
604            let delimiter_has_spaces = delimiter_row
605                .iter()
606                .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
607
608            // If target is compact (no spaces) but current has spaces, not aligned
609            // If target is spaced but current has no spaces, not aligned
610            if compact_delimiter && delimiter_has_spaces {
611                return false;
612            }
613            if !compact_delimiter && !delimiter_has_spaces {
614                return false;
615            }
616        }
617
618        // Check each column has consistent width across all content rows
619        // Use cell.width() to get display width INCLUDING padding, not trimmed content
620        // This correctly handles CJK characters (display width 2, byte length 3)
621        for col_idx in 0..num_columns {
622            let mut widths = Vec::new();
623            for (row_idx, row) in parsed.iter().enumerate() {
624                // Skip delimiter row for content width check
625                if row_idx == 1 {
626                    continue;
627                }
628                if let Some(cell) = row.get(col_idx) {
629                    widths.push(cell.width());
630                }
631            }
632            // All content cells in this column should have the same display width
633            if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
634                return false;
635            }
636        }
637
638        // Check 5: Content padding distribution matches column alignment
639        // For center-aligned columns, content must be centered (left/right padding differ by at most 1)
640        // For right-aligned columns, content must be right-aligned (left padding >= right padding)
641        // Padding is counted in space characters (always 1 byte each), so byte-length arithmetic is safe.
642        if let Some(delimiter_row) = parsed.get(1) {
643            let alignments = Self::parse_column_alignments(delimiter_row);
644            for (col_idx, alignment) in alignments.iter().enumerate() {
645                if *alignment == ColumnAlignment::Left {
646                    continue;
647                }
648                for (row_idx, row) in parsed.iter().enumerate() {
649                    // Skip delimiter row
650                    if row_idx == 1 {
651                        continue;
652                    }
653                    if let Some(cell) = row.get(col_idx) {
654                        if cell.trim().is_empty() {
655                            continue;
656                        }
657                        // Count leading/trailing space characters (always ASCII, so byte length = char count)
658                        let left_pad = cell.len() - cell.trim_start().len();
659                        let right_pad = cell.len() - cell.trim_end().len();
660
661                        match alignment {
662                            ColumnAlignment::Center => {
663                                // Center: left and right padding must differ by at most 1
664                                if left_pad.abs_diff(right_pad) > 1 {
665                                    return false;
666                                }
667                            }
668                            ColumnAlignment::Right => {
669                                // Right: content pushed right means more padding on the left
670                                if left_pad < right_pad {
671                                    return false;
672                                }
673                            }
674                            ColumnAlignment::Left => unreachable!(),
675                        }
676                    }
677                }
678            }
679        }
680
681        true
682    }
683
684    fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
685        if table_lines.is_empty() {
686            return None;
687        }
688
689        // Check all rows (except delimiter) to determine consistent style
690        // A table is only "tight" or "compact" if ALL rows follow that pattern
691        let mut is_tight = true;
692        let mut is_compact = true;
693
694        for line in table_lines {
695            let cells = Self::parse_table_row_with_flavor(line, flavor);
696
697            if cells.is_empty() {
698                continue;
699            }
700
701            // Skip delimiter rows when detecting style
702            if Self::is_delimiter_row(&cells) {
703                continue;
704            }
705
706            // Check if this row has no padding
707            let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
708
709            // Compact rows pad every cell with one space on each side. An
710            // empty compact cell is the special case `" "` (single space
711            // between pipes), matching mdformat's canonical empty cell.
712            let row_has_single_space = cells.iter().all(|cell| match cell.trim() {
713                "" => cell == " ",
714                trimmed => cell == &format!(" {trimmed} "),
715            });
716
717            // If any row doesn't match tight, the table isn't tight
718            if !row_has_no_padding {
719                is_tight = false;
720            }
721
722            // If any row doesn't match compact, the table isn't compact
723            if !row_has_single_space {
724                is_compact = false;
725            }
726
727            // Early exit: if neither tight nor compact, it must be aligned
728            if !is_tight && !is_compact {
729                return Some("aligned".to_string());
730            }
731        }
732
733        // Return the most restrictive style that matches
734        if is_tight {
735            Some("tight".to_string())
736        } else if is_compact {
737            Some("compact".to_string())
738        } else {
739            Some("aligned".to_string())
740        }
741    }
742
743    fn fix_table_block(
744        &self,
745        lines: &[&str],
746        table_block: &crate::utils::table_utils::TableBlock,
747        flavor: crate::config::MarkdownFlavor,
748    ) -> TableFormatResult {
749        let mut result = Vec::new();
750        let mut auto_compacted = false;
751        let mut aligned_width = None;
752
753        let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
754            .chain(std::iter::once(lines[table_block.delimiter_line]))
755            .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
756            .collect();
757
758        if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
759            return TableFormatResult {
760                lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
761                auto_compacted: false,
762                aligned_width: None,
763            };
764        }
765
766        // Extract blockquote prefix from the header line (first line of table)
767        // All lines in the same table should have the same blockquote level
768        let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
769
770        // Extract list prefix if present (for tables inside list items)
771        let list_context = &table_block.list_context;
772        let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
773            (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
774        } else {
775            ("", String::new())
776        };
777
778        // Strip blockquote prefix and list prefix from all lines for processing
779        let stripped_lines: Vec<&str> = table_lines
780            .iter()
781            .enumerate()
782            .map(|(i, line)| {
783                let after_blockquote = Self::extract_blockquote_prefix(line).1;
784                if list_context.is_some() {
785                    if i == 0 {
786                        // Header line: strip list prefix (handles both markers and indentation)
787                        after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
788                            crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
789                        })
790                    } else {
791                        // Continuation lines: strip expected indentation
792                        after_blockquote
793                            .strip_prefix(&continuation_indent)
794                            .unwrap_or(after_blockquote.trim_start())
795                    }
796                } else {
797                    after_blockquote
798                }
799            })
800            .collect();
801
802        let style = self.config.style.as_str();
803
804        match style {
805            "any" => {
806                let detected_style = Self::detect_table_style(&stripped_lines, flavor);
807                if detected_style.is_none() {
808                    return TableFormatResult {
809                        lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
810                        auto_compacted: false,
811                        aligned_width: None,
812                    };
813                }
814
815                let target_style = detected_style.unwrap();
816
817                // Parse column alignments from delimiter row (always at index 1)
818                let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
819                let column_alignments = Self::parse_column_alignments(&delimiter_cells);
820
821                for (row_idx, line) in stripped_lines.iter().enumerate() {
822                    let cells = Self::parse_table_row_with_flavor(line, flavor);
823                    match target_style.as_str() {
824                        "tight" => result.push(Self::format_table_tight(&cells)),
825                        "compact" => result.push(Self::format_table_compact(&cells)),
826                        _ => {
827                            let column_widths =
828                                Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
829                            let row_type = match row_idx {
830                                0 => RowType::Header,
831                                1 => RowType::Delimiter,
832                                _ => RowType::Body,
833                            };
834                            let options = RowFormatOptions {
835                                row_type,
836                                compact_delimiter: false,
837                                column_align: self.config.column_align,
838                                column_align_header: self.config.column_align_header,
839                                column_align_body: self.config.column_align_body,
840                            };
841                            result.push(Self::format_table_row(
842                                &cells,
843                                &column_widths,
844                                &column_alignments,
845                                &options,
846                            ));
847                        }
848                    }
849                }
850            }
851            "compact" | "tight" => {
852                let compact = style == "compact";
853                let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
854                    let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
855                    Some(Self::header_cell_widths(&header_cells))
856                } else {
857                    None
858                };
859
860                for (row_idx, line) in stripped_lines.iter().enumerate() {
861                    let cells = Self::parse_table_row_with_flavor(line, flavor);
862                    if row_idx == 1
863                        && let Some(widths) = &header_widths
864                    {
865                        result.push(Self::format_delimiter_aligned_to_header(&cells, widths, compact));
866                        continue;
867                    }
868                    result.push(if compact {
869                        Self::format_table_compact(&cells)
870                    } else {
871                        Self::format_table_tight(&cells)
872                    });
873                }
874            }
875            "aligned" | "aligned-no-space" => {
876                let compact_delimiter = style == "aligned-no-space";
877
878                // Determine if we need to reformat: skip if table is already aligned
879                // UNLESS any alignment or formatting options require reformatting
880                let needs_reformat = self.config.column_align != ColumnAlign::Auto
881                    || self.config.column_align_header.is_some()
882                    || self.config.column_align_body.is_some()
883                    || self.config.loose_last_column;
884
885                if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
886                    return TableFormatResult {
887                        lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
888                        auto_compacted: false,
889                        aligned_width: None,
890                    };
891                }
892
893                let column_widths =
894                    Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
895
896                // Calculate aligned table width: 1 (leading pipe) + num_columns * 3 (| cell |) + sum(column_widths)
897                let num_columns = column_widths.len();
898                let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
899                aligned_width = Some(calc_aligned_width);
900
901                // Auto-compact: if aligned table exceeds max width, use compact formatting instead.
902                // The effective output style is now `compact`, so honor `aligned-delimiter`
903                // exactly as the explicit `compact` style does: align the delimiter row's pipes
904                // to the header column widths while body rows stay compact.
905                if calc_aligned_width > self.effective_max_width() {
906                    auto_compacted = true;
907                    let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
908                        let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
909                        Some(Self::header_cell_widths(&header_cells))
910                    } else {
911                        None
912                    };
913                    for (row_idx, line) in stripped_lines.iter().enumerate() {
914                        let cells = Self::parse_table_row_with_flavor(line, flavor);
915                        if row_idx == 1
916                            && let Some(widths) = &header_widths
917                        {
918                            // Auto-compact always produces the single-space compact form.
919                            result.push(Self::format_delimiter_aligned_to_header(&cells, widths, true));
920                            continue;
921                        }
922                        result.push(Self::format_table_compact(&cells));
923                    }
924                } else {
925                    // Parse column alignments from delimiter row (always at index 1)
926                    let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
927                    let column_alignments = Self::parse_column_alignments(&delimiter_cells);
928
929                    for (row_idx, line) in stripped_lines.iter().enumerate() {
930                        let cells = Self::parse_table_row_with_flavor(line, flavor);
931                        let row_type = match row_idx {
932                            0 => RowType::Header,
933                            1 => RowType::Delimiter,
934                            _ => RowType::Body,
935                        };
936                        let options = RowFormatOptions {
937                            row_type,
938                            compact_delimiter,
939                            column_align: self.config.column_align,
940                            column_align_header: self.config.column_align_header,
941                            column_align_body: self.config.column_align_body,
942                        };
943                        result.push(Self::format_table_row(
944                            &cells,
945                            &column_widths,
946                            &column_alignments,
947                            &options,
948                        ));
949                    }
950                }
951            }
952            _ => {
953                return TableFormatResult {
954                    lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
955                    auto_compacted: false,
956                    aligned_width: None,
957                };
958            }
959        }
960
961        // Re-add blockquote prefix and list prefix to all formatted lines
962        let prefixed_result: Vec<String> = result
963            .into_iter()
964            .enumerate()
965            .map(|(i, line)| {
966                if list_context.is_some() {
967                    if i == 0 {
968                        // Header line: add list prefix
969                        format!("{blockquote_prefix}{list_prefix}{line}")
970                    } else {
971                        // Continuation lines: add indentation
972                        format!("{blockquote_prefix}{continuation_indent}{line}")
973                    }
974                } else {
975                    format!("{blockquote_prefix}{line}")
976                }
977            })
978            .collect();
979
980        TableFormatResult {
981            lines: prefixed_result,
982            auto_compacted,
983            aligned_width,
984        }
985    }
986}
987
988impl Rule for MD060TableFormat {
989    fn name(&self) -> &'static str {
990        "MD060"
991    }
992
993    fn description(&self) -> &'static str {
994        "Table columns should be consistently aligned"
995    }
996
997    fn category(&self) -> RuleCategory {
998        RuleCategory::Table
999    }
1000
1001    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1002        !ctx.likely_has_tables()
1003    }
1004
1005    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1006        let line_index = &ctx.line_index;
1007        let mut warnings = Vec::new();
1008
1009        let lines = ctx.raw_lines();
1010        let table_blocks = &ctx.table_blocks;
1011
1012        for table_block in table_blocks {
1013            let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1014
1015            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1016                .chain(std::iter::once(table_block.delimiter_line))
1017                .chain(table_block.content_lines.iter().copied())
1018                .collect();
1019
1020            // Build the whole-table fix once for all warnings in this table
1021            // This ensures that applying Quick Fix on any row fixes the entire table
1022            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
1023            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
1024
1025            // Build the complete fixed table content
1026            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(table_line_indices.len());
1027            for (i, &line_idx) in table_line_indices.iter().enumerate() {
1028                let fixed_line = &format_result.lines[i];
1029                // Add newline for all lines except the last if the original didn't have one
1030                if line_idx < lines.len() - 1 {
1031                    fixed_table_lines.push(format!("{fixed_line}\n"));
1032                } else {
1033                    fixed_table_lines.push(fixed_line.clone());
1034                }
1035            }
1036            let table_replacement = fixed_table_lines.concat();
1037            let table_range = line_index.multi_line_range(table_start_line, table_end_line);
1038
1039            for (i, &line_idx) in table_line_indices.iter().enumerate() {
1040                let original = lines[line_idx];
1041                let fixed = &format_result.lines[i];
1042
1043                if original != fixed {
1044                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1045
1046                    let message = if format_result.auto_compacted {
1047                        if let Some(width) = format_result.aligned_width {
1048                            format!(
1049                                "Table too wide for aligned formatting ({} chars > max-width: {})",
1050                                width,
1051                                self.effective_max_width()
1052                            )
1053                        } else {
1054                            "Table too wide for aligned formatting".to_string()
1055                        }
1056                    } else {
1057                        "Table columns should be aligned".to_string()
1058                    };
1059
1060                    // Each warning uses the same whole-table fix
1061                    // This ensures Quick Fix on any row aligns the entire table
1062                    warnings.push(LintWarning {
1063                        rule_name: Some(self.name().to_string()),
1064                        severity: Severity::Warning,
1065                        message,
1066                        line: start_line,
1067                        column: start_col,
1068                        end_line,
1069                        end_column: end_col,
1070                        fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1071                    });
1072                }
1073            }
1074        }
1075
1076        Ok(warnings)
1077    }
1078
1079    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1080        let content = ctx.content;
1081        let lines = ctx.raw_lines();
1082        let table_blocks = &ctx.table_blocks;
1083
1084        // Nothing to format when there are no tables; return the content verbatim
1085        // so non-table documents are never altered.
1086        if table_blocks.is_empty() {
1087            return Ok(content.to_string());
1088        }
1089
1090        let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1091
1092        for table_block in table_blocks {
1093            let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1094
1095            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1096                .chain(std::iter::once(table_block.delimiter_line))
1097                .chain(table_block.content_lines.iter().copied())
1098                .collect();
1099
1100            // Check if any line in this table has the rule disabled via inline config;
1101            // if so, skip fixing the entire table to avoid partial formatting
1102            let any_disabled = table_line_indices
1103                .iter()
1104                .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1105
1106            if any_disabled {
1107                continue;
1108            }
1109
1110            for (i, &line_idx) in table_line_indices.iter().enumerate() {
1111                result_lines[line_idx].clone_from(&format_result.lines[i]);
1112            }
1113        }
1114
1115        let mut fixed = result_lines.join("\n");
1116        // `raw_lines()` drops the trailing empty line, so `join("\n")` collapses a
1117        // run of trailing blank lines down to a single newline. Restore the
1118        // original trailing-newline run exactly so trailing blank lines are
1119        // preserved and the fix is idempotent.
1120        let original_trailing_newlines = content.len() - content.trim_end_matches('\n').len();
1121        fixed.truncate(fixed.trim_end_matches('\n').len());
1122        fixed.push_str(&"\n".repeat(original_trailing_newlines));
1123        Ok(fixed)
1124    }
1125
1126    fn as_any(&self) -> &dyn std::any::Any {
1127        self
1128    }
1129
1130    crate::impl_rule_config_sections!(MD060Config);
1131
1132    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1133    where
1134        Self: Sized,
1135    {
1136        let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1137        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1138
1139        // Check if MD013 is globally disabled
1140        let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1141
1142        Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149    use crate::lint_context::LintContext;
1150    use crate::types::LineLength;
1151
1152    /// Helper to create an MD013Config with a specific line length for testing
1153    fn md013_with_line_length(line_length: usize) -> MD013Config {
1154        MD013Config {
1155            line_length: LineLength::from_const(line_length),
1156            tables: true, // Default: tables are checked
1157            ..Default::default()
1158        }
1159    }
1160
1161    #[test]
1162    fn test_md060_align_simple_ascii_table() {
1163        let rule = MD060TableFormat::new(true, "aligned".to_string());
1164
1165        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167
1168        let fixed = rule.fix(&ctx).unwrap();
1169        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1170        assert_eq!(fixed, expected);
1171
1172        // Verify all rows have equal length in aligned mode
1173        let lines: Vec<&str> = fixed.lines().collect();
1174        assert_eq!(lines[0].len(), lines[1].len());
1175        assert_eq!(lines[1].len(), lines[2].len());
1176    }
1177
1178    #[test]
1179    fn test_md060_cjk_characters_aligned_correctly() {
1180        let rule = MD060TableFormat::new(true, "aligned".to_string());
1181
1182        let content = "| Name | Age |\n|---|---|\n| δΈ­ζ–‡ | 30 |";
1183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184
1185        let fixed = rule.fix(&ctx).unwrap();
1186
1187        let lines: Vec<&str> = fixed.lines().collect();
1188        let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1189        let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1190
1191        let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1192        let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1193
1194        assert_eq!(width1, width3);
1195    }
1196
1197    #[test]
1198    fn test_md060_basic_emoji() {
1199        let rule = MD060TableFormat::new(true, "aligned".to_string());
1200
1201        let content = "| Status | Name |\n|---|---|\n| βœ… | Test |";
1202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203
1204        let fixed = rule.fix(&ctx).unwrap();
1205        assert!(fixed.contains("Status"));
1206    }
1207
1208    #[test]
1209    fn test_md060_zwj_emoji_skipped() {
1210        let rule = MD060TableFormat::new(true, "aligned".to_string());
1211
1212        let content = "| Emoji | Name |\n|---|---|\n| πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ | Family |";
1213        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214
1215        let fixed = rule.fix(&ctx).unwrap();
1216        assert_eq!(fixed, content);
1217    }
1218
1219    #[test]
1220    fn test_md060_inline_code_with_escaped_pipes() {
1221        // Pipes inside code spans are treated as content, not cell delimiters.
1222        // Escaped pipes (\|) are also supported outside code spans.
1223        let rule = MD060TableFormat::new(true, "aligned".to_string());
1224
1225        // CORRECT: `[0-9]\|[0-9]` - the \| is escaped, stays as content (2 columns)
1226        let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228
1229        let fixed = rule.fix(&ctx).unwrap();
1230        assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1231    }
1232
1233    #[test]
1234    fn test_md060_compact_style() {
1235        let rule = MD060TableFormat::new(true, "compact".to_string());
1236
1237        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239
1240        let fixed = rule.fix(&ctx).unwrap();
1241        let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1242        assert_eq!(fixed, expected);
1243    }
1244
1245    #[test]
1246    fn test_md060_tight_style() {
1247        let rule = MD060TableFormat::new(true, "tight".to_string());
1248
1249        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251
1252        let fixed = rule.fix(&ctx).unwrap();
1253        let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1254        assert_eq!(fixed, expected);
1255    }
1256
1257    #[test]
1258    fn test_md060_aligned_no_space_style() {
1259        // Issue #277: aligned-no-space style has no spaces in delimiter row
1260        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1261
1262        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264
1265        let fixed = rule.fix(&ctx).unwrap();
1266
1267        // Content rows have spaces, delimiter row does not
1268        let lines: Vec<&str> = fixed.lines().collect();
1269        assert_eq!(lines[0], "| Name  | Age |", "Header should have spaces around content");
1270        assert_eq!(
1271            lines[1], "|-------|-----|",
1272            "Delimiter should have NO spaces around dashes"
1273        );
1274        assert_eq!(lines[2], "| Alice | 30  |", "Content should have spaces around content");
1275
1276        // All rows should have equal length
1277        assert_eq!(lines[0].len(), lines[1].len());
1278        assert_eq!(lines[1].len(), lines[2].len());
1279    }
1280
1281    #[test]
1282    fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1283        // Alignment indicators (:) should be preserved
1284        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1285
1286        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1287        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288
1289        let fixed = rule.fix(&ctx).unwrap();
1290        let lines: Vec<&str> = fixed.lines().collect();
1291
1292        // Verify alignment indicators are preserved without spaces around them
1293        assert!(
1294            fixed.contains("|:"),
1295            "Should have left alignment indicator adjacent to pipe"
1296        );
1297        assert!(
1298            fixed.contains(":|"),
1299            "Should have right alignment indicator adjacent to pipe"
1300        );
1301        // Check for center alignment - the exact dash count depends on column width
1302        assert!(
1303            lines[1].contains(":---") && lines[1].contains("---:"),
1304            "Should have center alignment colons"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_md060_aligned_no_space_three_column_table() {
1310        // Test the exact format from issue #277
1311        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1312
1313        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 |";
1314        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315
1316        let fixed = rule.fix(&ctx).unwrap();
1317        let lines: Vec<&str> = fixed.lines().collect();
1318
1319        // Verify delimiter row format: |--------------|--------------|--------------|
1320        assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1321        assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1322        assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1323        assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1324    }
1325
1326    #[test]
1327    fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1328        // Auto-compact should work with aligned-no-space when table exceeds max-width
1329        let config = MD060Config {
1330            enabled: true,
1331            style: "aligned-no-space".to_string(),
1332            max_width: LineLength::from_const(50),
1333            column_align: ColumnAlign::Auto,
1334            column_align_header: None,
1335            column_align_body: None,
1336            loose_last_column: false,
1337            aligned_delimiter: false,
1338        };
1339        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1340
1341        // Wide table that exceeds 50 chars when aligned
1342        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344
1345        let fixed = rule.fix(&ctx).unwrap();
1346
1347        // Should auto-compact to compact style (not aligned-no-space)
1348        assert!(
1349            fixed.contains("| --- |"),
1350            "Should be compact format when exceeding max-width"
1351        );
1352    }
1353
1354    #[test]
1355    fn test_md060_aligned_no_space_cjk_characters() {
1356        // CJK characters should be handled correctly
1357        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1358
1359        let content = "| Name | City |\n|---|---|\n| δΈ­ζ–‡ | 東京 |";
1360        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361
1362        let fixed = rule.fix(&ctx).unwrap();
1363        let lines: Vec<&str> = fixed.lines().collect();
1364
1365        // All rows should have equal DISPLAY width (not byte length)
1366        // CJK characters are double-width, so byte length differs from display width
1367        use unicode_width::UnicodeWidthStr;
1368        assert_eq!(
1369            lines[0].width(),
1370            lines[1].width(),
1371            "Header and delimiter should have same display width"
1372        );
1373        assert_eq!(
1374            lines[1].width(),
1375            lines[2].width(),
1376            "Delimiter and content should have same display width"
1377        );
1378
1379        // Delimiter should have no spaces
1380        assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1381    }
1382
1383    #[test]
1384    fn test_md060_aligned_no_space_minimum_width() {
1385        // Minimum column width (3 dashes) should be respected
1386        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1387
1388        let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390
1391        let fixed = rule.fix(&ctx).unwrap();
1392        let lines: Vec<&str> = fixed.lines().collect();
1393
1394        // Should have at least 3 dashes per column (GFM requirement)
1395        assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1396        // All rows should have equal length
1397        assert_eq!(lines[0].len(), lines[1].len());
1398        assert_eq!(lines[1].len(), lines[2].len());
1399    }
1400
1401    #[test]
1402    fn test_md060_any_style_consistency() {
1403        let rule = MD060TableFormat::new(true, "any".to_string());
1404
1405        // Table is already compact, should stay compact
1406        let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408
1409        let fixed = rule.fix(&ctx).unwrap();
1410        assert_eq!(fixed, content);
1411
1412        // Table is aligned, should stay aligned
1413        let content_aligned = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1414        let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1415
1416        let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1417        assert_eq!(fixed_aligned, content_aligned);
1418    }
1419
1420    #[test]
1421    fn test_md060_empty_cells() {
1422        let rule = MD060TableFormat::new(true, "aligned".to_string());
1423
1424        let content = "| A | B |\n|---|---|\n|  | X |";
1425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426
1427        let fixed = rule.fix(&ctx).unwrap();
1428        assert!(fixed.contains('|'));
1429    }
1430
1431    #[test]
1432    fn test_md060_mixed_content() {
1433        let rule = MD060TableFormat::new(true, "aligned".to_string());
1434
1435        let content = "| Name | Age | City |\n|---|---|---|\n| δΈ­ζ–‡ | 30 | NYC |";
1436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437
1438        let fixed = rule.fix(&ctx).unwrap();
1439        assert!(fixed.contains("δΈ­ζ–‡"));
1440        assert!(fixed.contains("NYC"));
1441    }
1442
1443    #[test]
1444    fn test_md060_preserve_alignment_indicators() {
1445        let rule = MD060TableFormat::new(true, "aligned".to_string());
1446
1447        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1449
1450        let fixed = rule.fix(&ctx).unwrap();
1451
1452        assert!(fixed.contains(":---"), "Should contain left alignment");
1453        assert!(fixed.contains(":----:"), "Should contain center alignment");
1454        assert!(fixed.contains("----:"), "Should contain right alignment");
1455    }
1456
1457    #[test]
1458    fn test_md060_minimum_column_width() {
1459        let rule = MD060TableFormat::new(true, "aligned".to_string());
1460
1461        // Test with very short column content to ensure minimum width of 3
1462        // GFM requires at least 3 dashes in delimiter rows
1463        let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465
1466        let fixed = rule.fix(&ctx).unwrap();
1467
1468        let lines: Vec<&str> = fixed.lines().collect();
1469        assert_eq!(lines[0].len(), lines[1].len());
1470        assert_eq!(lines[1].len(), lines[2].len());
1471
1472        // Verify minimum width is enforced
1473        assert!(fixed.contains("ID "), "Short content should be padded");
1474        assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1475    }
1476
1477    #[test]
1478    fn test_md060_auto_compact_exceeds_default_threshold() {
1479        // Default max_width = 0, which inherits from default MD013 line_length = 80
1480        let config = MD060Config {
1481            enabled: true,
1482            style: "aligned".to_string(),
1483            max_width: LineLength::from_const(0),
1484            column_align: ColumnAlign::Auto,
1485            column_align_header: None,
1486            column_align_body: None,
1487            loose_last_column: false,
1488            aligned_delimiter: false,
1489        };
1490        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1491
1492        // Table that would be 85 chars when aligned (exceeds 80)
1493        // Formula: 1 + (3 * 3) + (20 + 20 + 30) = 1 + 9 + 70 = 80 chars
1494        // But with actual content padding it will exceed
1495        let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497
1498        let fixed = rule.fix(&ctx).unwrap();
1499
1500        // Should use compact formatting (single spaces)
1501        assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1502        assert!(fixed.contains("| --- | --- | --- |"));
1503        assert!(fixed.contains("| Short | Data | Here |"));
1504
1505        // Verify it's compact (no extra padding)
1506        let lines: Vec<&str> = fixed.lines().collect();
1507        // In compact mode, lines can have different lengths
1508        assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1509    }
1510
1511    #[test]
1512    fn test_md060_auto_compact_exceeds_explicit_threshold() {
1513        // Explicit max_width = 50
1514        let config = MD060Config {
1515            enabled: true,
1516            style: "aligned".to_string(),
1517            max_width: LineLength::from_const(50),
1518            column_align: ColumnAlign::Auto,
1519            column_align_header: None,
1520            column_align_body: None,
1521            loose_last_column: false,
1522            aligned_delimiter: false,
1523        };
1524        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false); // MD013 setting doesn't matter
1525
1526        // Table that would exceed 50 chars when aligned
1527        // Column widths: 25 + 25 + 25 = 75 chars
1528        // Formula: 1 + (3 * 3) + 75 = 85 chars (exceeds 50)
1529        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531
1532        let fixed = rule.fix(&ctx).unwrap();
1533
1534        // Should use compact formatting (single spaces, no extra padding)
1535        assert!(
1536            fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1537        );
1538        assert!(fixed.contains("| --- | --- | --- |"));
1539        assert!(fixed.contains("| Data | Data | Data |"));
1540
1541        // Verify it's compact (lines have different lengths)
1542        let lines: Vec<&str> = fixed.lines().collect();
1543        assert!(lines[0].len() != lines[2].len());
1544    }
1545
1546    #[test]
1547    fn test_md060_stays_aligned_under_threshold() {
1548        // max_width = 100, table will be under this
1549        let config = MD060Config {
1550            enabled: true,
1551            style: "aligned".to_string(),
1552            max_width: LineLength::from_const(100),
1553            column_align: ColumnAlign::Auto,
1554            column_align_header: None,
1555            column_align_body: None,
1556            loose_last_column: false,
1557            aligned_delimiter: false,
1558        };
1559        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1560
1561        // Small table that fits well under 100 chars
1562        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564
1565        let fixed = rule.fix(&ctx).unwrap();
1566
1567        // Should use aligned formatting (all lines same length)
1568        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1569        assert_eq!(fixed, expected);
1570
1571        let lines: Vec<&str> = fixed.lines().collect();
1572        assert_eq!(lines[0].len(), lines[1].len());
1573        assert_eq!(lines[1].len(), lines[2].len());
1574    }
1575
1576    #[test]
1577    fn test_md060_width_calculation_formula() {
1578        // Verify the width calculation formula: 1 + (num_columns * 3) + sum(column_widths)
1579        let config = MD060Config {
1580            enabled: true,
1581            style: "aligned".to_string(),
1582            max_width: LineLength::from_const(0),
1583            column_align: ColumnAlign::Auto,
1584            column_align_header: None,
1585            column_align_body: None,
1586            loose_last_column: false,
1587            aligned_delimiter: false,
1588        };
1589        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1590
1591        // Create a table where we know exact column widths: 5 + 5 + 5 = 15
1592        // Expected aligned width: 1 + (3 * 3) + 15 = 1 + 9 + 15 = 25 chars
1593        // This is under 30, so should stay aligned
1594        let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596
1597        let fixed = rule.fix(&ctx).unwrap();
1598
1599        // Should be aligned
1600        let lines: Vec<&str> = fixed.lines().collect();
1601        assert_eq!(lines[0].len(), lines[1].len());
1602        assert_eq!(lines[1].len(), lines[2].len());
1603        assert_eq!(lines[0].len(), 25); // Verify formula
1604
1605        // Now test with threshold = 24 (just under aligned width)
1606        let config_tight = MD060Config {
1607            enabled: true,
1608            style: "aligned".to_string(),
1609            max_width: LineLength::from_const(24),
1610            column_align: ColumnAlign::Auto,
1611            column_align_header: None,
1612            column_align_body: None,
1613            loose_last_column: false,
1614            aligned_delimiter: false,
1615        };
1616        let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1617
1618        let fixed_compact = rule_tight.fix(&ctx).unwrap();
1619
1620        // Should be compact now (25 > 24)
1621        assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1622        assert!(fixed_compact.contains("| --- | --- | --- |"));
1623    }
1624
1625    #[test]
1626    fn test_md060_very_wide_table_auto_compacts() {
1627        let config = MD060Config {
1628            enabled: true,
1629            style: "aligned".to_string(),
1630            max_width: LineLength::from_const(0),
1631            column_align: ColumnAlign::Auto,
1632            column_align_header: None,
1633            column_align_body: None,
1634            loose_last_column: false,
1635            aligned_delimiter: false,
1636        };
1637        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1638
1639        // Very wide table with many columns
1640        // 8 columns with widths of 12 chars each = 96 chars
1641        // Formula: 1 + (8 * 3) + 96 = 121 chars (exceeds 80)
1642        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 |";
1643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644
1645        let fixed = rule.fix(&ctx).unwrap();
1646
1647        // Should be compact (table would be way over 80 chars aligned)
1648        assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1649        assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1650    }
1651
1652    #[test]
1653    fn test_md060_inherit_from_md013_line_length() {
1654        // max_width = 0 should inherit from MD013's line_length
1655        let config = MD060Config {
1656            enabled: true,
1657            style: "aligned".to_string(),
1658            max_width: LineLength::from_const(0), // Inherit
1659            column_align: ColumnAlign::Auto,
1660            column_align_header: None,
1661            column_align_body: None,
1662            loose_last_column: false,
1663            aligned_delimiter: false,
1664        };
1665
1666        // Test with different MD013 line_length values
1667        let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1668        let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1669
1670        // Medium-sized table
1671        let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673
1674        // With 80 char limit, likely compacts
1675        let _fixed_80 = rule_80.fix(&ctx).unwrap();
1676
1677        // With 120 char limit, likely stays aligned
1678        let fixed_120 = rule_120.fix(&ctx).unwrap();
1679
1680        // Verify 120 is aligned (all lines same length)
1681        let lines_120: Vec<&str> = fixed_120.lines().collect();
1682        assert_eq!(lines_120[0].len(), lines_120[1].len());
1683        assert_eq!(lines_120[1].len(), lines_120[2].len());
1684    }
1685
1686    #[test]
1687    fn test_md060_edge_case_exactly_at_threshold() {
1688        // Create table that's exactly at the threshold
1689        // Formula: 1 + (num_columns * 3) + sum(column_widths) = max_width
1690        // For 2 columns with widths 5 and 5: 1 + 6 + 10 = 17
1691        let config = MD060Config {
1692            enabled: true,
1693            style: "aligned".to_string(),
1694            max_width: LineLength::from_const(17),
1695            column_align: ColumnAlign::Auto,
1696            column_align_header: None,
1697            column_align_body: None,
1698            loose_last_column: false,
1699            aligned_delimiter: false,
1700        };
1701        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1702
1703        let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1704        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705
1706        let fixed = rule.fix(&ctx).unwrap();
1707
1708        // At threshold (17 <= 17), should stay aligned
1709        let lines: Vec<&str> = fixed.lines().collect();
1710        assert_eq!(lines[0].len(), 17);
1711        assert_eq!(lines[0].len(), lines[1].len());
1712        assert_eq!(lines[1].len(), lines[2].len());
1713
1714        // Now test with threshold = 16 (just under)
1715        let config_under = MD060Config {
1716            enabled: true,
1717            style: "aligned".to_string(),
1718            max_width: LineLength::from_const(16),
1719            column_align: ColumnAlign::Auto,
1720            column_align_header: None,
1721            column_align_body: None,
1722            loose_last_column: false,
1723            aligned_delimiter: false,
1724        };
1725        let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1726
1727        let fixed_compact = rule_under.fix(&ctx).unwrap();
1728
1729        // Should compact (17 > 16)
1730        assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1731        assert!(fixed_compact.contains("| --- | --- |"));
1732    }
1733
1734    #[test]
1735    fn test_md060_auto_compact_warning_message() {
1736        // Verify that auto-compact generates an informative warning
1737        let config = MD060Config {
1738            enabled: true,
1739            style: "aligned".to_string(),
1740            max_width: LineLength::from_const(50),
1741            column_align: ColumnAlign::Auto,
1742            column_align_header: None,
1743            column_align_body: None,
1744            loose_last_column: false,
1745            aligned_delimiter: false,
1746        };
1747        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1748
1749        // Table that will be auto-compacted (exceeds 50 chars when aligned)
1750        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1751        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1752
1753        let warnings = rule.check(&ctx).unwrap();
1754
1755        // Should generate warnings with auto-compact message
1756        assert!(!warnings.is_empty(), "Should generate warnings");
1757
1758        let auto_compact_warnings: Vec<_> = warnings
1759            .iter()
1760            .filter(|w| w.message.contains("too wide for aligned formatting"))
1761            .collect();
1762
1763        assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1764
1765        // Verify the warning message includes the width and threshold
1766        let first_warning = auto_compact_warnings[0];
1767        assert!(first_warning.message.contains("85 chars > max-width: 50"));
1768        assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1769    }
1770
1771    #[test]
1772    fn test_md060_issue_129_detect_style_from_all_rows() {
1773        // Issue #129: detect_table_style should check all rows, not just the first row
1774        // If header row has single-space padding but content rows have extra padding,
1775        // the table should be detected as "aligned" and preserved
1776        let rule = MD060TableFormat::new(true, "any".to_string());
1777
1778        // Table where header looks compact but content is aligned
1779        let content = "| a long heading | another long heading |\n\
1780                       | -------------- | -------------------- |\n\
1781                       | a              | 1                    |\n\
1782                       | b b            | 2                    |\n\
1783                       | c c c          | 3                    |";
1784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785
1786        let fixed = rule.fix(&ctx).unwrap();
1787
1788        // Should preserve the aligned formatting of content rows
1789        assert!(
1790            fixed.contains("| a              | 1                    |"),
1791            "Should preserve aligned padding in first content row"
1792        );
1793        assert!(
1794            fixed.contains("| b b            | 2                    |"),
1795            "Should preserve aligned padding in second content row"
1796        );
1797        assert!(
1798            fixed.contains("| c c c          | 3                    |"),
1799            "Should preserve aligned padding in third content row"
1800        );
1801
1802        // Entire table should remain unchanged because it's already properly aligned
1803        assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1804    }
1805
1806    #[test]
1807    fn test_md060_regular_alignment_warning_message() {
1808        // Verify that regular alignment (not auto-compact) generates normal warning
1809        let config = MD060Config {
1810            enabled: true,
1811            style: "aligned".to_string(),
1812            max_width: LineLength::from_const(100), // Large enough to not trigger auto-compact
1813            column_align: ColumnAlign::Auto,
1814            column_align_header: None,
1815            column_align_body: None,
1816            loose_last_column: false,
1817            aligned_delimiter: false,
1818        };
1819        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1820
1821        // Small misaligned table
1822        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1824
1825        let warnings = rule.check(&ctx).unwrap();
1826
1827        // Should generate warnings
1828        assert!(!warnings.is_empty(), "Should generate warnings");
1829
1830        // Verify it's the standard alignment message, not auto-compact
1831        assert!(warnings[0].message.contains("Table columns should be aligned"));
1832        assert!(!warnings[0].message.contains("too wide"));
1833        assert!(!warnings[0].message.contains("max-width"));
1834    }
1835
1836    // === Issue #219: Unlimited table width tests ===
1837
1838    #[test]
1839    fn test_md060_unlimited_when_md013_disabled() {
1840        // When MD013 is globally disabled, max_width should be unlimited
1841        let config = MD060Config {
1842            enabled: true,
1843            style: "aligned".to_string(),
1844            max_width: LineLength::from_const(0), // Inherit
1845            column_align: ColumnAlign::Auto,
1846            column_align_header: None,
1847            column_align_body: None,
1848            loose_last_column: false,
1849            aligned_delimiter: false,
1850        };
1851        let md013_config = MD013Config::default();
1852        let rule = MD060TableFormat::from_config_struct(config, md013_config, true /* disabled */);
1853
1854        // Very wide table that would normally exceed 80 chars
1855        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857        let fixed = rule.fix(&ctx).unwrap();
1858
1859        // Should be aligned (not compacted) since MD013 is disabled
1860        let lines: Vec<&str> = fixed.lines().collect();
1861        // In aligned mode, all lines have the same length
1862        assert_eq!(
1863            lines[0].len(),
1864            lines[1].len(),
1865            "Table should be aligned when MD013 is disabled"
1866        );
1867    }
1868
1869    #[test]
1870    fn test_md060_unlimited_when_md013_tables_false() {
1871        // When MD013.tables = false, max_width should be unlimited
1872        let config = MD060Config {
1873            enabled: true,
1874            style: "aligned".to_string(),
1875            max_width: LineLength::from_const(0),
1876            column_align: ColumnAlign::Auto,
1877            column_align_header: None,
1878            column_align_body: None,
1879            loose_last_column: false,
1880            aligned_delimiter: false,
1881        };
1882        let md013_config = MD013Config {
1883            tables: false, // User doesn't care about table line length
1884            line_length: LineLength::from_const(80),
1885            ..Default::default()
1886        };
1887        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1888
1889        // Wide table that would exceed 80 chars
1890        let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892        let fixed = rule.fix(&ctx).unwrap();
1893
1894        // Should be aligned (no auto-compact since tables=false)
1895        let lines: Vec<&str> = fixed.lines().collect();
1896        assert_eq!(
1897            lines[0].len(),
1898            lines[1].len(),
1899            "Table should be aligned when MD013.tables=false"
1900        );
1901    }
1902
1903    #[test]
1904    fn test_md060_unlimited_when_md013_line_length_zero() {
1905        // When MD013.line_length = 0, max_width should be unlimited
1906        let config = MD060Config {
1907            enabled: true,
1908            style: "aligned".to_string(),
1909            max_width: LineLength::from_const(0),
1910            column_align: ColumnAlign::Auto,
1911            column_align_header: None,
1912            column_align_body: None,
1913            loose_last_column: false,
1914            aligned_delimiter: false,
1915        };
1916        let md013_config = MD013Config {
1917            tables: true,
1918            line_length: LineLength::from_const(0), // No limit
1919            ..Default::default()
1920        };
1921        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1922
1923        // Wide table
1924        let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926        let fixed = rule.fix(&ctx).unwrap();
1927
1928        // Should be aligned
1929        let lines: Vec<&str> = fixed.lines().collect();
1930        assert_eq!(
1931            lines[0].len(),
1932            lines[1].len(),
1933            "Table should be aligned when MD013.line_length=0"
1934        );
1935    }
1936
1937    #[test]
1938    fn test_md060_explicit_max_width_overrides_md013_settings() {
1939        // Explicit max_width should always take precedence
1940        let config = MD060Config {
1941            enabled: true,
1942            style: "aligned".to_string(),
1943            max_width: LineLength::from_const(50), // Explicit limit
1944            column_align: ColumnAlign::Auto,
1945            column_align_header: None,
1946            column_align_body: None,
1947            loose_last_column: false,
1948            aligned_delimiter: false,
1949        };
1950        let md013_config = MD013Config {
1951            tables: false,                          // This would make it unlimited...
1952            line_length: LineLength::from_const(0), // ...and this too
1953            ..Default::default()
1954        };
1955        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1956
1957        // Wide table that exceeds explicit 50-char limit
1958        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1959        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960        let fixed = rule.fix(&ctx).unwrap();
1961
1962        // Should be compact (explicit max_width = 50 overrides MD013 settings)
1963        assert!(
1964            fixed.contains("| --- |"),
1965            "Should be compact format due to explicit max_width"
1966        );
1967    }
1968
1969    #[test]
1970    fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1971        // When MD013.tables = true and MD013.line_length is set, inherit that limit
1972        let config = MD060Config {
1973            enabled: true,
1974            style: "aligned".to_string(),
1975            max_width: LineLength::from_const(0), // Inherit
1976            column_align: ColumnAlign::Auto,
1977            column_align_header: None,
1978            column_align_body: None,
1979            loose_last_column: false,
1980            aligned_delimiter: false,
1981        };
1982        let md013_config = MD013Config {
1983            tables: true,
1984            line_length: LineLength::from_const(50), // 50 char limit
1985            ..Default::default()
1986        };
1987        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1988
1989        // Wide table that exceeds 50 chars
1990        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1991        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1992        let fixed = rule.fix(&ctx).unwrap();
1993
1994        // Should be compact (inherited 50-char limit from MD013)
1995        assert!(
1996            fixed.contains("| --- |"),
1997            "Should be compact format when inheriting MD013 limit"
1998        );
1999    }
2000
2001    // === Issue #311: aligned-no-space style tests ===
2002
2003    #[test]
2004    fn test_aligned_no_space_reformats_spaced_delimiter() {
2005        // Table with "aligned" style (spaces around dashes) should be reformatted
2006        // when target style is "aligned-no-space"
2007        let config = MD060Config {
2008            enabled: true,
2009            style: "aligned-no-space".to_string(),
2010            max_width: LineLength::from_const(0),
2011            column_align: ColumnAlign::Auto,
2012            column_align_header: None,
2013            column_align_body: None,
2014            loose_last_column: false,
2015            aligned_delimiter: false,
2016        };
2017        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2018
2019        // Input: aligned table with spaces around dashes
2020        let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |";
2021        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022        let fixed = rule.fix(&ctx).unwrap();
2023
2024        // Should have no spaces around dashes in delimiter row
2025        // The dashes may be longer to match column width, but should have no spaces
2026        assert!(
2027            !fixed.contains("| ----"),
2028            "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2029        );
2030        assert!(
2031            !fixed.contains("---- |"),
2032            "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2033        );
2034        // Verify it has the compact delimiter format (dashes touching pipes)
2035        assert!(
2036            fixed.contains("|----"),
2037            "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2038        );
2039    }
2040
2041    #[test]
2042    fn test_aligned_reformats_compact_delimiter() {
2043        // Table with "aligned-no-space" style (no spaces around dashes) should be reformatted
2044        // when target style is "aligned"
2045        let config = MD060Config {
2046            enabled: true,
2047            style: "aligned".to_string(),
2048            max_width: LineLength::from_const(0),
2049            column_align: ColumnAlign::Auto,
2050            column_align_header: None,
2051            column_align_body: None,
2052            loose_last_column: false,
2053            aligned_delimiter: false,
2054        };
2055        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2056
2057        // Input: aligned-no-space table (no spaces around dashes)
2058        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
2059        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060        let fixed = rule.fix(&ctx).unwrap();
2061
2062        // Should have spaces around dashes in delimiter row
2063        assert!(
2064            fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2065            "Delimiter should have spaces around dashes. Got:\n{fixed}"
2066        );
2067    }
2068
2069    #[test]
2070    fn test_aligned_no_space_preserves_matching_table() {
2071        // Table already in "aligned-no-space" style should be preserved
2072        let config = MD060Config {
2073            enabled: true,
2074            style: "aligned-no-space".to_string(),
2075            max_width: LineLength::from_const(0),
2076            column_align: ColumnAlign::Auto,
2077            column_align_header: None,
2078            column_align_body: None,
2079            loose_last_column: false,
2080            aligned_delimiter: false,
2081        };
2082        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2083
2084        // Input: already in aligned-no-space style
2085        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
2086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087        let fixed = rule.fix(&ctx).unwrap();
2088
2089        // Should be preserved as-is
2090        assert_eq!(
2091            fixed, content,
2092            "Table already in aligned-no-space style should be preserved"
2093        );
2094    }
2095
2096    #[test]
2097    fn test_aligned_preserves_matching_table() {
2098        // Table already in "aligned" style should be preserved
2099        let config = MD060Config {
2100            enabled: true,
2101            style: "aligned".to_string(),
2102            max_width: LineLength::from_const(0),
2103            column_align: ColumnAlign::Auto,
2104            column_align_header: None,
2105            column_align_body: None,
2106            loose_last_column: false,
2107            aligned_delimiter: false,
2108        };
2109        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2110
2111        // Input: already in aligned style
2112        let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |";
2113        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2114        let fixed = rule.fix(&ctx).unwrap();
2115
2116        // Should be preserved as-is
2117        assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2118    }
2119
2120    #[test]
2121    fn test_cjk_table_display_width_consistency() {
2122        // Test that is_table_already_aligned correctly uses display width, not byte length
2123        // CJK characters have display width of 2, but byte length of 3 in UTF-8
2124        //
2125        // This table is NOT aligned because line lengths differ
2126        // (CJK chars take 3 bytes in UTF-8 but only 2 columns in display)
2127        let table_lines = vec!["| 名前 | Age |", "|------|-----|", "| η”°δΈ­ | 25  |"];
2128
2129        // First check is raw line length equality (byte-based), which fails
2130        let is_aligned =
2131            MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2132        assert!(
2133            !is_aligned,
2134            "Table with uneven raw line lengths should NOT be considered aligned"
2135        );
2136    }
2137
2138    #[test]
2139    fn test_cjk_width_calculation_in_aligned_check() {
2140        // calculate_cell_display_width trims content before calculating width
2141        // Verify CJK width is correctly calculated (2 per character)
2142        let cjk_width = MD060TableFormat::calculate_cell_display_width("名前");
2143        assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2144
2145        let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2146        assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2147
2148        // Test that spacing is trimmed before width calculation
2149        let padded_cjk = MD060TableFormat::calculate_cell_display_width(" 名前 ");
2150        assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2151
2152        // Test mixed content
2153        let mixed = MD060TableFormat::calculate_cell_display_width(" ζ—₯本θͺžABC ");
2154        // 3 CJK chars (width 6) + 3 ASCII (width 3) = 9
2155        assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2156    }
2157
2158    // === Issue #317: column-align option tests ===
2159
2160    #[test]
2161    fn test_md060_column_align_left() {
2162        // Default/explicit left alignment
2163        let config = MD060Config {
2164            enabled: true,
2165            style: "aligned".to_string(),
2166            max_width: LineLength::from_const(0),
2167            column_align: ColumnAlign::Left,
2168            column_align_header: None,
2169            column_align_body: None,
2170            loose_last_column: false,
2171            aligned_delimiter: false,
2172        };
2173        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2174
2175        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2177
2178        let fixed = rule.fix(&ctx).unwrap();
2179        let lines: Vec<&str> = fixed.lines().collect();
2180
2181        // Left aligned: content on left, padding on right
2182        assert!(
2183            lines[2].contains("| Alice "),
2184            "Content should be left-aligned (Alice should have trailing padding)"
2185        );
2186        assert!(
2187            lines[3].contains("| Bob   "),
2188            "Content should be left-aligned (Bob should have trailing padding)"
2189        );
2190    }
2191
2192    #[test]
2193    fn test_md060_column_align_center() {
2194        // Center alignment forces all columns to center
2195        let config = MD060Config {
2196            enabled: true,
2197            style: "aligned".to_string(),
2198            max_width: LineLength::from_const(0),
2199            column_align: ColumnAlign::Center,
2200            column_align_header: None,
2201            column_align_body: None,
2202            loose_last_column: false,
2203            aligned_delimiter: false,
2204        };
2205        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2206
2207        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209
2210        let fixed = rule.fix(&ctx).unwrap();
2211        let lines: Vec<&str> = fixed.lines().collect();
2212
2213        // Center aligned: padding split on both sides
2214        // "Bob" (3 chars) in "Name" column (5 chars) = 2 padding total, 1 left, 1 right
2215        assert!(
2216            lines[3].contains("|  Bob  |"),
2217            "Bob should be centered with padding on both sides. Got: {}",
2218            lines[3]
2219        );
2220    }
2221
2222    #[test]
2223    fn test_md060_column_align_right() {
2224        // Right alignment forces all columns to right-align
2225        let config = MD060Config {
2226            enabled: true,
2227            style: "aligned".to_string(),
2228            max_width: LineLength::from_const(0),
2229            column_align: ColumnAlign::Right,
2230            column_align_header: None,
2231            column_align_body: None,
2232            loose_last_column: false,
2233            aligned_delimiter: false,
2234        };
2235        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2236
2237        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239
2240        let fixed = rule.fix(&ctx).unwrap();
2241        let lines: Vec<&str> = fixed.lines().collect();
2242
2243        // Right aligned: padding on left, content on right
2244        assert!(
2245            lines[3].contains("|   Bob |"),
2246            "Bob should be right-aligned with padding on left. Got: {}",
2247            lines[3]
2248        );
2249    }
2250
2251    #[test]
2252    fn test_md060_column_align_auto_respects_delimiter() {
2253        // Auto mode (default) should respect delimiter row alignment indicators
2254        let config = MD060Config {
2255            enabled: true,
2256            style: "aligned".to_string(),
2257            max_width: LineLength::from_const(0),
2258            column_align: ColumnAlign::Auto,
2259            column_align_header: None,
2260            column_align_body: None,
2261            loose_last_column: false,
2262            aligned_delimiter: false,
2263        };
2264        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2265
2266        // Left, center, right columns via delimiter indicators
2267        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269
2270        let fixed = rule.fix(&ctx).unwrap();
2271
2272        // Verify alignment is applied per-column based on delimiter
2273        assert!(fixed.contains("| A "), "Left column should be left-aligned");
2274        // Center and right columns with longer content in header
2275        let lines: Vec<&str> = fixed.lines().collect();
2276        // The content row should have B centered and C right-aligned
2277        // B (1 char) in "Center" (6 chars) = 5 padding, ~2 left, ~3 right
2278        // C (1 char) in "Right" (5 chars) = 4 padding, all on left
2279        assert!(
2280            lines[2].contains(" C |"),
2281            "Right column should be right-aligned. Got: {}",
2282            lines[2]
2283        );
2284    }
2285
2286    #[test]
2287    fn test_md060_column_align_overrides_delimiter_indicators() {
2288        // column-align should override delimiter row indicators
2289        let config = MD060Config {
2290            enabled: true,
2291            style: "aligned".to_string(),
2292            max_width: LineLength::from_const(0),
2293            column_align: ColumnAlign::Right, // Override all to right
2294            column_align_header: None,
2295            column_align_body: None,
2296            loose_last_column: false,
2297            aligned_delimiter: false,
2298        };
2299        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2300
2301        // Delimiter says left, center, right - but we override all to right
2302        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2304
2305        let fixed = rule.fix(&ctx).unwrap();
2306        let lines: Vec<&str> = fixed.lines().collect();
2307
2308        // ALL columns should be right-aligned despite delimiter indicators
2309        // "A" in "Left" column (4 chars minimum due to header length) should be right-aligned
2310        assert!(
2311            lines[2].contains("    A |") || lines[2].contains("   A |"),
2312            "Even left-indicated column should be right-aligned. Got: {}",
2313            lines[2]
2314        );
2315    }
2316
2317    #[test]
2318    fn test_md060_column_align_with_aligned_no_space() {
2319        // column-align should work with aligned-no-space style
2320        let config = MD060Config {
2321            enabled: true,
2322            style: "aligned-no-space".to_string(),
2323            max_width: LineLength::from_const(0),
2324            column_align: ColumnAlign::Center,
2325            column_align_header: None,
2326            column_align_body: None,
2327            loose_last_column: false,
2328            aligned_delimiter: false,
2329        };
2330        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2331
2332        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334
2335        let fixed = rule.fix(&ctx).unwrap();
2336        let lines: Vec<&str> = fixed.lines().collect();
2337
2338        // Delimiter row should have no spaces (aligned-no-space)
2339        assert!(
2340            lines[1].contains("|---"),
2341            "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2342            lines[1]
2343        );
2344        // Content should still be centered
2345        assert!(
2346            lines[3].contains("|  Bob  |"),
2347            "Content should be centered. Got: {}",
2348            lines[3]
2349        );
2350    }
2351
2352    #[test]
2353    fn test_md060_column_align_config_parsing() {
2354        // Test that column-align config is correctly parsed
2355        let toml_str = r#"
2356enabled = true
2357style = "aligned"
2358column-align = "center"
2359"#;
2360        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2361        assert_eq!(config.column_align, ColumnAlign::Center);
2362
2363        let toml_str = r#"
2364enabled = true
2365style = "aligned"
2366column-align = "right"
2367"#;
2368        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2369        assert_eq!(config.column_align, ColumnAlign::Right);
2370
2371        let toml_str = r#"
2372enabled = true
2373style = "aligned"
2374column-align = "left"
2375"#;
2376        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2377        assert_eq!(config.column_align, ColumnAlign::Left);
2378
2379        let toml_str = r#"
2380enabled = true
2381style = "aligned"
2382column-align = "auto"
2383"#;
2384        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2385        assert_eq!(config.column_align, ColumnAlign::Auto);
2386    }
2387
2388    #[test]
2389    fn test_md060_column_align_default_is_auto() {
2390        // Without column-align specified, default should be Auto
2391        let toml_str = r#"
2392enabled = true
2393style = "aligned"
2394"#;
2395        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2396        assert_eq!(config.column_align, ColumnAlign::Auto);
2397    }
2398
2399    #[test]
2400    fn test_md060_column_align_reformats_already_aligned_table() {
2401        // A table that is already aligned (left) should be reformatted when column-align=right
2402        let config = MD060Config {
2403            enabled: true,
2404            style: "aligned".to_string(),
2405            max_width: LineLength::from_const(0),
2406            column_align: ColumnAlign::Right,
2407            column_align_header: None,
2408            column_align_body: None,
2409            loose_last_column: false,
2410            aligned_delimiter: false,
2411        };
2412        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2413
2414        // This table is already properly aligned with left alignment
2415        let content = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |\n| Bob   | 25  |";
2416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417
2418        let fixed = rule.fix(&ctx).unwrap();
2419        let lines: Vec<&str> = fixed.lines().collect();
2420
2421        // Should be reformatted with right alignment
2422        assert!(
2423            lines[2].contains("| Alice |") && lines[2].contains("|  30 |"),
2424            "Already aligned table should be reformatted with right alignment. Got: {}",
2425            lines[2]
2426        );
2427        assert!(
2428            lines[3].contains("|   Bob |") || lines[3].contains("|  Bob |"),
2429            "Bob should be right-aligned. Got: {}",
2430            lines[3]
2431        );
2432    }
2433
2434    #[test]
2435    fn test_md060_column_align_with_cjk_characters() {
2436        // CJK characters have double display width - centering should account for this
2437        let config = MD060Config {
2438            enabled: true,
2439            style: "aligned".to_string(),
2440            max_width: LineLength::from_const(0),
2441            column_align: ColumnAlign::Center,
2442            column_align_header: None,
2443            column_align_body: None,
2444            loose_last_column: false,
2445            aligned_delimiter: false,
2446        };
2447        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2448
2449        let content = "| Name | City |\n|---|---|\n| Alice | 東京 |\n| Bob | LA |";
2450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2451
2452        let fixed = rule.fix(&ctx).unwrap();
2453
2454        // Both Alice and Bob should be centered, and 東京 should be properly aligned
2455        // considering its double-width display
2456        assert!(fixed.contains("Bob"), "Table should contain Bob");
2457        assert!(fixed.contains("東京"), "Table should contain 東京");
2458    }
2459
2460    #[test]
2461    fn test_md060_column_align_ignored_for_compact_style() {
2462        // column-align should have no effect on compact style (minimal padding)
2463        let config = MD060Config {
2464            enabled: true,
2465            style: "compact".to_string(),
2466            max_width: LineLength::from_const(0),
2467            column_align: ColumnAlign::Right, // This should be ignored
2468            column_align_header: None,
2469            column_align_body: None,
2470            loose_last_column: false,
2471            aligned_delimiter: false,
2472        };
2473        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2474
2475        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2477
2478        let fixed = rule.fix(&ctx).unwrap();
2479
2480        // Compact style: single space padding, no alignment
2481        assert!(
2482            fixed.contains("| Alice |"),
2483            "Compact style should have single space padding, not alignment. Got: {fixed}"
2484        );
2485    }
2486
2487    #[test]
2488    fn test_md060_column_align_ignored_for_tight_style() {
2489        // column-align should have no effect on tight style (no padding)
2490        let config = MD060Config {
2491            enabled: true,
2492            style: "tight".to_string(),
2493            max_width: LineLength::from_const(0),
2494            column_align: ColumnAlign::Center, // This should be ignored
2495            column_align_header: None,
2496            column_align_body: None,
2497            loose_last_column: false,
2498            aligned_delimiter: false,
2499        };
2500        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2501
2502        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2504
2505        let fixed = rule.fix(&ctx).unwrap();
2506
2507        // Tight style: no spaces at all
2508        assert!(
2509            fixed.contains("|Alice|"),
2510            "Tight style should have no spaces. Got: {fixed}"
2511        );
2512    }
2513
2514    #[test]
2515    fn test_md060_column_align_with_empty_cells() {
2516        // Empty cells should be handled correctly with centering
2517        let config = MD060Config {
2518            enabled: true,
2519            style: "aligned".to_string(),
2520            max_width: LineLength::from_const(0),
2521            column_align: ColumnAlign::Center,
2522            column_align_header: None,
2523            column_align_body: None,
2524            loose_last_column: false,
2525            aligned_delimiter: false,
2526        };
2527        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2528
2529        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n|  | 25 |";
2530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531
2532        let fixed = rule.fix(&ctx).unwrap();
2533        let lines: Vec<&str> = fixed.lines().collect();
2534
2535        // Empty cell should have all padding (centered empty string)
2536        assert!(
2537            lines[3].contains("|       |") || lines[3].contains("|      |"),
2538            "Empty cell should be padded correctly. Got: {}",
2539            lines[3]
2540        );
2541    }
2542
2543    #[test]
2544    fn test_md060_column_align_auto_preserves_already_aligned() {
2545        // With column-align=auto (default), already aligned tables should be preserved
2546        let config = MD060Config {
2547            enabled: true,
2548            style: "aligned".to_string(),
2549            max_width: LineLength::from_const(0),
2550            column_align: ColumnAlign::Auto,
2551            column_align_header: None,
2552            column_align_body: None,
2553            loose_last_column: false,
2554            aligned_delimiter: false,
2555        };
2556        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2557
2558        // This table is already properly aligned
2559        let content = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |\n| Bob   | 25  |";
2560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2561
2562        let fixed = rule.fix(&ctx).unwrap();
2563
2564        // Should be preserved as-is
2565        assert_eq!(
2566            fixed, content,
2567            "Already aligned table should be preserved with column-align=auto"
2568        );
2569    }
2570
2571    #[test]
2572    fn test_cjk_table_display_aligned_not_flagged() {
2573        // Verify that alignment detection uses display width (.width()), not byte
2574        // length (.len()). CJK chars are 3 bytes but 2 display columns, so a
2575        // visually aligned table must not be flagged as misaligned.
2576        use crate::config::MarkdownFlavor;
2577
2578        // This table is display-aligned: "Hello " and "δ½ ε₯½  " are both 6 display columns wide
2579        let table_lines: Vec<&str> = vec![
2580            "| Header | Name |",
2581            "| ------ | ---- |",
2582            "| Hello  | Test |",
2583            "| δ½ ε₯½   | Test |",
2584        ];
2585
2586        let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2587        assert!(
2588            result,
2589            "Table with CJK characters that is display-aligned should be recognized as aligned"
2590        );
2591    }
2592
2593    #[test]
2594    fn test_cjk_table_not_reformatted_when_aligned() {
2595        // End-to-end test: a display-aligned CJK table should not trigger MD060
2596        let rule = MD060TableFormat::new(true, "aligned".to_string());
2597        // Build a table that is already correctly aligned (display-width)
2598        let content = "| Header | Name |\n| ------ | ---- |\n| Hello  | Test |\n| δ½ ε₯½   | Test |\n";
2599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2600
2601        // If the table is display-aligned, MD060 should preserve it as-is
2602        let fixed = rule.fix(&ctx).unwrap();
2603        assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2604    }
2605
2606    // === Pandoc construct reachability tests ===
2607    //
2608    // These tests document that MD060 does not flag Pandoc-specific constructs
2609    // because `ctx.table_blocks` excludes them at the source:
2610    //
2611    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
2612    //   returns false and no `TableBlock` is created.
2613    // - Multi-line table separators have no `|`, same exclusion.
2614    // - Line blocks (`| First line`) end without `|`; `is_potential_table_row`
2615    //   requires `valid_parts >= 2` for non-outer-piped lines (only 1 found).
2616    // - Pipe-table captions (`: caption`) have no `|` β€” excluded.
2617    //
2618    // No production guard is needed. If `find_table_blocks` ever changes to
2619    // include these constructs, these tests will surface that.
2620
2621    #[test]
2622    fn md060_pandoc_grid_tables_not_flagged() {
2623        let rule = MD060TableFormat::new(true, "aligned".to_string());
2624        let content = "\
2625+---+---+
2626| a | b |
2627+===+===+
2628| 1 | 2 |
2629+---+---+
2630";
2631        // Grid table delimiters (`+===+===+`) contain no `|`, so `is_delimiter_row`
2632        // returns false and no TableBlock is created β€” MD060 has nothing to check.
2633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2634        let result = rule.check(&ctx).unwrap();
2635        assert!(
2636            result.is_empty(),
2637            "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2638        );
2639
2640        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2641        let result_std = rule.check(&ctx_std).unwrap();
2642        assert!(
2643            result_std.is_empty(),
2644            "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2645        );
2646    }
2647
2648    #[test]
2649    fn md060_pandoc_multi_line_tables_not_flagged() {
2650        let rule = MD060TableFormat::new(true, "aligned".to_string());
2651        let content = "\
2652--------- -----------
2653Header 1   Header 2
2654--------- -----------
2655Cell 1     Cell 2
2656--------- -----------
2657";
2658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2659        let result = rule.check(&ctx).unwrap();
2660        assert!(
2661            result.is_empty(),
2662            "MD060 should not flag Pandoc multi-line tables: {result:?}"
2663        );
2664
2665        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2666        let result_std = rule.check(&ctx_std).unwrap();
2667        assert!(
2668            result_std.is_empty(),
2669            "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2670        );
2671    }
2672
2673    #[test]
2674    fn md060_pandoc_line_blocks_not_flagged() {
2675        let rule = MD060TableFormat::new(true, "aligned".to_string());
2676        // Pandoc line blocks start with `|` but do not end with `|`.
2677        let content = "| First line\n| Second line\n";
2678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2679        let result = rule.check(&ctx).unwrap();
2680        assert!(
2681            result.is_empty(),
2682            "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2683        );
2684
2685        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2686        let result_std = rule.check(&ctx_std).unwrap();
2687        assert!(
2688            result_std.is_empty(),
2689            "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2690        );
2691    }
2692
2693    #[test]
2694    fn md060_pandoc_pipe_table_captions_not_flagged() {
2695        let rule = MD060TableFormat::new(true, "aligned".to_string());
2696        // Pipe-table captions (`: caption`) have no `|` and are excluded from table_blocks.
2697        // Use a fully aligned table so that MD060 does not flag the pipe rows themselves.
2698        let content = "\
2699| H1 | H2 |
2700| -- | -- |
2701| a  | b  |
2702
2703: My table caption
2704";
2705        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2706        let result = rule.check(&ctx).unwrap();
2707        assert!(
2708            result.is_empty(),
2709            "MD060 should not flag the pipe-table caption line: {result:?}"
2710        );
2711
2712        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2713        let result_std = rule.check(&ctx_std).unwrap();
2714        assert!(
2715            result_std.is_empty(),
2716            "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2717        );
2718    }
2719
2720    #[test]
2721    fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2722        // Regression: the fix reconstructed content via raw_lines().join("\n"),
2723        // which dropped trailing blank lines one per pass (non-idempotent) and
2724        // altered documents with no tables at all.
2725        let rule = MD060TableFormat::new(true, "aligned".to_string());
2726
2727        // No table: content must be returned byte-for-byte unchanged.
2728        for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
2729            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2730            assert_eq!(
2731                rule.fix(&ctx).unwrap(),
2732                input,
2733                "MD060 must not alter table-free content: {input:?}"
2734            );
2735        }
2736
2737        // Table followed by trailing blank lines: the table is formatted but the
2738        // trailing blanks survive, and a second pass is a no-op.
2739        let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
2740        let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
2741        let once = rule.fix(&ctx).unwrap();
2742        assert!(
2743            once.ends_with("\n\n\n"),
2744            "trailing blank lines must be preserved, got: {once:?}"
2745        );
2746        let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
2747        let twice = rule.fix(&ctx2).unwrap();
2748        assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
2749    }
2750}