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 || ctx.content.ends_with('\n') {
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 table_quick_fix_preserves_final_newline_like_the_native_fixer() {
1397        let rule = MD060TableFormat::new(true, "aligned".to_string());
1398        for newline in ["", "\n", "\n\n", "\r\n"] {
1399            let input = format!("|Name|Value|\n|---|---|\n|Zulu|2|{newline}");
1400            let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::Standard, None);
1401            let warnings = rule.check(&ctx).unwrap();
1402            assert!(!warnings.is_empty());
1403            // Use raw edits: the diagnostic fix itself must retain EOF, rather
1404            // than relying on the outer formatter to restore it afterward.
1405            let edit = warnings[0].fix.as_ref().unwrap();
1406            let mut fixed = input.clone();
1407            fixed.replace_range(edit.range.clone(), &edit.replacement);
1408            assert_eq!(fixed, rule.fix(&ctx).unwrap());
1409        }
1410    }
1411
1412    #[test]
1413    fn test_md060_cjk_characters_aligned_correctly() {
1414        let rule = MD060TableFormat::new(true, "aligned".to_string());
1415
1416        let content = "| Name | Age |\n|---|---|\n| 中文 | 30 |";
1417        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1418
1419        let fixed = rule.fix(&ctx).unwrap();
1420
1421        let lines: Vec<&str> = fixed.lines().collect();
1422        let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1423        let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1424
1425        let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1426        let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1427
1428        assert_eq!(width1, width3);
1429    }
1430
1431    #[test]
1432    fn test_md060_basic_emoji() {
1433        let rule = MD060TableFormat::new(true, "aligned".to_string());
1434
1435        let content = "| Status | Name |\n|---|---|\n| ✅ | Test |";
1436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437
1438        let fixed = rule.fix(&ctx).unwrap();
1439        assert!(fixed.contains("Status"));
1440    }
1441
1442    #[test]
1443    fn test_md060_zwj_emoji_skipped() {
1444        let rule = MD060TableFormat::new(true, "aligned".to_string());
1445
1446        let content = "| Emoji | Name |\n|---|---|\n| 👨‍👩‍👧‍👦 | Family |";
1447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448
1449        let fixed = rule.fix(&ctx).unwrap();
1450        assert_eq!(fixed, content);
1451    }
1452
1453    #[test]
1454    fn test_md060_inline_code_with_escaped_pipes() {
1455        // Pipes inside code spans are treated as content, not cell delimiters.
1456        // Escaped pipes (\|) are also supported outside code spans.
1457        let rule = MD060TableFormat::new(true, "aligned".to_string());
1458
1459        // CORRECT: `[0-9]\|[0-9]` - the \| is escaped, stays as content (2 columns)
1460        let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462
1463        let fixed = rule.fix(&ctx).unwrap();
1464        assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1465    }
1466
1467    #[test]
1468    fn test_md060_compact_style() {
1469        let rule = MD060TableFormat::new(true, "compact".to_string());
1470
1471        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473
1474        let fixed = rule.fix(&ctx).unwrap();
1475        let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1476        assert_eq!(fixed, expected);
1477    }
1478
1479    #[test]
1480    fn test_md060_tight_style() {
1481        let rule = MD060TableFormat::new(true, "tight".to_string());
1482
1483        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485
1486        let fixed = rule.fix(&ctx).unwrap();
1487        let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1488        assert_eq!(fixed, expected);
1489    }
1490
1491    #[test]
1492    fn test_md060_aligned_no_space_style() {
1493        // Issue #277: aligned-no-space style has no spaces in delimiter row
1494        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1495
1496        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498
1499        let fixed = rule.fix(&ctx).unwrap();
1500
1501        // Content rows have spaces, delimiter row does not
1502        let lines: Vec<&str> = fixed.lines().collect();
1503        assert_eq!(lines[0], "| Name  | Age |", "Header should have spaces around content");
1504        assert_eq!(
1505            lines[1], "|-------|-----|",
1506            "Delimiter should have NO spaces around dashes"
1507        );
1508        assert_eq!(lines[2], "| Alice | 30  |", "Content should have spaces around content");
1509
1510        // All rows should have equal length
1511        assert_eq!(lines[0].len(), lines[1].len());
1512        assert_eq!(lines[1].len(), lines[2].len());
1513    }
1514
1515    #[test]
1516    fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1517        // Alignment indicators (:) should be preserved
1518        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1519
1520        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522
1523        let fixed = rule.fix(&ctx).unwrap();
1524        let lines: Vec<&str> = fixed.lines().collect();
1525
1526        // Verify alignment indicators are preserved without spaces around them
1527        assert!(
1528            fixed.contains("|:"),
1529            "Should have left alignment indicator adjacent to pipe"
1530        );
1531        assert!(
1532            fixed.contains(":|"),
1533            "Should have right alignment indicator adjacent to pipe"
1534        );
1535        // Check for center alignment - the exact dash count depends on column width
1536        assert!(
1537            lines[1].contains(":---") && lines[1].contains("---:"),
1538            "Should have center alignment colons"
1539        );
1540    }
1541
1542    #[test]
1543    fn test_md060_aligned_no_space_three_column_table() {
1544        // Test the exact format from issue #277
1545        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1546
1547        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 |";
1548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549
1550        let fixed = rule.fix(&ctx).unwrap();
1551        let lines: Vec<&str> = fixed.lines().collect();
1552
1553        // Verify delimiter row format: |--------------|--------------|--------------|
1554        assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1555        assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1556        assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1557        assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1558    }
1559
1560    #[test]
1561    fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1562        // Auto-compact should work with aligned-no-space when table exceeds max-width
1563        let config = MD060Config {
1564            enabled: true,
1565            style: "aligned-no-space".to_string(),
1566            max_width: LineLength::from_const(50),
1567            column_align: ColumnAlign::Auto,
1568            column_align_header: None,
1569            column_align_body: None,
1570            loose_last_column: false,
1571            aligned_delimiter: false,
1572            pad_width: None,
1573        };
1574        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1575
1576        // Wide table that exceeds 50 chars when aligned
1577        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579
1580        let fixed = rule.fix(&ctx).unwrap();
1581
1582        // Should auto-compact to compact style (not aligned-no-space)
1583        assert!(
1584            fixed.contains("| --- |"),
1585            "Should be compact format when exceeding max-width"
1586        );
1587    }
1588
1589    #[test]
1590    fn test_md060_aligned_no_space_cjk_characters() {
1591        // CJK characters should be handled correctly
1592        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1593
1594        let content = "| Name | City |\n|---|---|\n| 中文 | 東京 |";
1595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596
1597        let fixed = rule.fix(&ctx).unwrap();
1598        let lines: Vec<&str> = fixed.lines().collect();
1599
1600        // All rows should have equal DISPLAY width (not byte length)
1601        // CJK characters are double-width, so byte length differs from display width
1602        use unicode_width::UnicodeWidthStr;
1603        assert_eq!(
1604            lines[0].width(),
1605            lines[1].width(),
1606            "Header and delimiter should have same display width"
1607        );
1608        assert_eq!(
1609            lines[1].width(),
1610            lines[2].width(),
1611            "Delimiter and content should have same display width"
1612        );
1613
1614        // Delimiter should have no spaces
1615        assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1616    }
1617
1618    #[test]
1619    fn test_md060_aligned_no_space_minimum_width() {
1620        // Minimum column width (3 dashes) should be respected
1621        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1622
1623        let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625
1626        let fixed = rule.fix(&ctx).unwrap();
1627        let lines: Vec<&str> = fixed.lines().collect();
1628
1629        // Should have at least 3 dashes per column (GFM requirement)
1630        assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1631        // All rows should have equal length
1632        assert_eq!(lines[0].len(), lines[1].len());
1633        assert_eq!(lines[1].len(), lines[2].len());
1634    }
1635
1636    #[test]
1637    fn test_md060_any_style_consistency() {
1638        let rule = MD060TableFormat::new(true, "any".to_string());
1639
1640        // Table is already compact, should stay compact
1641        let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1643
1644        let fixed = rule.fix(&ctx).unwrap();
1645        assert_eq!(fixed, content);
1646
1647        // Table is aligned, should stay aligned
1648        let content_aligned = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1649        let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1650
1651        let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1652        assert_eq!(fixed_aligned, content_aligned);
1653    }
1654
1655    #[test]
1656    fn test_md060_empty_cells() {
1657        let rule = MD060TableFormat::new(true, "aligned".to_string());
1658
1659        let content = "| A | B |\n|---|---|\n|  | X |";
1660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661
1662        let fixed = rule.fix(&ctx).unwrap();
1663        assert!(fixed.contains('|'));
1664    }
1665
1666    #[test]
1667    fn test_md060_mixed_content() {
1668        let rule = MD060TableFormat::new(true, "aligned".to_string());
1669
1670        let content = "| Name | Age | City |\n|---|---|---|\n| 中文 | 30 | NYC |";
1671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672
1673        let fixed = rule.fix(&ctx).unwrap();
1674        assert!(fixed.contains("中文"));
1675        assert!(fixed.contains("NYC"));
1676    }
1677
1678    #[test]
1679    fn test_md060_preserve_alignment_indicators() {
1680        let rule = MD060TableFormat::new(true, "aligned".to_string());
1681
1682        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684
1685        let fixed = rule.fix(&ctx).unwrap();
1686
1687        assert!(fixed.contains(":---"), "Should contain left alignment");
1688        assert!(fixed.contains(":----:"), "Should contain center alignment");
1689        assert!(fixed.contains("----:"), "Should contain right alignment");
1690    }
1691
1692    #[test]
1693    fn test_md060_minimum_column_width() {
1694        let rule = MD060TableFormat::new(true, "aligned".to_string());
1695
1696        // Test with very short column content to ensure minimum width of 3
1697        // GFM requires at least 3 dashes in delimiter rows
1698        let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700
1701        let fixed = rule.fix(&ctx).unwrap();
1702
1703        let lines: Vec<&str> = fixed.lines().collect();
1704        assert_eq!(lines[0].len(), lines[1].len());
1705        assert_eq!(lines[1].len(), lines[2].len());
1706
1707        // Verify minimum width is enforced
1708        assert!(fixed.contains("ID "), "Short content should be padded");
1709        assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1710    }
1711
1712    #[test]
1713    fn test_md060_auto_compact_exceeds_default_threshold() {
1714        // Default max_width = 0, which inherits from default MD013 line_length = 80
1715        let config = MD060Config {
1716            enabled: true,
1717            style: "aligned".to_string(),
1718            max_width: LineLength::from_const(0),
1719            column_align: ColumnAlign::Auto,
1720            column_align_header: None,
1721            column_align_body: None,
1722            loose_last_column: false,
1723            aligned_delimiter: false,
1724            pad_width: None,
1725        };
1726        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1727
1728        // Table that would be 85 chars when aligned (exceeds 80)
1729        // Formula: 1 + (3 * 3) + (20 + 20 + 30) = 1 + 9 + 70 = 80 chars
1730        // But with actual content padding it will exceed
1731        let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733
1734        let fixed = rule.fix(&ctx).unwrap();
1735
1736        // Should use compact formatting (single spaces)
1737        assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1738        assert!(fixed.contains("| --- | --- | --- |"));
1739        assert!(fixed.contains("| Short | Data | Here |"));
1740
1741        // Verify it's compact (no extra padding)
1742        let lines: Vec<&str> = fixed.lines().collect();
1743        // In compact mode, lines can have different lengths
1744        assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1745    }
1746
1747    #[test]
1748    fn test_md060_auto_compact_exceeds_explicit_threshold() {
1749        // Explicit max_width = 50
1750        let config = MD060Config {
1751            enabled: true,
1752            style: "aligned".to_string(),
1753            max_width: LineLength::from_const(50),
1754            column_align: ColumnAlign::Auto,
1755            column_align_header: None,
1756            column_align_body: None,
1757            loose_last_column: false,
1758            aligned_delimiter: false,
1759            pad_width: None,
1760        };
1761        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false); // MD013 setting doesn't matter
1762
1763        // Table that would exceed 50 chars when aligned
1764        // Column widths: 25 + 25 + 25 = 75 chars
1765        // Formula: 1 + (3 * 3) + 75 = 85 chars (exceeds 50)
1766        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1767        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1768
1769        let fixed = rule.fix(&ctx).unwrap();
1770
1771        // Should use compact formatting (single spaces, no extra padding)
1772        assert!(
1773            fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1774        );
1775        assert!(fixed.contains("| --- | --- | --- |"));
1776        assert!(fixed.contains("| Data | Data | Data |"));
1777
1778        // Verify it's compact (lines have different lengths)
1779        let lines: Vec<&str> = fixed.lines().collect();
1780        assert!(lines[0].len() != lines[2].len());
1781    }
1782
1783    #[test]
1784    fn test_md060_stays_aligned_under_threshold() {
1785        // max_width = 100, table will be under this
1786        let config = MD060Config {
1787            enabled: true,
1788            style: "aligned".to_string(),
1789            max_width: LineLength::from_const(100),
1790            column_align: ColumnAlign::Auto,
1791            column_align_header: None,
1792            column_align_body: None,
1793            loose_last_column: false,
1794            aligned_delimiter: false,
1795            pad_width: None,
1796        };
1797        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1798
1799        // Small table that fits well under 100 chars
1800        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802
1803        let fixed = rule.fix(&ctx).unwrap();
1804
1805        // Should use aligned formatting (all lines same length)
1806        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1807        assert_eq!(fixed, expected);
1808
1809        let lines: Vec<&str> = fixed.lines().collect();
1810        assert_eq!(lines[0].len(), lines[1].len());
1811        assert_eq!(lines[1].len(), lines[2].len());
1812    }
1813
1814    #[test]
1815    fn test_md060_width_calculation_formula() {
1816        // Verify the width calculation formula: 1 + (num_columns * 3) + sum(column_widths)
1817        let config = MD060Config {
1818            enabled: true,
1819            style: "aligned".to_string(),
1820            max_width: LineLength::from_const(0),
1821            column_align: ColumnAlign::Auto,
1822            column_align_header: None,
1823            column_align_body: None,
1824            loose_last_column: false,
1825            aligned_delimiter: false,
1826            pad_width: None,
1827        };
1828        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1829
1830        // Create a table where we know exact column widths: 5 + 5 + 5 = 15
1831        // Expected aligned width: 1 + (3 * 3) + 15 = 1 + 9 + 15 = 25 chars
1832        // This is under 30, so should stay aligned
1833        let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835
1836        let fixed = rule.fix(&ctx).unwrap();
1837
1838        // Should be aligned
1839        let lines: Vec<&str> = fixed.lines().collect();
1840        assert_eq!(lines[0].len(), lines[1].len());
1841        assert_eq!(lines[1].len(), lines[2].len());
1842        assert_eq!(lines[0].len(), 25); // Verify formula
1843
1844        // Now test with threshold = 24 (just under aligned width)
1845        let config_tight = MD060Config {
1846            enabled: true,
1847            style: "aligned".to_string(),
1848            max_width: LineLength::from_const(24),
1849            column_align: ColumnAlign::Auto,
1850            column_align_header: None,
1851            column_align_body: None,
1852            loose_last_column: false,
1853            aligned_delimiter: false,
1854            pad_width: None,
1855        };
1856        let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1857
1858        let fixed_compact = rule_tight.fix(&ctx).unwrap();
1859
1860        // Should be compact now (25 > 24)
1861        assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1862        assert!(fixed_compact.contains("| --- | --- | --- |"));
1863    }
1864
1865    #[test]
1866    fn test_md060_very_wide_table_auto_compacts() {
1867        let config = MD060Config {
1868            enabled: true,
1869            style: "aligned".to_string(),
1870            max_width: LineLength::from_const(0),
1871            column_align: ColumnAlign::Auto,
1872            column_align_header: None,
1873            column_align_body: None,
1874            loose_last_column: false,
1875            aligned_delimiter: false,
1876            pad_width: None,
1877        };
1878        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1879
1880        // Very wide table with many columns
1881        // 8 columns with widths of 12 chars each = 96 chars
1882        // Formula: 1 + (8 * 3) + 96 = 121 chars (exceeds 80)
1883        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 |";
1884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885
1886        let fixed = rule.fix(&ctx).unwrap();
1887
1888        // Should be compact (table would be way over 80 chars aligned)
1889        assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1890        assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1891    }
1892
1893    #[test]
1894    fn test_md060_inherit_from_md013_line_length() {
1895        // max_width = 0 should inherit from MD013's line_length
1896        let config = MD060Config {
1897            enabled: true,
1898            style: "aligned".to_string(),
1899            max_width: LineLength::from_const(0), // Inherit
1900            column_align: ColumnAlign::Auto,
1901            column_align_header: None,
1902            column_align_body: None,
1903            loose_last_column: false,
1904            aligned_delimiter: false,
1905            pad_width: None,
1906        };
1907
1908        // Test with different MD013 line_length values
1909        let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1910        let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1911
1912        // Medium-sized table
1913        let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915
1916        // With 80 char limit, likely compacts
1917        let _fixed_80 = rule_80.fix(&ctx).unwrap();
1918
1919        // With 120 char limit, likely stays aligned
1920        let fixed_120 = rule_120.fix(&ctx).unwrap();
1921
1922        // Verify 120 is aligned (all lines same length)
1923        let lines_120: Vec<&str> = fixed_120.lines().collect();
1924        assert_eq!(lines_120[0].len(), lines_120[1].len());
1925        assert_eq!(lines_120[1].len(), lines_120[2].len());
1926    }
1927
1928    #[test]
1929    fn test_md060_edge_case_exactly_at_threshold() {
1930        // Create table that's exactly at the threshold
1931        // Formula: 1 + (num_columns * 3) + sum(column_widths) = max_width
1932        // For 2 columns with widths 5 and 5: 1 + 6 + 10 = 17
1933        let config = MD060Config {
1934            enabled: true,
1935            style: "aligned".to_string(),
1936            max_width: LineLength::from_const(17),
1937            column_align: ColumnAlign::Auto,
1938            column_align_header: None,
1939            column_align_body: None,
1940            loose_last_column: false,
1941            aligned_delimiter: false,
1942            pad_width: None,
1943        };
1944        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1945
1946        let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1947        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948
1949        let fixed = rule.fix(&ctx).unwrap();
1950
1951        // At threshold (17 <= 17), should stay aligned
1952        let lines: Vec<&str> = fixed.lines().collect();
1953        assert_eq!(lines[0].len(), 17);
1954        assert_eq!(lines[0].len(), lines[1].len());
1955        assert_eq!(lines[1].len(), lines[2].len());
1956
1957        // Now test with threshold = 16 (just under)
1958        let config_under = MD060Config {
1959            enabled: true,
1960            style: "aligned".to_string(),
1961            max_width: LineLength::from_const(16),
1962            column_align: ColumnAlign::Auto,
1963            column_align_header: None,
1964            column_align_body: None,
1965            loose_last_column: false,
1966            aligned_delimiter: false,
1967            pad_width: None,
1968        };
1969        let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1970
1971        let fixed_compact = rule_under.fix(&ctx).unwrap();
1972
1973        // Should compact (17 > 16)
1974        assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1975        assert!(fixed_compact.contains("| --- | --- |"));
1976    }
1977
1978    #[test]
1979    fn test_md060_auto_compact_warning_message() {
1980        // Verify that auto-compact generates an informative warning
1981        let config = MD060Config {
1982            enabled: true,
1983            style: "aligned".to_string(),
1984            max_width: LineLength::from_const(50),
1985            column_align: ColumnAlign::Auto,
1986            column_align_header: None,
1987            column_align_body: None,
1988            loose_last_column: false,
1989            aligned_delimiter: false,
1990            pad_width: None,
1991        };
1992        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1993
1994        // Table that will be auto-compacted (exceeds 50 chars when aligned)
1995        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1996        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1997
1998        let warnings = rule.check(&ctx).unwrap();
1999
2000        // Should generate warnings with auto-compact message
2001        assert!(!warnings.is_empty(), "Should generate warnings");
2002
2003        let auto_compact_warnings: Vec<_> = warnings
2004            .iter()
2005            .filter(|w| w.message.contains("too wide for aligned formatting"))
2006            .collect();
2007
2008        assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
2009
2010        // Verify the warning message includes the width and threshold
2011        let first_warning = auto_compact_warnings[0];
2012        assert!(first_warning.message.contains("85 chars > max-width: 50"));
2013        assert!(first_warning.message.contains("Table too wide for aligned formatting"));
2014    }
2015
2016    #[test]
2017    fn test_md060_issue_129_detect_style_from_all_rows() {
2018        // Issue #129: detect_table_style should check all rows, not just the first row
2019        // If header row has single-space padding but content rows have extra padding,
2020        // the table should be detected as "aligned" and preserved
2021        let rule = MD060TableFormat::new(true, "any".to_string());
2022
2023        // Table where header looks compact but content is aligned
2024        let content = "| a long heading | another long heading |\n\
2025                       | -------------- | -------------------- |\n\
2026                       | a              | 1                    |\n\
2027                       | b b            | 2                    |\n\
2028                       | c c c          | 3                    |";
2029        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030
2031        let fixed = rule.fix(&ctx).unwrap();
2032
2033        // Should preserve the aligned formatting of content rows
2034        assert!(
2035            fixed.contains("| a              | 1                    |"),
2036            "Should preserve aligned padding in first content row"
2037        );
2038        assert!(
2039            fixed.contains("| b b            | 2                    |"),
2040            "Should preserve aligned padding in second content row"
2041        );
2042        assert!(
2043            fixed.contains("| c c c          | 3                    |"),
2044            "Should preserve aligned padding in third content row"
2045        );
2046
2047        // Entire table should remain unchanged because it's already properly aligned
2048        assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
2049    }
2050
2051    #[test]
2052    fn test_md060_regular_alignment_warning_message() {
2053        // Verify that regular alignment (not auto-compact) generates normal warning
2054        let config = MD060Config {
2055            enabled: true,
2056            style: "aligned".to_string(),
2057            max_width: LineLength::from_const(100), // Large enough to not trigger auto-compact
2058            column_align: ColumnAlign::Auto,
2059            column_align_header: None,
2060            column_align_body: None,
2061            loose_last_column: false,
2062            aligned_delimiter: false,
2063            pad_width: None,
2064        };
2065        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
2066
2067        // Small misaligned table
2068        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
2069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070
2071        let warnings = rule.check(&ctx).unwrap();
2072
2073        // Should generate warnings
2074        assert!(!warnings.is_empty(), "Should generate warnings");
2075
2076        // Verify it's the standard alignment message, not auto-compact
2077        assert!(warnings[0].message.contains("Table columns should be aligned"));
2078        assert!(!warnings[0].message.contains("too wide"));
2079        assert!(!warnings[0].message.contains("max-width"));
2080    }
2081
2082    // === Issue #219: Unlimited table width tests ===
2083
2084    #[test]
2085    fn test_md060_unlimited_when_md013_disabled() {
2086        // When MD013 is globally disabled, max_width should be unlimited
2087        let config = MD060Config {
2088            enabled: true,
2089            style: "aligned".to_string(),
2090            max_width: LineLength::from_const(0), // Inherit
2091            column_align: ColumnAlign::Auto,
2092            column_align_header: None,
2093            column_align_body: None,
2094            loose_last_column: false,
2095            aligned_delimiter: false,
2096            pad_width: None,
2097        };
2098        let md013_config = MD013Config::default();
2099        let rule = MD060TableFormat::from_config_struct(config, md013_config, true /* disabled */);
2100
2101        // Very wide table that would normally exceed 80 chars
2102        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
2103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2104        let fixed = rule.fix(&ctx).unwrap();
2105
2106        // Should be aligned (not compacted) since MD013 is disabled
2107        let lines: Vec<&str> = fixed.lines().collect();
2108        // In aligned mode, all lines have the same length
2109        assert_eq!(
2110            lines[0].len(),
2111            lines[1].len(),
2112            "Table should be aligned when MD013 is disabled"
2113        );
2114    }
2115
2116    #[test]
2117    fn test_md060_unlimited_when_md013_tables_false() {
2118        // When MD013.tables = false, max_width should be unlimited
2119        let config = MD060Config {
2120            enabled: true,
2121            style: "aligned".to_string(),
2122            max_width: LineLength::from_const(0),
2123            column_align: ColumnAlign::Auto,
2124            column_align_header: None,
2125            column_align_body: None,
2126            loose_last_column: false,
2127            aligned_delimiter: false,
2128            pad_width: None,
2129        };
2130        let md013_config = MD013Config {
2131            tables: false, // User doesn't care about table line length
2132            line_length: LineLength::from_const(80),
2133            ..Default::default()
2134        };
2135        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2136
2137        // Wide table that would exceed 80 chars
2138        let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
2139        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2140        let fixed = rule.fix(&ctx).unwrap();
2141
2142        // Should be aligned (no auto-compact since tables=false)
2143        let lines: Vec<&str> = fixed.lines().collect();
2144        assert_eq!(
2145            lines[0].len(),
2146            lines[1].len(),
2147            "Table should be aligned when MD013.tables=false"
2148        );
2149    }
2150
2151    #[test]
2152    fn test_md060_unlimited_when_md013_line_length_zero() {
2153        // When MD013.line_length = 0, max_width should be unlimited
2154        let config = MD060Config {
2155            enabled: true,
2156            style: "aligned".to_string(),
2157            max_width: LineLength::from_const(0),
2158            column_align: ColumnAlign::Auto,
2159            column_align_header: None,
2160            column_align_body: None,
2161            loose_last_column: false,
2162            aligned_delimiter: false,
2163            pad_width: None,
2164        };
2165        let md013_config = MD013Config {
2166            tables: true,
2167            line_length: LineLength::from_const(0), // No limit
2168            ..Default::default()
2169        };
2170        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2171
2172        // Wide table
2173        let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
2174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2175        let fixed = rule.fix(&ctx).unwrap();
2176
2177        // Should be aligned
2178        let lines: Vec<&str> = fixed.lines().collect();
2179        assert_eq!(
2180            lines[0].len(),
2181            lines[1].len(),
2182            "Table should be aligned when MD013.line_length=0"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_md060_explicit_max_width_overrides_md013_settings() {
2188        // Explicit max_width should always take precedence
2189        let config = MD060Config {
2190            enabled: true,
2191            style: "aligned".to_string(),
2192            max_width: LineLength::from_const(50), // Explicit limit
2193            column_align: ColumnAlign::Auto,
2194            column_align_header: None,
2195            column_align_body: None,
2196            loose_last_column: false,
2197            aligned_delimiter: false,
2198            pad_width: None,
2199        };
2200        let md013_config = MD013Config {
2201            tables: false,                          // This would make it unlimited...
2202            line_length: LineLength::from_const(0), // ...and this too
2203            ..Default::default()
2204        };
2205        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2206
2207        // Wide table that exceeds explicit 50-char limit
2208        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2209        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210        let fixed = rule.fix(&ctx).unwrap();
2211
2212        // Should be compact (explicit max_width = 50 overrides MD013 settings)
2213        assert!(
2214            fixed.contains("| --- |"),
2215            "Should be compact format due to explicit max_width"
2216        );
2217    }
2218
2219    #[test]
2220    fn test_md060_inherits_md013_line_length_when_tables_enabled() {
2221        // When MD013.tables = true and MD013.line_length is set, inherit that limit
2222        let config = MD060Config {
2223            enabled: true,
2224            style: "aligned".to_string(),
2225            max_width: LineLength::from_const(0), // Inherit
2226            column_align: ColumnAlign::Auto,
2227            column_align_header: None,
2228            column_align_body: None,
2229            loose_last_column: false,
2230            aligned_delimiter: false,
2231            pad_width: None,
2232        };
2233        let md013_config = MD013Config {
2234            tables: true,
2235            line_length: LineLength::from_const(50), // 50 char limit
2236            ..Default::default()
2237        };
2238        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2239
2240        // Wide table that exceeds 50 chars
2241        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2243        let fixed = rule.fix(&ctx).unwrap();
2244
2245        // Should be compact (inherited 50-char limit from MD013)
2246        assert!(
2247            fixed.contains("| --- |"),
2248            "Should be compact format when inheriting MD013 limit"
2249        );
2250    }
2251
2252    // === Issue #311: aligned-no-space style tests ===
2253
2254    #[test]
2255    fn test_aligned_no_space_reformats_spaced_delimiter() {
2256        // Table with "aligned" style (spaces around dashes) should be reformatted
2257        // when target style is "aligned-no-space"
2258        let config = MD060Config {
2259            enabled: true,
2260            style: "aligned-no-space".to_string(),
2261            max_width: LineLength::from_const(0),
2262            column_align: ColumnAlign::Auto,
2263            column_align_header: None,
2264            column_align_body: None,
2265            loose_last_column: false,
2266            aligned_delimiter: false,
2267            pad_width: None,
2268        };
2269        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2270
2271        // Input: aligned table with spaces around dashes
2272        let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |";
2273        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274        let fixed = rule.fix(&ctx).unwrap();
2275
2276        // Should have no spaces around dashes in delimiter row
2277        // The dashes may be longer to match column width, but should have no spaces
2278        assert!(
2279            !fixed.contains("| ----"),
2280            "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2281        );
2282        assert!(
2283            !fixed.contains("---- |"),
2284            "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2285        );
2286        // Verify it has the compact delimiter format (dashes touching pipes)
2287        assert!(
2288            fixed.contains("|----"),
2289            "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2290        );
2291    }
2292
2293    #[test]
2294    fn test_aligned_reformats_compact_delimiter() {
2295        // Table with "aligned-no-space" style (no spaces around dashes) should be reformatted
2296        // when target style is "aligned"
2297        let config = MD060Config {
2298            enabled: true,
2299            style: "aligned".to_string(),
2300            max_width: LineLength::from_const(0),
2301            column_align: ColumnAlign::Auto,
2302            column_align_header: None,
2303            column_align_body: None,
2304            loose_last_column: false,
2305            aligned_delimiter: false,
2306            pad_width: None,
2307        };
2308        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2309
2310        // Input: aligned-no-space table (no spaces around dashes)
2311        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
2312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313        let fixed = rule.fix(&ctx).unwrap();
2314
2315        // Should have spaces around dashes in delimiter row
2316        assert!(
2317            fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2318            "Delimiter should have spaces around dashes. Got:\n{fixed}"
2319        );
2320    }
2321
2322    #[test]
2323    fn test_aligned_no_space_preserves_matching_table() {
2324        // Table already in "aligned-no-space" style should be preserved
2325        let config = MD060Config {
2326            enabled: true,
2327            style: "aligned-no-space".to_string(),
2328            max_width: LineLength::from_const(0),
2329            column_align: ColumnAlign::Auto,
2330            column_align_header: None,
2331            column_align_body: None,
2332            loose_last_column: false,
2333            aligned_delimiter: false,
2334            pad_width: None,
2335        };
2336        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2337
2338        // Input: already in aligned-no-space style
2339        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
2340        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2341        let fixed = rule.fix(&ctx).unwrap();
2342
2343        // Should be preserved as-is
2344        assert_eq!(
2345            fixed, content,
2346            "Table already in aligned-no-space style should be preserved"
2347        );
2348    }
2349
2350    #[test]
2351    fn test_aligned_preserves_matching_table() {
2352        // Table already in "aligned" style should be preserved
2353        let config = MD060Config {
2354            enabled: true,
2355            style: "aligned".to_string(),
2356            max_width: LineLength::from_const(0),
2357            column_align: ColumnAlign::Auto,
2358            column_align_header: None,
2359            column_align_body: None,
2360            loose_last_column: false,
2361            aligned_delimiter: false,
2362            pad_width: None,
2363        };
2364        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2365
2366        // Input: already in aligned style
2367        let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |";
2368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369        let fixed = rule.fix(&ctx).unwrap();
2370
2371        // Should be preserved as-is
2372        assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2373    }
2374
2375    #[test]
2376    fn test_cjk_table_display_width_consistency() {
2377        // Test that is_table_already_aligned correctly uses display width, not byte length
2378        // CJK characters have display width of 2, but byte length of 3 in UTF-8
2379        //
2380        // This table is NOT aligned because line lengths differ
2381        // (CJK chars take 3 bytes in UTF-8 but only 2 columns in display)
2382        let table_lines = vec!["| 名前 | Age |", "|------|-----|", "| 田中 | 25  |"];
2383
2384        // First check is raw line length equality (byte-based), which fails
2385        let is_aligned = MD060TableFormat::is_table_already_aligned(
2386            &table_lines,
2387            crate::config::MarkdownFlavor::Standard,
2388            false,
2389            None,
2390        );
2391        assert!(
2392            !is_aligned,
2393            "Table with uneven raw line lengths should NOT be considered aligned"
2394        );
2395    }
2396
2397    #[test]
2398    fn test_cjk_width_calculation_in_aligned_check() {
2399        // calculate_cell_display_width trims content before calculating width
2400        // Verify CJK width is correctly calculated (2 per character)
2401        let cjk_width = MD060TableFormat::calculate_cell_display_width("名前");
2402        assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2403
2404        let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2405        assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2406
2407        // Test that spacing is trimmed before width calculation
2408        let padded_cjk = MD060TableFormat::calculate_cell_display_width(" 名前 ");
2409        assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2410
2411        // Test mixed content
2412        let mixed = MD060TableFormat::calculate_cell_display_width(" 日本語ABC ");
2413        // 3 CJK chars (width 6) + 3 ASCII (width 3) = 9
2414        assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2415    }
2416
2417    // === Issue #317: column-align option tests ===
2418
2419    #[test]
2420    fn test_md060_column_align_left() {
2421        // Default/explicit left alignment
2422        let config = MD060Config {
2423            enabled: true,
2424            style: "aligned".to_string(),
2425            max_width: LineLength::from_const(0),
2426            column_align: ColumnAlign::Left,
2427            column_align_header: None,
2428            column_align_body: None,
2429            loose_last_column: false,
2430            aligned_delimiter: false,
2431            pad_width: None,
2432        };
2433        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2434
2435        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2437
2438        let fixed = rule.fix(&ctx).unwrap();
2439        let lines: Vec<&str> = fixed.lines().collect();
2440
2441        // Left aligned: content on left, padding on right
2442        assert!(
2443            lines[2].contains("| Alice "),
2444            "Content should be left-aligned (Alice should have trailing padding)"
2445        );
2446        assert!(
2447            lines[3].contains("| Bob   "),
2448            "Content should be left-aligned (Bob should have trailing padding)"
2449        );
2450    }
2451
2452    #[test]
2453    fn test_md060_column_align_center() {
2454        // Center alignment forces all columns to center
2455        let config = MD060Config {
2456            enabled: true,
2457            style: "aligned".to_string(),
2458            max_width: LineLength::from_const(0),
2459            column_align: ColumnAlign::Center,
2460            column_align_header: None,
2461            column_align_body: None,
2462            loose_last_column: false,
2463            aligned_delimiter: false,
2464            pad_width: None,
2465        };
2466        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2467
2468        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2470
2471        let fixed = rule.fix(&ctx).unwrap();
2472        let lines: Vec<&str> = fixed.lines().collect();
2473
2474        // Center aligned: padding split on both sides
2475        // "Bob" (3 chars) in "Name" column (5 chars) = 2 padding total, 1 left, 1 right
2476        assert!(
2477            lines[3].contains("|  Bob  |"),
2478            "Bob should be centered with padding on both sides. Got: {}",
2479            lines[3]
2480        );
2481    }
2482
2483    #[test]
2484    fn test_md060_column_align_right() {
2485        // Right alignment forces all columns to right-align
2486        let config = MD060Config {
2487            enabled: true,
2488            style: "aligned".to_string(),
2489            max_width: LineLength::from_const(0),
2490            column_align: ColumnAlign::Right,
2491            column_align_header: None,
2492            column_align_body: None,
2493            loose_last_column: false,
2494            aligned_delimiter: false,
2495            pad_width: None,
2496        };
2497        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2498
2499        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2500        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2501
2502        let fixed = rule.fix(&ctx).unwrap();
2503        let lines: Vec<&str> = fixed.lines().collect();
2504
2505        // Right aligned: padding on left, content on right
2506        assert!(
2507            lines[3].contains("|   Bob |"),
2508            "Bob should be right-aligned with padding on left. Got: {}",
2509            lines[3]
2510        );
2511    }
2512
2513    #[test]
2514    fn test_md060_column_align_auto_respects_delimiter() {
2515        // Auto mode (default) should respect delimiter row alignment indicators
2516        let config = MD060Config {
2517            enabled: true,
2518            style: "aligned".to_string(),
2519            max_width: LineLength::from_const(0),
2520            column_align: ColumnAlign::Auto,
2521            column_align_header: None,
2522            column_align_body: None,
2523            loose_last_column: false,
2524            aligned_delimiter: false,
2525            pad_width: None,
2526        };
2527        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2528
2529        // Left, center, right columns via delimiter indicators
2530        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2532
2533        let fixed = rule.fix(&ctx).unwrap();
2534
2535        // Verify alignment is applied per-column based on delimiter
2536        assert!(fixed.contains("| A "), "Left column should be left-aligned");
2537        // Center and right columns with longer content in header
2538        let lines: Vec<&str> = fixed.lines().collect();
2539        // The content row should have B centered and C right-aligned
2540        // B (1 char) in "Center" (6 chars) = 5 padding, ~2 left, ~3 right
2541        // C (1 char) in "Right" (5 chars) = 4 padding, all on left
2542        assert!(
2543            lines[2].contains(" C |"),
2544            "Right column should be right-aligned. Got: {}",
2545            lines[2]
2546        );
2547    }
2548
2549    #[test]
2550    fn test_md060_column_align_overrides_delimiter_indicators() {
2551        // column-align should override delimiter row indicators
2552        let config = MD060Config {
2553            enabled: true,
2554            style: "aligned".to_string(),
2555            max_width: LineLength::from_const(0),
2556            column_align: ColumnAlign::Right, // Override all to right
2557            column_align_header: None,
2558            column_align_body: None,
2559            loose_last_column: false,
2560            aligned_delimiter: false,
2561            pad_width: None,
2562        };
2563        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2564
2565        // Delimiter says left, center, right - but we override all to right
2566        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2568
2569        let fixed = rule.fix(&ctx).unwrap();
2570        let lines: Vec<&str> = fixed.lines().collect();
2571
2572        // ALL columns should be right-aligned despite delimiter indicators
2573        // "A" in "Left" column (4 chars minimum due to header length) should be right-aligned
2574        assert!(
2575            lines[2].contains("    A |") || lines[2].contains("   A |"),
2576            "Even left-indicated column should be right-aligned. Got: {}",
2577            lines[2]
2578        );
2579    }
2580
2581    #[test]
2582    fn test_md060_column_align_with_aligned_no_space() {
2583        // column-align should work with aligned-no-space style
2584        let config = MD060Config {
2585            enabled: true,
2586            style: "aligned-no-space".to_string(),
2587            max_width: LineLength::from_const(0),
2588            column_align: ColumnAlign::Center,
2589            column_align_header: None,
2590            column_align_body: None,
2591            loose_last_column: false,
2592            aligned_delimiter: false,
2593            pad_width: None,
2594        };
2595        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2596
2597        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2599
2600        let fixed = rule.fix(&ctx).unwrap();
2601        let lines: Vec<&str> = fixed.lines().collect();
2602
2603        // Delimiter row should have no spaces (aligned-no-space)
2604        assert!(
2605            lines[1].contains("|---"),
2606            "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2607            lines[1]
2608        );
2609        // Content should still be centered
2610        assert!(
2611            lines[3].contains("|  Bob  |"),
2612            "Content should be centered. Got: {}",
2613            lines[3]
2614        );
2615    }
2616
2617    #[test]
2618    fn test_md060_column_align_config_parsing() {
2619        // Test that column-align config is correctly parsed
2620        let toml_str = r#"
2621enabled = true
2622style = "aligned"
2623column-align = "center"
2624"#;
2625        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2626        assert_eq!(config.column_align, ColumnAlign::Center);
2627
2628        let toml_str = r#"
2629enabled = true
2630style = "aligned"
2631column-align = "right"
2632"#;
2633        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2634        assert_eq!(config.column_align, ColumnAlign::Right);
2635
2636        let toml_str = r#"
2637enabled = true
2638style = "aligned"
2639column-align = "left"
2640"#;
2641        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2642        assert_eq!(config.column_align, ColumnAlign::Left);
2643
2644        let toml_str = r#"
2645enabled = true
2646style = "aligned"
2647column-align = "auto"
2648"#;
2649        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2650        assert_eq!(config.column_align, ColumnAlign::Auto);
2651    }
2652
2653    #[test]
2654    fn test_md060_column_align_default_is_auto() {
2655        // Without column-align specified, default should be Auto
2656        let toml_str = r#"
2657enabled = true
2658style = "aligned"
2659"#;
2660        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2661        assert_eq!(config.column_align, ColumnAlign::Auto);
2662    }
2663
2664    #[test]
2665    fn test_md060_column_align_reformats_already_aligned_table() {
2666        // A table that is already aligned (left) should be reformatted when column-align=right
2667        let config = MD060Config {
2668            enabled: true,
2669            style: "aligned".to_string(),
2670            max_width: LineLength::from_const(0),
2671            column_align: ColumnAlign::Right,
2672            column_align_header: None,
2673            column_align_body: None,
2674            loose_last_column: false,
2675            aligned_delimiter: false,
2676            pad_width: None,
2677        };
2678        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2679
2680        // This table is already properly aligned with left alignment
2681        let content = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |\n| Bob   | 25  |";
2682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2683
2684        let fixed = rule.fix(&ctx).unwrap();
2685        let lines: Vec<&str> = fixed.lines().collect();
2686
2687        // Should be reformatted with right alignment
2688        assert!(
2689            lines[2].contains("| Alice |") && lines[2].contains("|  30 |"),
2690            "Already aligned table should be reformatted with right alignment. Got: {}",
2691            lines[2]
2692        );
2693        assert!(
2694            lines[3].contains("|   Bob |") || lines[3].contains("|  Bob |"),
2695            "Bob should be right-aligned. Got: {}",
2696            lines[3]
2697        );
2698    }
2699
2700    #[test]
2701    fn test_md060_column_align_with_cjk_characters() {
2702        // CJK characters have double display width - centering should account for this
2703        let config = MD060Config {
2704            enabled: true,
2705            style: "aligned".to_string(),
2706            max_width: LineLength::from_const(0),
2707            column_align: ColumnAlign::Center,
2708            column_align_header: None,
2709            column_align_body: None,
2710            loose_last_column: false,
2711            aligned_delimiter: false,
2712            pad_width: None,
2713        };
2714        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2715
2716        let content = "| Name | City |\n|---|---|\n| Alice | 東京 |\n| Bob | LA |";
2717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2718
2719        let fixed = rule.fix(&ctx).unwrap();
2720
2721        // Both Alice and Bob should be centered, and 東京 should be properly aligned
2722        // considering its double-width display
2723        assert!(fixed.contains("Bob"), "Table should contain Bob");
2724        assert!(fixed.contains("東京"), "Table should contain 東京");
2725    }
2726
2727    #[test]
2728    fn test_md060_column_align_ignored_for_compact_style() {
2729        // column-align should have no effect on compact style (minimal padding)
2730        let config = MD060Config {
2731            enabled: true,
2732            style: "compact".to_string(),
2733            max_width: LineLength::from_const(0),
2734            column_align: ColumnAlign::Right, // This should be ignored
2735            column_align_header: None,
2736            column_align_body: None,
2737            loose_last_column: false,
2738            aligned_delimiter: false,
2739            pad_width: None,
2740        };
2741        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2742
2743        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2745
2746        let fixed = rule.fix(&ctx).unwrap();
2747
2748        // Compact style: single space padding, no alignment
2749        assert!(
2750            fixed.contains("| Alice |"),
2751            "Compact style should have single space padding, not alignment. Got: {fixed}"
2752        );
2753    }
2754
2755    #[test]
2756    fn test_md060_column_align_ignored_for_tight_style() {
2757        // column-align should have no effect on tight style (no padding)
2758        let config = MD060Config {
2759            enabled: true,
2760            style: "tight".to_string(),
2761            max_width: LineLength::from_const(0),
2762            column_align: ColumnAlign::Center, // This should be ignored
2763            column_align_header: None,
2764            column_align_body: None,
2765            loose_last_column: false,
2766            aligned_delimiter: false,
2767            pad_width: None,
2768        };
2769        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2770
2771        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2773
2774        let fixed = rule.fix(&ctx).unwrap();
2775
2776        // Tight style: no spaces at all
2777        assert!(
2778            fixed.contains("|Alice|"),
2779            "Tight style should have no spaces. Got: {fixed}"
2780        );
2781    }
2782
2783    #[test]
2784    fn test_md060_column_align_with_empty_cells() {
2785        // Empty cells should be handled correctly with centering
2786        let config = MD060Config {
2787            enabled: true,
2788            style: "aligned".to_string(),
2789            max_width: LineLength::from_const(0),
2790            column_align: ColumnAlign::Center,
2791            column_align_header: None,
2792            column_align_body: None,
2793            loose_last_column: false,
2794            aligned_delimiter: false,
2795            pad_width: None,
2796        };
2797        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2798
2799        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n|  | 25 |";
2800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2801
2802        let fixed = rule.fix(&ctx).unwrap();
2803        let lines: Vec<&str> = fixed.lines().collect();
2804
2805        // Empty cell should have all padding (centered empty string)
2806        assert!(
2807            lines[3].contains("|       |") || lines[3].contains("|      |"),
2808            "Empty cell should be padded correctly. Got: {}",
2809            lines[3]
2810        );
2811    }
2812
2813    #[test]
2814    fn test_md060_column_align_auto_preserves_already_aligned() {
2815        // With column-align=auto (default), already aligned tables should be preserved
2816        let config = MD060Config {
2817            enabled: true,
2818            style: "aligned".to_string(),
2819            max_width: LineLength::from_const(0),
2820            column_align: ColumnAlign::Auto,
2821            column_align_header: None,
2822            column_align_body: None,
2823            loose_last_column: false,
2824            aligned_delimiter: false,
2825            pad_width: None,
2826        };
2827        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2828
2829        // This table is already properly aligned
2830        let content = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |\n| Bob   | 25  |";
2831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2832
2833        let fixed = rule.fix(&ctx).unwrap();
2834
2835        // Should be preserved as-is
2836        assert_eq!(
2837            fixed, content,
2838            "Already aligned table should be preserved with column-align=auto"
2839        );
2840    }
2841
2842    #[test]
2843    fn test_cjk_table_display_aligned_not_flagged() {
2844        // Verify that alignment detection uses display width (.width()), not byte
2845        // length (.len()). CJK chars are 3 bytes but 2 display columns, so a
2846        // visually aligned table must not be flagged as misaligned.
2847        use crate::config::MarkdownFlavor;
2848
2849        // This table is display-aligned: "Hello " and "你好  " are both 6 display columns wide
2850        let table_lines: Vec<&str> = vec![
2851            "| Header | Name |",
2852            "| ------ | ---- |",
2853            "| Hello  | Test |",
2854            "| 你好   | Test |",
2855        ];
2856
2857        let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false, None);
2858        assert!(
2859            result,
2860            "Table with CJK characters that is display-aligned should be recognized as aligned"
2861        );
2862    }
2863
2864    #[test]
2865    fn test_cjk_table_not_reformatted_when_aligned() {
2866        // End-to-end test: a display-aligned CJK table should not trigger MD060
2867        let rule = MD060TableFormat::new(true, "aligned".to_string());
2868        // Build a table that is already correctly aligned (display-width)
2869        let content = "| Header | Name |\n| ------ | ---- |\n| Hello  | Test |\n| 你好   | Test |\n";
2870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2871
2872        // If the table is display-aligned, MD060 should preserve it as-is
2873        let fixed = rule.fix(&ctx).unwrap();
2874        assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2875    }
2876
2877    // === Pandoc construct reachability tests ===
2878    //
2879    // These tests document that MD060 does not flag Pandoc-specific constructs
2880    // because `ctx.table_blocks` excludes them at the source:
2881    //
2882    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
2883    //   returns false and no `TableBlock` is created.
2884    // - Multi-line table separators have no `|`, same exclusion.
2885    // - Line blocks (`| First line`) end without `|`; `is_potential_table_row`
2886    //   requires `valid_parts >= 2` for non-outer-piped lines (only 1 found).
2887    // - Pipe-table captions (`: caption`) have no `|` — excluded.
2888    //
2889    // No production guard is needed. If `find_table_blocks` ever changes to
2890    // include these constructs, these tests will surface that.
2891
2892    #[test]
2893    fn md060_pandoc_grid_tables_not_flagged() {
2894        let rule = MD060TableFormat::new(true, "aligned".to_string());
2895        let content = "\
2896+---+---+
2897| a | b |
2898+===+===+
2899| 1 | 2 |
2900+---+---+
2901";
2902        // Grid table delimiters (`+===+===+`) contain no `|`, so `is_delimiter_row`
2903        // returns false and no TableBlock is created — MD060 has nothing to check.
2904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2905        let result = rule.check(&ctx).unwrap();
2906        assert!(
2907            result.is_empty(),
2908            "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2909        );
2910
2911        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2912        let result_std = rule.check(&ctx_std).unwrap();
2913        assert!(
2914            result_std.is_empty(),
2915            "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2916        );
2917    }
2918
2919    #[test]
2920    fn md060_pandoc_multi_line_tables_not_flagged() {
2921        let rule = MD060TableFormat::new(true, "aligned".to_string());
2922        let content = "\
2923--------- -----------
2924Header 1   Header 2
2925--------- -----------
2926Cell 1     Cell 2
2927--------- -----------
2928";
2929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2930        let result = rule.check(&ctx).unwrap();
2931        assert!(
2932            result.is_empty(),
2933            "MD060 should not flag Pandoc multi-line tables: {result:?}"
2934        );
2935
2936        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2937        let result_std = rule.check(&ctx_std).unwrap();
2938        assert!(
2939            result_std.is_empty(),
2940            "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2941        );
2942    }
2943
2944    #[test]
2945    fn md060_pandoc_line_blocks_not_flagged() {
2946        let rule = MD060TableFormat::new(true, "aligned".to_string());
2947        // Pandoc line blocks start with `|` but do not end with `|`.
2948        let content = "| First line\n| Second line\n";
2949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2950        let result = rule.check(&ctx).unwrap();
2951        assert!(
2952            result.is_empty(),
2953            "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2954        );
2955
2956        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2957        let result_std = rule.check(&ctx_std).unwrap();
2958        assert!(
2959            result_std.is_empty(),
2960            "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2961        );
2962    }
2963
2964    #[test]
2965    fn md060_pandoc_pipe_table_captions_not_flagged() {
2966        let rule = MD060TableFormat::new(true, "aligned".to_string());
2967        // Pipe-table captions (`: caption`) have no `|` and are excluded from table_blocks.
2968        // Use a fully aligned table so that MD060 does not flag the pipe rows themselves.
2969        let content = "\
2970| H1 | H2 |
2971| -- | -- |
2972| a  | b  |
2973
2974: My table caption
2975";
2976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2977        let result = rule.check(&ctx).unwrap();
2978        assert!(
2979            result.is_empty(),
2980            "MD060 should not flag the pipe-table caption line: {result:?}"
2981        );
2982
2983        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2984        let result_std = rule.check(&ctx_std).unwrap();
2985        assert!(
2986            result_std.is_empty(),
2987            "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2988        );
2989    }
2990
2991    #[test]
2992    fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2993        // Regression: the fix reconstructed content via raw_lines().join("\n"),
2994        // which dropped trailing blank lines one per pass (non-idempotent) and
2995        // altered documents with no tables at all.
2996        let rule = MD060TableFormat::new(true, "aligned".to_string());
2997
2998        // No table: content must be returned byte-for-byte unchanged.
2999        for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
3000            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3001            assert_eq!(
3002                rule.fix(&ctx).unwrap(),
3003                input,
3004                "MD060 must not alter table-free content: {input:?}"
3005            );
3006        }
3007
3008        // Table followed by trailing blank lines: the table is formatted but the
3009        // trailing blanks survive, and a second pass is a no-op.
3010        let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
3011        let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
3012        let once = rule.fix(&ctx).unwrap();
3013        assert!(
3014            once.ends_with("\n\n\n"),
3015            "trailing blank lines must be preserved, got: {once:?}"
3016        );
3017        let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3018        let twice = rule.fix(&ctx2).unwrap();
3019        assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
3020    }
3021
3022    /// Indent every line of `block` by `indent` spaces.
3023    fn indent_block(block: &str, indent: usize) -> String {
3024        let spaces = " ".repeat(indent);
3025        block.lines().fold(String::new(), |mut indented, line| {
3026            indented.push_str(&spaces);
3027            indented.push_str(line);
3028            indented.push('\n');
3029            indented
3030        })
3031    }
3032
3033    #[test]
3034    fn test_mdg_table_indent_stays_within_enclosing_list_item() {
3035        // Two spaces is Gherkin's canonical table indent, but a table dedented
3036        // to it falls below the content column of an ordered or nested item and
3037        // is no longer part of that item, so the deeper column wins while it is
3038        // still one Gherkin reads as a table.
3039        let rule = MD060TableFormat::new(true, "aligned".to_string());
3040        let written = "| name | price |\n|---|---|\n| a | b |\n";
3041        let formatted = "| name | price |\n| ---- | ----- |\n| a    | b     |\n";
3042
3043        // (lines opening the item, indent as written, indent after the fix)
3044        let cases = [
3045            ("* Given", 2, 2),
3046            ("* Given", 5, 2),
3047            ("1. Given", 3, 3),
3048            ("1. Given", 5, 3),
3049            ("* Given\n  * Nested", 4, 4),
3050            ("* Given\n  * Nested", 5, 4),
3051        ];
3052
3053        for (item, indent, expected_indent) in cases {
3054            let input = format!("# Feature: F\n\n{item}\n\n{}", indent_block(written, indent));
3055            let expected = format!("# Feature: F\n\n{item}\n\n{}", indent_block(formatted, expected_indent));
3056            let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::MDG, None);
3057
3058            assert_eq!(
3059                rule.fix(&ctx).unwrap(),
3060                expected,
3061                "a {indent}-space table under {item:?} belongs at {expected_indent} spaces"
3062            );
3063
3064            let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDG, None);
3065            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3066            assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected, "MD060 fix must be idempotent");
3067        }
3068    }
3069
3070    #[test]
3071    fn test_mdg_table_below_a_deep_list_item_keeps_list_indentation() {
3072        // The item's content column is past the 5 whitespace characters Gherkin
3073        // reads as a table, so no indentation both keeps the rows in the item
3074        // and keeps them a Gherkin table: format them as ordinary Markdown.
3075        let rule = MD060TableFormat::new(true, "aligned".to_string());
3076        let input = "# Feature: F\n\n* A\n  * B\n    * C\n\n      | name | price |\n      |---|---|\n      | a | b |\n";
3077        let expected = "# Feature: F\n\n* A\n  * B\n    * C\n\n      | name | price |\n      | ---- | ----- |\n      | a    | b     |\n";
3078
3079        let mdg = rule
3080            .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
3081            .unwrap();
3082        assert_eq!(mdg, expected);
3083        assert_eq!(
3084            mdg,
3085            rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3086                .unwrap()
3087        );
3088    }
3089
3090    #[test]
3091    fn test_mdg_tab_indented_table_is_normalized_not_flattened() {
3092        // Gherkin reads a table indent as 2-5 whitespace characters, tabs
3093        // included, so tabbed rows are normalized like spaced ones. Flattening
3094        // them to column 0 would drop the table from the Gherkin document.
3095        let rule = MD060TableFormat::new(true, "aligned".to_string());
3096        let input = "# Feature: F\n\n#### Examples:\n\n\t\t| a | bb |\n\t\t|---|---|\n\t\t| 1 | 2 |\n";
3097        let expected = "# Feature: F\n\n#### Examples:\n\n  | a   | bb  |\n  | --- | --- |\n  | 1   | 2   |\n";
3098
3099        let ctx = LintContext::new(input, crate::config::MarkdownFlavor::MDG, None);
3100        assert_eq!(rule.fix(&ctx).unwrap(), expected);
3101
3102        let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::MDG, None);
3103        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3104
3105        let standard = rule
3106            .fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3107            .unwrap();
3108        assert_eq!(
3109            standard, input,
3110            "tab-indented rows are indented code outside MDG and stay untouched"
3111        );
3112    }
3113
3114    #[test]
3115    fn test_mdg_table_row_needs_a_pipe_behind_its_indent() {
3116        // Gherkin reads a table row as 2-5 whitespace characters followed by a
3117        // pipe, so a blockquote or list marker in that position makes the rows
3118        // ordinary Markdown: re-indenting them would corrupt the enclosing
3119        // construct instead of preserving a Gherkin table.
3120        let rule = MD060TableFormat::new(true, "aligned".to_string());
3121
3122        for input in [
3123            "# Feature: F\n\n  > | a | b |\n  > |---|---|\n  > | 1 | 2 |\n",
3124            "# Feature: F\n\n  - | a | b |\n    |---|---|\n    | 1 | 2 |\n",
3125        ] {
3126            let mdg = rule
3127                .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
3128                .unwrap();
3129            assert_eq!(
3130                mdg,
3131                rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3132                    .unwrap(),
3133                "{input:?} is not a Gherkin table, so both flavors must agree"
3134            );
3135
3136            let fixed_ctx = LintContext::new(&mdg, crate::config::MarkdownFlavor::MDG, None);
3137            assert_eq!(rule.fix(&fixed_ctx).unwrap(), mdg, "MD060 fix must converge");
3138        }
3139    }
3140}