Skip to main content

rumdl_lib/rules/md060_table_format/
mod.rs

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