Skip to main content

rumdl_lib/rules/md060_table_format/
mod.rs

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