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