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