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