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