Skip to main content

rumdl_lib/rules/md060_table_format/
mod.rs

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