Skip to main content

rumdl_lib/rules/
md060_table_format.rs

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