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::table_utils::TableUtils;
4use unicode_width::UnicodeWidthStr;
5
6mod md060_config;
7use crate::md013_line_length::MD013Config;
8use md060_config::MD060Config;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11enum ColumnAlignment {
12    Left,
13    Center,
14    Right,
15}
16
17#[derive(Debug, Clone)]
18struct TableFormatResult {
19    lines: Vec<String>,
20    auto_compacted: bool,
21    aligned_width: Option<usize>,
22}
23
24/// Rule MD060: Table Column Alignment
25///
26/// See [docs/md060.md](../../docs/md060.md) for full documentation, configuration, and examples.
27///
28/// This rule enforces consistent column alignment in Markdown tables for improved readability
29/// in source form. When enabled, it ensures table columns are properly aligned with appropriate
30/// padding.
31///
32/// ## Purpose
33///
34/// - **Readability**: Aligned tables are significantly easier to read in source form
35/// - **Maintainability**: Properly formatted tables are easier to edit and review
36/// - **Consistency**: Ensures uniform table formatting throughout documents
37/// - **Developer Experience**: Makes working with tables in plain text more pleasant
38///
39/// ## Configuration Options
40///
41/// The rule supports the following configuration options:
42///
43/// ```toml
44/// [MD013]
45/// line-length = 100  # MD060 inherits this by default
46///
47/// [MD060]
48/// enabled = false      # Default: opt-in for conservative adoption
49/// style = "aligned"    # Can be "aligned", "compact", "tight", or "any"
50/// max-width = 0        # Default: inherit from MD013's line-length
51/// ```
52///
53/// ### Style Options
54///
55/// - **aligned**: Columns are padded with spaces for visual alignment (default)
56/// - **compact**: Minimal spacing with single spaces
57/// - **tight**: No spacing, pipes directly adjacent to content
58/// - **any**: Preserve existing formatting style
59///
60/// ### Max Width (auto-compact threshold)
61///
62/// Controls when tables automatically switch from aligned to compact formatting:
63///
64/// - **`max-width = 0`** (default): Smart inheritance from MD013
65/// - **`max-width = N`**: Explicit threshold, independent of MD013
66///
67/// When `max-width = 0`:
68/// - If MD013 is disabled β†’ unlimited (no auto-compact)
69/// - If MD013.tables = false β†’ unlimited (no auto-compact)
70/// - If MD013.line_length = 0 β†’ unlimited (no auto-compact)
71/// - Otherwise β†’ inherits MD013's line-length
72///
73/// This matches the behavior of Prettier's table formatting.
74///
75/// #### Examples
76///
77/// ```toml
78/// # Inherit from MD013 (recommended)
79/// [MD013]
80/// line-length = 100
81///
82/// [MD060]
83/// style = "aligned"
84/// max-width = 0  # Tables exceeding 100 chars will be compacted
85/// ```
86///
87/// ```toml
88/// # Explicit threshold
89/// [MD060]
90/// style = "aligned"
91/// max-width = 120  # Independent of MD013
92/// ```
93///
94/// ## Examples
95///
96/// ### Aligned Style (Good)
97///
98/// ```markdown
99/// | Name  | Age | City      |
100/// |-------|-----|-----------|
101/// | Alice | 30  | Seattle   |
102/// | Bob   | 25  | Portland  |
103/// ```
104///
105/// ### Unaligned (Bad)
106///
107/// ```markdown
108/// | Name | Age | City |
109/// |---|---|---|
110/// | Alice | 30 | Seattle |
111/// | Bob | 25 | Portland |
112/// ```
113///
114/// ## Unicode Support
115///
116/// This rule properly handles:
117/// - **CJK Characters**: Chinese, Japanese, Korean characters are correctly measured as double-width
118/// - **Basic Emoji**: Most emoji are handled correctly
119/// - **Inline Code**: Pipes in inline code blocks are properly masked
120///
121/// ## Known Limitations
122///
123/// **Complex Unicode Sequences**: Tables containing certain Unicode characters are automatically
124/// skipped to prevent alignment corruption. These include:
125/// - Zero-Width Joiner (ZWJ) emoji: πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦, πŸ‘©β€πŸ’»
126/// - Zero-Width Space (ZWS): Invisible word break opportunities
127/// - Zero-Width Non-Joiner (ZWNJ): Ligature prevention marks
128/// - Word Joiner (WJ): Non-breaking invisible characters
129///
130/// These characters have inconsistent or zero display widths across terminals and fonts,
131/// making accurate alignment impossible. The rule preserves these tables as-is rather than
132/// risk corrupting them.
133///
134/// This is an honest limitation of terminal display technology, similar to what other tools
135/// like markdownlint experience.
136///
137/// ## Fix Behavior
138///
139/// When applying automatic fixes, this rule:
140/// - Calculates proper display width for each column using Unicode width measurements
141/// - Pads cells with trailing spaces to align columns
142/// - Preserves cell content exactly (only spacing is modified)
143/// - Respects alignment indicators in delimiter rows (`:---`, `:---:`, `---:`)
144/// - Automatically switches to compact mode for tables exceeding max_width
145/// - Skips tables with ZWJ emoji to prevent corruption
146#[derive(Debug, Clone, Default)]
147pub struct MD060TableFormat {
148    config: MD060Config,
149    md013_config: MD013Config,
150    md013_disabled: bool,
151}
152
153impl MD060TableFormat {
154    pub fn new(enabled: bool, style: String) -> Self {
155        use crate::types::LineLength;
156        Self {
157            config: MD060Config {
158                enabled,
159                style,
160                max_width: LineLength::from_const(0),
161            },
162            md013_config: MD013Config::default(),
163            md013_disabled: false,
164        }
165    }
166
167    pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
168        Self {
169            config,
170            md013_config,
171            md013_disabled,
172        }
173    }
174
175    /// Get the effective max width for table formatting.
176    ///
177    /// Priority order:
178    /// 1. Explicit `max_width > 0` always takes precedence
179    /// 2. When `max_width = 0` (inherit mode), check MD013 configuration:
180    ///    - If MD013 is globally disabled β†’ unlimited
181    ///    - If `MD013.tables = false` β†’ unlimited
182    ///    - If `MD013.line_length = 0` β†’ unlimited
183    ///    - Otherwise β†’ inherit MD013's line_length
184    fn effective_max_width(&self) -> usize {
185        // Explicit max_width always takes precedence
186        if !self.config.max_width.is_unlimited() {
187            return self.config.max_width.get();
188        }
189
190        // max_width = 0 means "inherit" - but inherit UNLIMITED if:
191        // 1. MD013 is globally disabled
192        // 2. MD013.tables = false (user doesn't care about table line length)
193        // 3. MD013.line_length = 0 (no line length limit at all)
194        if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
195            return usize::MAX; // Unlimited
196        }
197
198        // Otherwise inherit MD013's line-length
199        self.md013_config.line_length.get()
200    }
201
202    /// Check if text contains characters that break Unicode width calculations
203    ///
204    /// Tables with these characters are skipped to avoid alignment corruption:
205    /// - Zero-Width Joiner (ZWJ, U+200D): Complex emoji like πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
206    /// - Zero-Width Space (ZWS, U+200B): Invisible word break opportunity
207    /// - Zero-Width Non-Joiner (ZWNJ, U+200C): Prevents ligature formation
208    /// - Word Joiner (WJ, U+2060): Prevents line breaks without taking space
209    ///
210    /// These characters have inconsistent display widths across terminals,
211    /// making accurate alignment impossible.
212    fn contains_problematic_chars(text: &str) -> bool {
213        text.contains('\u{200D}')  // ZWJ
214            || text.contains('\u{200B}')  // ZWS
215            || text.contains('\u{200C}')  // ZWNJ
216            || text.contains('\u{2060}') // Word Joiner
217    }
218
219    fn calculate_cell_display_width(cell_content: &str) -> usize {
220        let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
221        masked.trim().width()
222    }
223
224    /// Parse a table row into cells using Standard flavor (default behavior).
225    /// Used for tests and backward compatibility.
226    #[cfg(test)]
227    fn parse_table_row(line: &str) -> Vec<String> {
228        TableUtils::split_table_row(line)
229    }
230
231    /// Parse a table row into cells, respecting flavor-specific behavior.
232    ///
233    /// For MkDocs flavor, pipes inside inline code are NOT cell delimiters.
234    /// For Standard/GFM flavor, all pipes (except escaped) are cell delimiters.
235    fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
236        TableUtils::split_table_row_with_flavor(line, flavor)
237    }
238
239    fn is_delimiter_row(row: &[String]) -> bool {
240        if row.is_empty() {
241            return false;
242        }
243        row.iter().all(|cell| {
244            let trimmed = cell.trim();
245            // A delimiter cell must contain at least one dash
246            // Empty cells are not delimiter cells
247            !trimmed.is_empty()
248                && trimmed.contains('-')
249                && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
250        })
251    }
252
253    fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
254        delimiter_row
255            .iter()
256            .map(|cell| {
257                let trimmed = cell.trim();
258                let has_left_colon = trimmed.starts_with(':');
259                let has_right_colon = trimmed.ends_with(':');
260
261                match (has_left_colon, has_right_colon) {
262                    (true, true) => ColumnAlignment::Center,
263                    (false, true) => ColumnAlignment::Right,
264                    _ => ColumnAlignment::Left,
265                }
266            })
267            .collect()
268    }
269
270    fn calculate_column_widths(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Vec<usize> {
271        let mut column_widths = Vec::new();
272        let mut delimiter_cells: Option<Vec<String>> = None;
273
274        for line in table_lines {
275            let cells = Self::parse_table_row_with_flavor(line, flavor);
276
277            // Save delimiter row for later processing, but don't use it for width calculation
278            if Self::is_delimiter_row(&cells) {
279                delimiter_cells = Some(cells);
280                continue;
281            }
282
283            for (i, cell) in cells.iter().enumerate() {
284                let width = Self::calculate_cell_display_width(cell);
285                if i >= column_widths.len() {
286                    column_widths.push(width);
287                } else {
288                    column_widths[i] = column_widths[i].max(width);
289                }
290            }
291        }
292
293        // GFM requires delimiter rows to have at least 3 dashes per column.
294        // To ensure visual alignment, all columns must be at least width 3.
295        let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
296
297        // Adjust column widths to accommodate alignment indicators (colons) in delimiter row
298        // This ensures the delimiter row has the same length as content rows
299        if let Some(delimiter_cells) = delimiter_cells {
300            for (i, cell) in delimiter_cells.iter().enumerate() {
301                if i < final_widths.len() {
302                    let trimmed = cell.trim();
303                    let has_left_colon = trimmed.starts_with(':');
304                    let has_right_colon = trimmed.ends_with(':');
305                    let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
306
307                    // Minimum width needed: 3 dashes + colons
308                    let min_width_for_delimiter = 3 + colon_count;
309                    final_widths[i] = final_widths[i].max(min_width_for_delimiter);
310                }
311            }
312        }
313
314        final_widths
315    }
316
317    fn format_table_row(
318        cells: &[String],
319        column_widths: &[usize],
320        column_alignments: &[ColumnAlignment],
321        is_delimiter: bool,
322    ) -> String {
323        let formatted_cells: Vec<String> = cells
324            .iter()
325            .enumerate()
326            .map(|(i, cell)| {
327                let target_width = column_widths.get(i).copied().unwrap_or(0);
328                if is_delimiter {
329                    let trimmed = cell.trim();
330                    let has_left_colon = trimmed.starts_with(':');
331                    let has_right_colon = trimmed.ends_with(':');
332
333                    // Delimiter rows use the same cell format as content rows: | content |
334                    // The "content" is dashes, possibly with colons for alignment
335                    let dash_count = if has_left_colon && has_right_colon {
336                        target_width.saturating_sub(2)
337                    } else if has_left_colon || has_right_colon {
338                        target_width.saturating_sub(1)
339                    } else {
340                        target_width
341                    };
342
343                    let dashes = "-".repeat(dash_count.max(3)); // Minimum 3 dashes
344                    let delimiter_content = if has_left_colon && has_right_colon {
345                        format!(":{dashes}:")
346                    } else if has_left_colon {
347                        format!(":{dashes}")
348                    } else if has_right_colon {
349                        format!("{dashes}:")
350                    } else {
351                        dashes
352                    };
353
354                    // Add spaces around delimiter content, just like content cells
355                    format!(" {delimiter_content} ")
356                } else {
357                    let trimmed = cell.trim();
358                    let current_width = Self::calculate_cell_display_width(cell);
359                    let padding = target_width.saturating_sub(current_width);
360
361                    // Apply alignment based on column's alignment indicator
362                    let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
363                    match alignment {
364                        ColumnAlignment::Left => {
365                            // Left: content on left, padding on right
366                            format!(" {trimmed}{} ", " ".repeat(padding))
367                        }
368                        ColumnAlignment::Center => {
369                            // Center: split padding on both sides
370                            let left_padding = padding / 2;
371                            let right_padding = padding - left_padding;
372                            format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
373                        }
374                        ColumnAlignment::Right => {
375                            // Right: padding on left, content on right
376                            format!(" {}{trimmed} ", " ".repeat(padding))
377                        }
378                    }
379                }
380            })
381            .collect();
382
383        format!("|{}|", formatted_cells.join("|"))
384    }
385
386    fn format_table_compact(cells: &[String]) -> String {
387        let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
388        format!("|{}|", formatted_cells.join("|"))
389    }
390
391    fn format_table_tight(cells: &[String]) -> String {
392        let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
393        format!("|{}|", formatted_cells.join("|"))
394    }
395
396    /// Checks if a table is already aligned with consistent column widths.
397    ///
398    /// A table is considered "already aligned" if:
399    /// 1. All rows have the same display length
400    /// 2. Each column has consistent cell width across all rows
401    /// 3. The delimiter row has valid minimum widths (at least 3 chars per cell)
402    fn is_table_already_aligned(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> bool {
403        if table_lines.len() < 2 {
404            return false;
405        }
406
407        // Check 1: All rows must have the same length
408        let first_len = table_lines[0].len();
409        if !table_lines.iter().all(|line| line.len() == first_len) {
410            return false;
411        }
412
413        // Parse all rows and check column count consistency
414        let parsed: Vec<Vec<String>> = table_lines
415            .iter()
416            .map(|line| Self::parse_table_row_with_flavor(line, flavor))
417            .collect();
418
419        if parsed.is_empty() {
420            return false;
421        }
422
423        let num_columns = parsed[0].len();
424        if !parsed.iter().all(|row| row.len() == num_columns) {
425            return false;
426        }
427
428        // Check delimiter row has valid minimum widths (3 chars: at least one dash + optional colons)
429        // Delimiter row is always at index 1
430        if let Some(delimiter_row) = parsed.get(1) {
431            if !Self::is_delimiter_row(delimiter_row) {
432                return false;
433            }
434            // Check each delimiter cell has at least one dash (minimum valid is "---" or ":--" etc)
435            for cell in delimiter_row {
436                let trimmed = cell.trim();
437                let dash_count = trimmed.chars().filter(|&c| c == '-').count();
438                if dash_count < 1 {
439                    return false;
440                }
441            }
442        }
443
444        // Check each column has consistent width across all content rows
445        for col_idx in 0..num_columns {
446            let mut widths = Vec::new();
447            for (row_idx, row) in parsed.iter().enumerate() {
448                // Skip delimiter row for content width check
449                if row_idx == 1 {
450                    continue;
451                }
452                if let Some(cell) = row.get(col_idx) {
453                    widths.push(cell.len());
454                }
455            }
456            // All content cells in this column should have the same raw width
457            if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
458                return false;
459            }
460        }
461
462        true
463    }
464
465    fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
466        if table_lines.is_empty() {
467            return None;
468        }
469
470        // Check all rows (except delimiter) to determine consistent style
471        // A table is only "tight" or "compact" if ALL rows follow that pattern
472        let mut is_tight = true;
473        let mut is_compact = true;
474
475        for line in table_lines {
476            let cells = Self::parse_table_row_with_flavor(line, flavor);
477
478            if cells.is_empty() {
479                continue;
480            }
481
482            // Skip delimiter rows when detecting style
483            if Self::is_delimiter_row(&cells) {
484                continue;
485            }
486
487            // Check if this row has no padding
488            let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
489
490            // Check if this row has exactly single-space padding
491            let row_has_single_space = cells.iter().all(|cell| {
492                let trimmed = cell.trim();
493                cell == &format!(" {trimmed} ")
494            });
495
496            // If any row doesn't match tight, the table isn't tight
497            if !row_has_no_padding {
498                is_tight = false;
499            }
500
501            // If any row doesn't match compact, the table isn't compact
502            if !row_has_single_space {
503                is_compact = false;
504            }
505
506            // Early exit: if neither tight nor compact, it must be aligned
507            if !is_tight && !is_compact {
508                return Some("aligned".to_string());
509            }
510        }
511
512        // Return the most restrictive style that matches
513        if is_tight {
514            Some("tight".to_string())
515        } else if is_compact {
516            Some("compact".to_string())
517        } else {
518            Some("aligned".to_string())
519        }
520    }
521
522    fn fix_table_block(
523        &self,
524        lines: &[&str],
525        table_block: &crate::utils::table_utils::TableBlock,
526        flavor: crate::config::MarkdownFlavor,
527    ) -> TableFormatResult {
528        let mut result = Vec::new();
529        let mut auto_compacted = false;
530        let mut aligned_width = None;
531
532        let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
533            .chain(std::iter::once(lines[table_block.delimiter_line]))
534            .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
535            .collect();
536
537        if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
538            return TableFormatResult {
539                lines: table_lines.iter().map(|s| s.to_string()).collect(),
540                auto_compacted: false,
541                aligned_width: None,
542            };
543        }
544
545        let style = self.config.style.as_str();
546
547        match style {
548            "any" => {
549                let detected_style = Self::detect_table_style(&table_lines, flavor);
550                if detected_style.is_none() {
551                    return TableFormatResult {
552                        lines: table_lines.iter().map(|s| s.to_string()).collect(),
553                        auto_compacted: false,
554                        aligned_width: None,
555                    };
556                }
557
558                let target_style = detected_style.unwrap();
559
560                // Parse column alignments from delimiter row (always at index 1)
561                let delimiter_cells = Self::parse_table_row_with_flavor(table_lines[1], flavor);
562                let column_alignments = Self::parse_column_alignments(&delimiter_cells);
563
564                for line in &table_lines {
565                    let cells = Self::parse_table_row_with_flavor(line, flavor);
566                    match target_style.as_str() {
567                        "tight" => result.push(Self::format_table_tight(&cells)),
568                        "compact" => result.push(Self::format_table_compact(&cells)),
569                        _ => {
570                            let column_widths = Self::calculate_column_widths(&table_lines, flavor);
571                            let is_delimiter = Self::is_delimiter_row(&cells);
572                            result.push(Self::format_table_row(
573                                &cells,
574                                &column_widths,
575                                &column_alignments,
576                                is_delimiter,
577                            ));
578                        }
579                    }
580                }
581            }
582            "compact" => {
583                for line in table_lines {
584                    let cells = Self::parse_table_row_with_flavor(line, flavor);
585                    result.push(Self::format_table_compact(&cells));
586                }
587            }
588            "tight" => {
589                for line in table_lines {
590                    let cells = Self::parse_table_row_with_flavor(line, flavor);
591                    result.push(Self::format_table_tight(&cells));
592                }
593            }
594            "aligned" => {
595                // If the table is already aligned with consistent column widths,
596                // preserve it as-is rather than forcing our preferred minimum widths
597                if Self::is_table_already_aligned(&table_lines, flavor) {
598                    return TableFormatResult {
599                        lines: table_lines.iter().map(|s| s.to_string()).collect(),
600                        auto_compacted: false,
601                        aligned_width: None,
602                    };
603                }
604
605                let column_widths = Self::calculate_column_widths(&table_lines, flavor);
606
607                // Calculate aligned table width: 1 (leading pipe) + num_columns * 3 (| cell |) + sum(column_widths)
608                let num_columns = column_widths.len();
609                let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
610                aligned_width = Some(calc_aligned_width);
611
612                // Auto-compact: if aligned table exceeds max width, use compact formatting instead
613                if calc_aligned_width > self.effective_max_width() {
614                    auto_compacted = true;
615                    for line in table_lines {
616                        let cells = Self::parse_table_row_with_flavor(line, flavor);
617                        result.push(Self::format_table_compact(&cells));
618                    }
619                } else {
620                    // Parse column alignments from delimiter row (always at index 1)
621                    let delimiter_cells = Self::parse_table_row_with_flavor(table_lines[1], flavor);
622                    let column_alignments = Self::parse_column_alignments(&delimiter_cells);
623
624                    for line in table_lines {
625                        let cells = Self::parse_table_row_with_flavor(line, flavor);
626                        let is_delimiter = Self::is_delimiter_row(&cells);
627                        result.push(Self::format_table_row(
628                            &cells,
629                            &column_widths,
630                            &column_alignments,
631                            is_delimiter,
632                        ));
633                    }
634                }
635            }
636            _ => {
637                return TableFormatResult {
638                    lines: table_lines.iter().map(|s| s.to_string()).collect(),
639                    auto_compacted: false,
640                    aligned_width: None,
641                };
642            }
643        }
644
645        TableFormatResult {
646            lines: result,
647            auto_compacted,
648            aligned_width,
649        }
650    }
651}
652
653impl Rule for MD060TableFormat {
654    fn name(&self) -> &'static str {
655        "MD060"
656    }
657
658    fn description(&self) -> &'static str {
659        "Table columns should be consistently aligned"
660    }
661
662    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
663        !self.config.enabled || !ctx.likely_has_tables()
664    }
665
666    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
667        if !self.config.enabled {
668            return Ok(Vec::new());
669        }
670
671        let content = ctx.content;
672        let line_index = &ctx.line_index;
673        let mut warnings = Vec::new();
674
675        let lines: Vec<&str> = content.lines().collect();
676        let table_blocks = &ctx.table_blocks;
677
678        for table_block in table_blocks {
679            let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
680
681            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
682                .chain(std::iter::once(table_block.delimiter_line))
683                .chain(table_block.content_lines.iter().copied())
684                .collect();
685
686            // Build the whole-table fix once for all warnings in this table
687            // This ensures that applying Quick Fix on any row fixes the entire table
688            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
689            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
690
691            // Build the complete fixed table content
692            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(table_line_indices.len());
693            for (i, &line_idx) in table_line_indices.iter().enumerate() {
694                let fixed_line = &format_result.lines[i];
695                // Add newline for all lines except the last if the original didn't have one
696                if line_idx < lines.len() - 1 {
697                    fixed_table_lines.push(format!("{fixed_line}\n"));
698                } else {
699                    fixed_table_lines.push(fixed_line.clone());
700                }
701            }
702            let table_replacement = fixed_table_lines.concat();
703            let table_range = line_index.multi_line_range(table_start_line, table_end_line);
704
705            for (i, &line_idx) in table_line_indices.iter().enumerate() {
706                let original = lines[line_idx];
707                let fixed = &format_result.lines[i];
708
709                if original != fixed {
710                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
711
712                    let message = if format_result.auto_compacted {
713                        if let Some(width) = format_result.aligned_width {
714                            format!(
715                                "Table too wide for aligned formatting ({} chars > max-width: {})",
716                                width,
717                                self.effective_max_width()
718                            )
719                        } else {
720                            "Table too wide for aligned formatting".to_string()
721                        }
722                    } else {
723                        "Table columns should be aligned".to_string()
724                    };
725
726                    // Each warning uses the same whole-table fix
727                    // This ensures Quick Fix on any row aligns the entire table
728                    warnings.push(LintWarning {
729                        rule_name: Some(self.name().to_string()),
730                        severity: Severity::Warning,
731                        message,
732                        line: start_line,
733                        column: start_col,
734                        end_line,
735                        end_column: end_col,
736                        fix: Some(crate::rule::Fix {
737                            range: table_range.clone(),
738                            replacement: table_replacement.clone(),
739                        }),
740                    });
741                }
742            }
743        }
744
745        Ok(warnings)
746    }
747
748    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
749        if !self.config.enabled {
750            return Ok(ctx.content.to_string());
751        }
752
753        let content = ctx.content;
754        let lines: Vec<&str> = content.lines().collect();
755        let table_blocks = &ctx.table_blocks;
756
757        let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
758
759        for table_block in table_blocks {
760            let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
761
762            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
763                .chain(std::iter::once(table_block.delimiter_line))
764                .chain(table_block.content_lines.iter().copied())
765                .collect();
766
767            for (i, &line_idx) in table_line_indices.iter().enumerate() {
768                result_lines[line_idx] = format_result.lines[i].clone();
769            }
770        }
771
772        let mut fixed = result_lines.join("\n");
773        if content.ends_with('\n') && !fixed.ends_with('\n') {
774            fixed.push('\n');
775        }
776        Ok(fixed)
777    }
778
779    fn as_any(&self) -> &dyn std::any::Any {
780        self
781    }
782
783    fn default_config_section(&self) -> Option<(String, toml::Value)> {
784        let json_value = serde_json::to_value(&self.config).ok()?;
785        Some((
786            self.name().to_string(),
787            crate::rule_config_serde::json_to_toml_value(&json_value)?,
788        ))
789    }
790
791    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
792    where
793        Self: Sized,
794    {
795        let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
796        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
797
798        // Check if MD013 is globally disabled
799        let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
800
801        Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::lint_context::LintContext;
809    use crate::types::LineLength;
810
811    /// Helper to create an MD013Config with a specific line length for testing
812    fn md013_with_line_length(line_length: usize) -> MD013Config {
813        MD013Config {
814            line_length: LineLength::from_const(line_length),
815            tables: true, // Default: tables are checked
816            ..Default::default()
817        }
818    }
819
820    #[test]
821    fn test_md060_disabled_by_default() {
822        let rule = MD060TableFormat::default();
823        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825
826        let warnings = rule.check(&ctx).unwrap();
827        assert_eq!(warnings.len(), 0);
828
829        let fixed = rule.fix(&ctx).unwrap();
830        assert_eq!(fixed, content);
831    }
832
833    #[test]
834    fn test_md060_align_simple_ascii_table() {
835        let rule = MD060TableFormat::new(true, "aligned".to_string());
836
837        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839
840        let fixed = rule.fix(&ctx).unwrap();
841        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
842        assert_eq!(fixed, expected);
843
844        // Verify all rows have equal length in aligned mode
845        let lines: Vec<&str> = fixed.lines().collect();
846        assert_eq!(lines[0].len(), lines[1].len());
847        assert_eq!(lines[1].len(), lines[2].len());
848    }
849
850    #[test]
851    fn test_md060_cjk_characters_aligned_correctly() {
852        let rule = MD060TableFormat::new(true, "aligned".to_string());
853
854        let content = "| Name | Age |\n|---|---|\n| δΈ­ζ–‡ | 30 |";
855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856
857        let fixed = rule.fix(&ctx).unwrap();
858
859        let lines: Vec<&str> = fixed.lines().collect();
860        let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
861        let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
862
863        let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
864        let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
865
866        assert_eq!(width1, width3);
867    }
868
869    #[test]
870    fn test_md060_basic_emoji() {
871        let rule = MD060TableFormat::new(true, "aligned".to_string());
872
873        let content = "| Status | Name |\n|---|---|\n| βœ… | Test |";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875
876        let fixed = rule.fix(&ctx).unwrap();
877        assert!(fixed.contains("Status"));
878    }
879
880    #[test]
881    fn test_md060_zwj_emoji_skipped() {
882        let rule = MD060TableFormat::new(true, "aligned".to_string());
883
884        let content = "| Emoji | Name |\n|---|---|\n| πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ | Family |";
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886
887        let fixed = rule.fix(&ctx).unwrap();
888        assert_eq!(fixed, content);
889    }
890
891    #[test]
892    fn test_md060_inline_code_with_escaped_pipes() {
893        // In GFM tables, bare pipes in inline code STILL act as cell delimiters.
894        // To include a literal pipe in table content (even in code), escape it with \|
895        let rule = MD060TableFormat::new(true, "aligned".to_string());
896
897        // CORRECT: `[0-9]\|[0-9]` - the \| is escaped, stays as content (2 columns)
898        let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900
901        let fixed = rule.fix(&ctx).unwrap();
902        assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
903    }
904
905    #[test]
906    fn test_md060_compact_style() {
907        let rule = MD060TableFormat::new(true, "compact".to_string());
908
909        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911
912        let fixed = rule.fix(&ctx).unwrap();
913        let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
914        assert_eq!(fixed, expected);
915    }
916
917    #[test]
918    fn test_md060_tight_style() {
919        let rule = MD060TableFormat::new(true, "tight".to_string());
920
921        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
922        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923
924        let fixed = rule.fix(&ctx).unwrap();
925        let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
926        assert_eq!(fixed, expected);
927    }
928
929    #[test]
930    fn test_md060_any_style_consistency() {
931        let rule = MD060TableFormat::new(true, "any".to_string());
932
933        // Table is already compact, should stay compact
934        let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936
937        let fixed = rule.fix(&ctx).unwrap();
938        assert_eq!(fixed, content);
939
940        // Table is aligned, should stay aligned
941        let content_aligned = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
942        let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
943
944        let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
945        assert_eq!(fixed_aligned, content_aligned);
946    }
947
948    #[test]
949    fn test_md060_empty_cells() {
950        let rule = MD060TableFormat::new(true, "aligned".to_string());
951
952        let content = "| A | B |\n|---|---|\n|  | X |";
953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954
955        let fixed = rule.fix(&ctx).unwrap();
956        assert!(fixed.contains("|"));
957    }
958
959    #[test]
960    fn test_md060_mixed_content() {
961        let rule = MD060TableFormat::new(true, "aligned".to_string());
962
963        let content = "| Name | Age | City |\n|---|---|---|\n| δΈ­ζ–‡ | 30 | NYC |";
964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965
966        let fixed = rule.fix(&ctx).unwrap();
967        assert!(fixed.contains("δΈ­ζ–‡"));
968        assert!(fixed.contains("NYC"));
969    }
970
971    #[test]
972    fn test_md060_preserve_alignment_indicators() {
973        let rule = MD060TableFormat::new(true, "aligned".to_string());
974
975        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977
978        let fixed = rule.fix(&ctx).unwrap();
979
980        assert!(fixed.contains(":---"), "Should contain left alignment");
981        assert!(fixed.contains(":----:"), "Should contain center alignment");
982        assert!(fixed.contains("----:"), "Should contain right alignment");
983    }
984
985    #[test]
986    fn test_md060_minimum_column_width() {
987        let rule = MD060TableFormat::new(true, "aligned".to_string());
988
989        // Test with very short column content to ensure minimum width of 3
990        // GFM requires at least 3 dashes in delimiter rows
991        let content = "| ID | Name |\n|-|-|\n| 1 | A |";
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993
994        let fixed = rule.fix(&ctx).unwrap();
995
996        let lines: Vec<&str> = fixed.lines().collect();
997        assert_eq!(lines[0].len(), lines[1].len());
998        assert_eq!(lines[1].len(), lines[2].len());
999
1000        // Verify minimum width is enforced
1001        assert!(fixed.contains("ID "), "Short content should be padded");
1002        assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1003    }
1004
1005    #[test]
1006    fn test_md060_auto_compact_exceeds_default_threshold() {
1007        // Default max_width = 0, which inherits from default MD013 line_length = 80
1008        let config = MD060Config {
1009            enabled: true,
1010            style: "aligned".to_string(),
1011            max_width: LineLength::from_const(0),
1012        };
1013        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1014
1015        // Table that would be 85 chars when aligned (exceeds 80)
1016        // Formula: 1 + (3 * 3) + (20 + 20 + 30) = 1 + 9 + 70 = 80 chars
1017        // But with actual content padding it will exceed
1018        let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1019        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020
1021        let fixed = rule.fix(&ctx).unwrap();
1022
1023        // Should use compact formatting (single spaces)
1024        assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1025        assert!(fixed.contains("| --- | --- | --- |"));
1026        assert!(fixed.contains("| Short | Data | Here |"));
1027
1028        // Verify it's compact (no extra padding)
1029        let lines: Vec<&str> = fixed.lines().collect();
1030        // In compact mode, lines can have different lengths
1031        assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1032    }
1033
1034    #[test]
1035    fn test_md060_auto_compact_exceeds_explicit_threshold() {
1036        // Explicit max_width = 50
1037        let config = MD060Config {
1038            enabled: true,
1039            style: "aligned".to_string(),
1040            max_width: LineLength::from_const(50),
1041        };
1042        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false); // MD013 setting doesn't matter
1043
1044        // Table that would exceed 50 chars when aligned
1045        // Column widths: 25 + 25 + 25 = 75 chars
1046        // Formula: 1 + (3 * 3) + 75 = 85 chars (exceeds 50)
1047        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049
1050        let fixed = rule.fix(&ctx).unwrap();
1051
1052        // Should use compact formatting (single spaces, no extra padding)
1053        assert!(
1054            fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1055        );
1056        assert!(fixed.contains("| --- | --- | --- |"));
1057        assert!(fixed.contains("| Data | Data | Data |"));
1058
1059        // Verify it's compact (lines have different lengths)
1060        let lines: Vec<&str> = fixed.lines().collect();
1061        assert!(lines[0].len() != lines[2].len());
1062    }
1063
1064    #[test]
1065    fn test_md060_stays_aligned_under_threshold() {
1066        // max_width = 100, table will be under this
1067        let config = MD060Config {
1068            enabled: true,
1069            style: "aligned".to_string(),
1070            max_width: LineLength::from_const(100),
1071        };
1072        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1073
1074        // Small table that fits well under 100 chars
1075        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1076        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077
1078        let fixed = rule.fix(&ctx).unwrap();
1079
1080        // Should use aligned formatting (all lines same length)
1081        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1082        assert_eq!(fixed, expected);
1083
1084        let lines: Vec<&str> = fixed.lines().collect();
1085        assert_eq!(lines[0].len(), lines[1].len());
1086        assert_eq!(lines[1].len(), lines[2].len());
1087    }
1088
1089    #[test]
1090    fn test_md060_width_calculation_formula() {
1091        // Verify the width calculation formula: 1 + (num_columns * 3) + sum(column_widths)
1092        let config = MD060Config {
1093            enabled: true,
1094            style: "aligned".to_string(),
1095            max_width: LineLength::from_const(0),
1096        };
1097        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1098
1099        // Create a table where we know exact column widths: 5 + 5 + 5 = 15
1100        // Expected aligned width: 1 + (3 * 3) + 15 = 1 + 9 + 15 = 25 chars
1101        // This is under 30, so should stay aligned
1102        let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104
1105        let fixed = rule.fix(&ctx).unwrap();
1106
1107        // Should be aligned
1108        let lines: Vec<&str> = fixed.lines().collect();
1109        assert_eq!(lines[0].len(), lines[1].len());
1110        assert_eq!(lines[1].len(), lines[2].len());
1111        assert_eq!(lines[0].len(), 25); // Verify formula
1112
1113        // Now test with threshold = 24 (just under aligned width)
1114        let config_tight = MD060Config {
1115            enabled: true,
1116            style: "aligned".to_string(),
1117            max_width: LineLength::from_const(24),
1118        };
1119        let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1120
1121        let fixed_compact = rule_tight.fix(&ctx).unwrap();
1122
1123        // Should be compact now (25 > 24)
1124        assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1125        assert!(fixed_compact.contains("| --- | --- | --- |"));
1126    }
1127
1128    #[test]
1129    fn test_md060_very_wide_table_auto_compacts() {
1130        let config = MD060Config {
1131            enabled: true,
1132            style: "aligned".to_string(),
1133            max_width: LineLength::from_const(0),
1134        };
1135        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1136
1137        // Very wide table with many columns
1138        // 8 columns with widths of 12 chars each = 96 chars
1139        // Formula: 1 + (8 * 3) + 96 = 121 chars (exceeds 80)
1140        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 |";
1141        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142
1143        let fixed = rule.fix(&ctx).unwrap();
1144
1145        // Should be compact (table would be way over 80 chars aligned)
1146        assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1147        assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1148    }
1149
1150    #[test]
1151    fn test_md060_inherit_from_md013_line_length() {
1152        // max_width = 0 should inherit from MD013's line_length
1153        let config = MD060Config {
1154            enabled: true,
1155            style: "aligned".to_string(),
1156            max_width: LineLength::from_const(0), // Inherit
1157        };
1158
1159        // Test with different MD013 line_length values
1160        let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1161        let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1162
1163        // Medium-sized table
1164        let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1165        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166
1167        // With 80 char limit, likely compacts
1168        let _fixed_80 = rule_80.fix(&ctx).unwrap();
1169
1170        // With 120 char limit, likely stays aligned
1171        let fixed_120 = rule_120.fix(&ctx).unwrap();
1172
1173        // Verify 120 is aligned (all lines same length)
1174        let lines_120: Vec<&str> = fixed_120.lines().collect();
1175        assert_eq!(lines_120[0].len(), lines_120[1].len());
1176        assert_eq!(lines_120[1].len(), lines_120[2].len());
1177    }
1178
1179    #[test]
1180    fn test_md060_edge_case_exactly_at_threshold() {
1181        // Create table that's exactly at the threshold
1182        // Formula: 1 + (num_columns * 3) + sum(column_widths) = max_width
1183        // For 2 columns with widths 5 and 5: 1 + 6 + 10 = 17
1184        let config = MD060Config {
1185            enabled: true,
1186            style: "aligned".to_string(),
1187            max_width: LineLength::from_const(17),
1188        };
1189        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1190
1191        let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193
1194        let fixed = rule.fix(&ctx).unwrap();
1195
1196        // At threshold (17 <= 17), should stay aligned
1197        let lines: Vec<&str> = fixed.lines().collect();
1198        assert_eq!(lines[0].len(), 17);
1199        assert_eq!(lines[0].len(), lines[1].len());
1200        assert_eq!(lines[1].len(), lines[2].len());
1201
1202        // Now test with threshold = 16 (just under)
1203        let config_under = MD060Config {
1204            enabled: true,
1205            style: "aligned".to_string(),
1206            max_width: LineLength::from_const(16),
1207        };
1208        let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1209
1210        let fixed_compact = rule_under.fix(&ctx).unwrap();
1211
1212        // Should compact (17 > 16)
1213        assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1214        assert!(fixed_compact.contains("| --- | --- |"));
1215    }
1216
1217    #[test]
1218    fn test_md060_auto_compact_warning_message() {
1219        // Verify that auto-compact generates an informative warning
1220        let config = MD060Config {
1221            enabled: true,
1222            style: "aligned".to_string(),
1223            max_width: LineLength::from_const(50),
1224        };
1225        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1226
1227        // Table that will be auto-compacted (exceeds 50 chars when aligned)
1228        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1229        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230
1231        let warnings = rule.check(&ctx).unwrap();
1232
1233        // Should generate warnings with auto-compact message
1234        assert!(!warnings.is_empty(), "Should generate warnings");
1235
1236        let auto_compact_warnings: Vec<_> = warnings
1237            .iter()
1238            .filter(|w| w.message.contains("too wide for aligned formatting"))
1239            .collect();
1240
1241        assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1242
1243        // Verify the warning message includes the width and threshold
1244        let first_warning = auto_compact_warnings[0];
1245        assert!(first_warning.message.contains("85 chars > max-width: 50"));
1246        assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1247    }
1248
1249    #[test]
1250    fn test_md060_issue_129_detect_style_from_all_rows() {
1251        // Issue #129: detect_table_style should check all rows, not just the first row
1252        // If header row has single-space padding but content rows have extra padding,
1253        // the table should be detected as "aligned" and preserved
1254        let rule = MD060TableFormat::new(true, "any".to_string());
1255
1256        // Table where header looks compact but content is aligned
1257        let content = "| a long heading | another long heading |\n\
1258                       | -------------- | -------------------- |\n\
1259                       | a              | 1                    |\n\
1260                       | b b            | 2                    |\n\
1261                       | c c c          | 3                    |";
1262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263
1264        let fixed = rule.fix(&ctx).unwrap();
1265
1266        // Should preserve the aligned formatting of content rows
1267        assert!(
1268            fixed.contains("| a              | 1                    |"),
1269            "Should preserve aligned padding in first content row"
1270        );
1271        assert!(
1272            fixed.contains("| b b            | 2                    |"),
1273            "Should preserve aligned padding in second content row"
1274        );
1275        assert!(
1276            fixed.contains("| c c c          | 3                    |"),
1277            "Should preserve aligned padding in third content row"
1278        );
1279
1280        // Entire table should remain unchanged because it's already properly aligned
1281        assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1282    }
1283
1284    #[test]
1285    fn test_md060_regular_alignment_warning_message() {
1286        // Verify that regular alignment (not auto-compact) generates normal warning
1287        let config = MD060Config {
1288            enabled: true,
1289            style: "aligned".to_string(),
1290            max_width: LineLength::from_const(100), // Large enough to not trigger auto-compact
1291        };
1292        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1293
1294        // Small misaligned table
1295        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297
1298        let warnings = rule.check(&ctx).unwrap();
1299
1300        // Should generate warnings
1301        assert!(!warnings.is_empty(), "Should generate warnings");
1302
1303        // Verify it's the standard alignment message, not auto-compact
1304        assert!(warnings[0].message.contains("Table columns should be aligned"));
1305        assert!(!warnings[0].message.contains("too wide"));
1306        assert!(!warnings[0].message.contains("max-width"));
1307    }
1308
1309    // === Issue #219: Unlimited table width tests ===
1310
1311    #[test]
1312    fn test_md060_unlimited_when_md013_disabled() {
1313        // When MD013 is globally disabled, max_width should be unlimited
1314        let config = MD060Config {
1315            enabled: true,
1316            style: "aligned".to_string(),
1317            max_width: LineLength::from_const(0), // Inherit
1318        };
1319        let md013_config = MD013Config::default();
1320        let rule = MD060TableFormat::from_config_struct(config, md013_config, true /* disabled */);
1321
1322        // Very wide table that would normally exceed 80 chars
1323        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325        let fixed = rule.fix(&ctx).unwrap();
1326
1327        // Should be aligned (not compacted) since MD013 is disabled
1328        let lines: Vec<&str> = fixed.lines().collect();
1329        // In aligned mode, all lines have the same length
1330        assert_eq!(
1331            lines[0].len(),
1332            lines[1].len(),
1333            "Table should be aligned when MD013 is disabled"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_md060_unlimited_when_md013_tables_false() {
1339        // When MD013.tables = false, max_width should be unlimited
1340        let config = MD060Config {
1341            enabled: true,
1342            style: "aligned".to_string(),
1343            max_width: LineLength::from_const(0),
1344        };
1345        let md013_config = MD013Config {
1346            tables: false, // User doesn't care about table line length
1347            line_length: LineLength::from_const(80),
1348            ..Default::default()
1349        };
1350        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1351
1352        // Wide table that would exceed 80 chars
1353        let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1354        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355        let fixed = rule.fix(&ctx).unwrap();
1356
1357        // Should be aligned (no auto-compact since tables=false)
1358        let lines: Vec<&str> = fixed.lines().collect();
1359        assert_eq!(
1360            lines[0].len(),
1361            lines[1].len(),
1362            "Table should be aligned when MD013.tables=false"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_md060_unlimited_when_md013_line_length_zero() {
1368        // When MD013.line_length = 0, max_width should be unlimited
1369        let config = MD060Config {
1370            enabled: true,
1371            style: "aligned".to_string(),
1372            max_width: LineLength::from_const(0),
1373        };
1374        let md013_config = MD013Config {
1375            tables: true,
1376            line_length: LineLength::from_const(0), // No limit
1377            ..Default::default()
1378        };
1379        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1380
1381        // Wide table
1382        let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384        let fixed = rule.fix(&ctx).unwrap();
1385
1386        // Should be aligned
1387        let lines: Vec<&str> = fixed.lines().collect();
1388        assert_eq!(
1389            lines[0].len(),
1390            lines[1].len(),
1391            "Table should be aligned when MD013.line_length=0"
1392        );
1393    }
1394
1395    #[test]
1396    fn test_md060_explicit_max_width_overrides_md013_settings() {
1397        // Explicit max_width should always take precedence
1398        let config = MD060Config {
1399            enabled: true,
1400            style: "aligned".to_string(),
1401            max_width: LineLength::from_const(50), // Explicit limit
1402        };
1403        let md013_config = MD013Config {
1404            tables: false,                          // This would make it unlimited...
1405            line_length: LineLength::from_const(0), // ...and this too
1406            ..Default::default()
1407        };
1408        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1409
1410        // Wide table that exceeds explicit 50-char limit
1411        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413        let fixed = rule.fix(&ctx).unwrap();
1414
1415        // Should be compact (explicit max_width = 50 overrides MD013 settings)
1416        assert!(
1417            fixed.contains("| --- |"),
1418            "Should be compact format due to explicit max_width"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1424        // When MD013.tables = true and MD013.line_length is set, inherit that limit
1425        let config = MD060Config {
1426            enabled: true,
1427            style: "aligned".to_string(),
1428            max_width: LineLength::from_const(0), // Inherit
1429        };
1430        let md013_config = MD013Config {
1431            tables: true,
1432            line_length: LineLength::from_const(50), // 50 char limit
1433            ..Default::default()
1434        };
1435        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1436
1437        // Wide table that exceeds 50 chars
1438        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440        let fixed = rule.fix(&ctx).unwrap();
1441
1442        // Should be compact (inherited 50-char limit from MD013)
1443        assert!(
1444            fixed.contains("| --- |"),
1445            "Should be compact format when inheriting MD013 limit"
1446        );
1447    }
1448}