Skip to main content

rumdl_lib/rules/md060_table_format/
mod.rs

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