Skip to main content

rumdl_lib/rules/md060_table_format/
mod.rs

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