Skip to main content

rumdl_lib/rules/md060_table_format/
mod.rs

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