Skip to main content

rumdl_lib/rules/
md060_table_format.rs

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