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