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 content = ctx.content;
863        let line_index = &ctx.line_index;
864        let mut warnings = Vec::new();
865
866        let lines: Vec<&str> = content.lines().collect();
867        let table_blocks = &ctx.table_blocks;
868
869        for table_block in table_blocks {
870            let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
871
872            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
873                .chain(std::iter::once(table_block.delimiter_line))
874                .chain(table_block.content_lines.iter().copied())
875                .collect();
876
877            // Build the whole-table fix once for all warnings in this table
878            // This ensures that applying Quick Fix on any row fixes the entire table
879            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
880            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
881
882            // Build the complete fixed table content
883            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(table_line_indices.len());
884            for (i, &line_idx) in table_line_indices.iter().enumerate() {
885                let fixed_line = &format_result.lines[i];
886                // Add newline for all lines except the last if the original didn't have one
887                if line_idx < lines.len() - 1 {
888                    fixed_table_lines.push(format!("{fixed_line}\n"));
889                } else {
890                    fixed_table_lines.push(fixed_line.clone());
891                }
892            }
893            let table_replacement = fixed_table_lines.concat();
894            let table_range = line_index.multi_line_range(table_start_line, table_end_line);
895
896            for (i, &line_idx) in table_line_indices.iter().enumerate() {
897                let original = lines[line_idx];
898                let fixed = &format_result.lines[i];
899
900                if original != fixed {
901                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
902
903                    let message = if format_result.auto_compacted {
904                        if let Some(width) = format_result.aligned_width {
905                            format!(
906                                "Table too wide for aligned formatting ({} chars > max-width: {})",
907                                width,
908                                self.effective_max_width()
909                            )
910                        } else {
911                            "Table too wide for aligned formatting".to_string()
912                        }
913                    } else {
914                        "Table columns should be aligned".to_string()
915                    };
916
917                    // Each warning uses the same whole-table fix
918                    // This ensures Quick Fix on any row aligns the entire table
919                    warnings.push(LintWarning {
920                        rule_name: Some(self.name().to_string()),
921                        severity: Severity::Warning,
922                        message,
923                        line: start_line,
924                        column: start_col,
925                        end_line,
926                        end_column: end_col,
927                        fix: Some(crate::rule::Fix {
928                            range: table_range.clone(),
929                            replacement: table_replacement.clone(),
930                        }),
931                    });
932                }
933            }
934        }
935
936        Ok(warnings)
937    }
938
939    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
940        if !self.config.enabled {
941            return Ok(ctx.content.to_string());
942        }
943
944        let content = ctx.content;
945        let lines: Vec<&str> = content.lines().collect();
946        let table_blocks = &ctx.table_blocks;
947
948        let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
949
950        for table_block in table_blocks {
951            let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
952
953            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
954                .chain(std::iter::once(table_block.delimiter_line))
955                .chain(table_block.content_lines.iter().copied())
956                .collect();
957
958            for (i, &line_idx) in table_line_indices.iter().enumerate() {
959                result_lines[line_idx] = format_result.lines[i].clone();
960            }
961        }
962
963        let mut fixed = result_lines.join("\n");
964        if content.ends_with('\n') && !fixed.ends_with('\n') {
965            fixed.push('\n');
966        }
967        Ok(fixed)
968    }
969
970    fn as_any(&self) -> &dyn std::any::Any {
971        self
972    }
973
974    fn default_config_section(&self) -> Option<(String, toml::Value)> {
975        // Build TOML table explicitly to include all keys, even optional ones
976        // (serde skips Option::None values, which causes config validation warnings)
977        let mut table = toml::map::Map::new();
978        table.insert("enabled".to_string(), toml::Value::Boolean(self.config.enabled));
979        table.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
980        table.insert(
981            "max-width".to_string(),
982            toml::Value::Integer(self.config.max_width.get() as i64),
983        );
984        table.insert(
985            "column-align".to_string(),
986            toml::Value::String(
987                match self.config.column_align {
988                    ColumnAlign::Auto => "auto",
989                    ColumnAlign::Left => "left",
990                    ColumnAlign::Center => "center",
991                    ColumnAlign::Right => "right",
992                }
993                .to_string(),
994            ),
995        );
996        // Include optional keys with placeholder values so they're recognized as valid
997        table.insert(
998            "column-align-header".to_string(),
999            toml::Value::String("auto".to_string()),
1000        );
1001        table.insert("column-align-body".to_string(), toml::Value::String("auto".to_string()));
1002        table.insert(
1003            "loose-last-column".to_string(),
1004            toml::Value::Boolean(self.config.loose_last_column),
1005        );
1006
1007        Some((self.name().to_string(), toml::Value::Table(table)))
1008    }
1009
1010    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1011    where
1012        Self: Sized,
1013    {
1014        let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1015        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1016
1017        // Check if MD013 is globally disabled
1018        let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1019
1020        Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1021    }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027    use crate::lint_context::LintContext;
1028    use crate::types::LineLength;
1029
1030    /// Helper to create an MD013Config with a specific line length for testing
1031    fn md013_with_line_length(line_length: usize) -> MD013Config {
1032        MD013Config {
1033            line_length: LineLength::from_const(line_length),
1034            tables: true, // Default: tables are checked
1035            ..Default::default()
1036        }
1037    }
1038
1039    #[test]
1040    fn test_md060_disabled_by_default() {
1041        let rule = MD060TableFormat::default();
1042        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044
1045        let warnings = rule.check(&ctx).unwrap();
1046        assert_eq!(warnings.len(), 0);
1047
1048        let fixed = rule.fix(&ctx).unwrap();
1049        assert_eq!(fixed, content);
1050    }
1051
1052    #[test]
1053    fn test_md060_align_simple_ascii_table() {
1054        let rule = MD060TableFormat::new(true, "aligned".to_string());
1055
1056        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1057        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058
1059        let fixed = rule.fix(&ctx).unwrap();
1060        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1061        assert_eq!(fixed, expected);
1062
1063        // Verify all rows have equal length in aligned mode
1064        let lines: Vec<&str> = fixed.lines().collect();
1065        assert_eq!(lines[0].len(), lines[1].len());
1066        assert_eq!(lines[1].len(), lines[2].len());
1067    }
1068
1069    #[test]
1070    fn test_md060_cjk_characters_aligned_correctly() {
1071        let rule = MD060TableFormat::new(true, "aligned".to_string());
1072
1073        let content = "| Name | Age |\n|---|---|\n| δΈ­ζ–‡ | 30 |";
1074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075
1076        let fixed = rule.fix(&ctx).unwrap();
1077
1078        let lines: Vec<&str> = fixed.lines().collect();
1079        let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1080        let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1081
1082        let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1083        let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1084
1085        assert_eq!(width1, width3);
1086    }
1087
1088    #[test]
1089    fn test_md060_basic_emoji() {
1090        let rule = MD060TableFormat::new(true, "aligned".to_string());
1091
1092        let content = "| Status | Name |\n|---|---|\n| βœ… | Test |";
1093        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094
1095        let fixed = rule.fix(&ctx).unwrap();
1096        assert!(fixed.contains("Status"));
1097    }
1098
1099    #[test]
1100    fn test_md060_zwj_emoji_skipped() {
1101        let rule = MD060TableFormat::new(true, "aligned".to_string());
1102
1103        let content = "| Emoji | Name |\n|---|---|\n| πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ | Family |";
1104        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105
1106        let fixed = rule.fix(&ctx).unwrap();
1107        assert_eq!(fixed, content);
1108    }
1109
1110    #[test]
1111    fn test_md060_inline_code_with_escaped_pipes() {
1112        // In GFM tables, bare pipes in inline code STILL act as cell delimiters.
1113        // To include a literal pipe in table content (even in code), escape it with \|
1114        let rule = MD060TableFormat::new(true, "aligned".to_string());
1115
1116        // CORRECT: `[0-9]\|[0-9]` - the \| is escaped, stays as content (2 columns)
1117        let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1118        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119
1120        let fixed = rule.fix(&ctx).unwrap();
1121        assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1122    }
1123
1124    #[test]
1125    fn test_md060_compact_style() {
1126        let rule = MD060TableFormat::new(true, "compact".to_string());
1127
1128        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1129        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130
1131        let fixed = rule.fix(&ctx).unwrap();
1132        let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1133        assert_eq!(fixed, expected);
1134    }
1135
1136    #[test]
1137    fn test_md060_tight_style() {
1138        let rule = MD060TableFormat::new(true, "tight".to_string());
1139
1140        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1141        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142
1143        let fixed = rule.fix(&ctx).unwrap();
1144        let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1145        assert_eq!(fixed, expected);
1146    }
1147
1148    #[test]
1149    fn test_md060_aligned_no_space_style() {
1150        // Issue #277: aligned-no-space style has no spaces in delimiter row
1151        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1152
1153        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155
1156        let fixed = rule.fix(&ctx).unwrap();
1157
1158        // Content rows have spaces, delimiter row does not
1159        let lines: Vec<&str> = fixed.lines().collect();
1160        assert_eq!(lines[0], "| Name  | Age |", "Header should have spaces around content");
1161        assert_eq!(
1162            lines[1], "|-------|-----|",
1163            "Delimiter should have NO spaces around dashes"
1164        );
1165        assert_eq!(lines[2], "| Alice | 30  |", "Content should have spaces around content");
1166
1167        // All rows should have equal length
1168        assert_eq!(lines[0].len(), lines[1].len());
1169        assert_eq!(lines[1].len(), lines[2].len());
1170    }
1171
1172    #[test]
1173    fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1174        // Alignment indicators (:) should be preserved
1175        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1176
1177        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179
1180        let fixed = rule.fix(&ctx).unwrap();
1181        let lines: Vec<&str> = fixed.lines().collect();
1182
1183        // Verify alignment indicators are preserved without spaces around them
1184        assert!(
1185            fixed.contains("|:"),
1186            "Should have left alignment indicator adjacent to pipe"
1187        );
1188        assert!(
1189            fixed.contains(":|"),
1190            "Should have right alignment indicator adjacent to pipe"
1191        );
1192        // Check for center alignment - the exact dash count depends on column width
1193        assert!(
1194            lines[1].contains(":---") && lines[1].contains("---:"),
1195            "Should have center alignment colons"
1196        );
1197    }
1198
1199    #[test]
1200    fn test_md060_aligned_no_space_three_column_table() {
1201        // Test the exact format from issue #277
1202        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1203
1204        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 |";
1205        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206
1207        let fixed = rule.fix(&ctx).unwrap();
1208        let lines: Vec<&str> = fixed.lines().collect();
1209
1210        // Verify delimiter row format: |--------------|--------------|--------------|
1211        assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1212        assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1213        assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1214        assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1215    }
1216
1217    #[test]
1218    fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1219        // Auto-compact should work with aligned-no-space when table exceeds max-width
1220        let config = MD060Config {
1221            enabled: true,
1222            style: "aligned-no-space".to_string(),
1223            max_width: LineLength::from_const(50),
1224            column_align: ColumnAlign::Auto,
1225            column_align_header: None,
1226            column_align_body: None,
1227            loose_last_column: false,
1228        };
1229        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1230
1231        // Wide table that exceeds 50 chars when aligned
1232        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1233        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234
1235        let fixed = rule.fix(&ctx).unwrap();
1236
1237        // Should auto-compact to compact style (not aligned-no-space)
1238        assert!(
1239            fixed.contains("| --- |"),
1240            "Should be compact format when exceeding max-width"
1241        );
1242    }
1243
1244    #[test]
1245    fn test_md060_aligned_no_space_cjk_characters() {
1246        // CJK characters should be handled correctly
1247        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1248
1249        let content = "| Name | City |\n|---|---|\n| δΈ­ζ–‡ | 東京 |";
1250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251
1252        let fixed = rule.fix(&ctx).unwrap();
1253        let lines: Vec<&str> = fixed.lines().collect();
1254
1255        // All rows should have equal DISPLAY width (not byte length)
1256        // CJK characters are double-width, so byte length differs from display width
1257        use unicode_width::UnicodeWidthStr;
1258        assert_eq!(
1259            lines[0].width(),
1260            lines[1].width(),
1261            "Header and delimiter should have same display width"
1262        );
1263        assert_eq!(
1264            lines[1].width(),
1265            lines[2].width(),
1266            "Delimiter and content should have same display width"
1267        );
1268
1269        // Delimiter should have no spaces
1270        assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1271    }
1272
1273    #[test]
1274    fn test_md060_aligned_no_space_minimum_width() {
1275        // Minimum column width (3 dashes) should be respected
1276        let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1277
1278        let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280
1281        let fixed = rule.fix(&ctx).unwrap();
1282        let lines: Vec<&str> = fixed.lines().collect();
1283
1284        // Should have at least 3 dashes per column (GFM requirement)
1285        assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1286        // All rows should have equal length
1287        assert_eq!(lines[0].len(), lines[1].len());
1288        assert_eq!(lines[1].len(), lines[2].len());
1289    }
1290
1291    #[test]
1292    fn test_md060_any_style_consistency() {
1293        let rule = MD060TableFormat::new(true, "any".to_string());
1294
1295        // Table is already compact, should stay compact
1296        let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298
1299        let fixed = rule.fix(&ctx).unwrap();
1300        assert_eq!(fixed, content);
1301
1302        // Table is aligned, should stay aligned
1303        let content_aligned = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1304        let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1305
1306        let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1307        assert_eq!(fixed_aligned, content_aligned);
1308    }
1309
1310    #[test]
1311    fn test_md060_empty_cells() {
1312        let rule = MD060TableFormat::new(true, "aligned".to_string());
1313
1314        let content = "| A | B |\n|---|---|\n|  | X |";
1315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316
1317        let fixed = rule.fix(&ctx).unwrap();
1318        assert!(fixed.contains("|"));
1319    }
1320
1321    #[test]
1322    fn test_md060_mixed_content() {
1323        let rule = MD060TableFormat::new(true, "aligned".to_string());
1324
1325        let content = "| Name | Age | City |\n|---|---|---|\n| δΈ­ζ–‡ | 30 | NYC |";
1326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327
1328        let fixed = rule.fix(&ctx).unwrap();
1329        assert!(fixed.contains("δΈ­ζ–‡"));
1330        assert!(fixed.contains("NYC"));
1331    }
1332
1333    #[test]
1334    fn test_md060_preserve_alignment_indicators() {
1335        let rule = MD060TableFormat::new(true, "aligned".to_string());
1336
1337        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339
1340        let fixed = rule.fix(&ctx).unwrap();
1341
1342        assert!(fixed.contains(":---"), "Should contain left alignment");
1343        assert!(fixed.contains(":----:"), "Should contain center alignment");
1344        assert!(fixed.contains("----:"), "Should contain right alignment");
1345    }
1346
1347    #[test]
1348    fn test_md060_minimum_column_width() {
1349        let rule = MD060TableFormat::new(true, "aligned".to_string());
1350
1351        // Test with very short column content to ensure minimum width of 3
1352        // GFM requires at least 3 dashes in delimiter rows
1353        let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1354        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355
1356        let fixed = rule.fix(&ctx).unwrap();
1357
1358        let lines: Vec<&str> = fixed.lines().collect();
1359        assert_eq!(lines[0].len(), lines[1].len());
1360        assert_eq!(lines[1].len(), lines[2].len());
1361
1362        // Verify minimum width is enforced
1363        assert!(fixed.contains("ID "), "Short content should be padded");
1364        assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1365    }
1366
1367    #[test]
1368    fn test_md060_auto_compact_exceeds_default_threshold() {
1369        // Default max_width = 0, which inherits from default MD013 line_length = 80
1370        let config = MD060Config {
1371            enabled: true,
1372            style: "aligned".to_string(),
1373            max_width: LineLength::from_const(0),
1374            column_align: ColumnAlign::Auto,
1375            column_align_header: None,
1376            column_align_body: None,
1377            loose_last_column: false,
1378        };
1379        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1380
1381        // Table that would be 85 chars when aligned (exceeds 80)
1382        // Formula: 1 + (3 * 3) + (20 + 20 + 30) = 1 + 9 + 70 = 80 chars
1383        // But with actual content padding it will exceed
1384        let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386
1387        let fixed = rule.fix(&ctx).unwrap();
1388
1389        // Should use compact formatting (single spaces)
1390        assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1391        assert!(fixed.contains("| --- | --- | --- |"));
1392        assert!(fixed.contains("| Short | Data | Here |"));
1393
1394        // Verify it's compact (no extra padding)
1395        let lines: Vec<&str> = fixed.lines().collect();
1396        // In compact mode, lines can have different lengths
1397        assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1398    }
1399
1400    #[test]
1401    fn test_md060_auto_compact_exceeds_explicit_threshold() {
1402        // Explicit max_width = 50
1403        let config = MD060Config {
1404            enabled: true,
1405            style: "aligned".to_string(),
1406            max_width: LineLength::from_const(50),
1407            column_align: ColumnAlign::Auto,
1408            column_align_header: None,
1409            column_align_body: None,
1410            loose_last_column: false,
1411        };
1412        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false); // MD013 setting doesn't matter
1413
1414        // Table that would exceed 50 chars when aligned
1415        // Column widths: 25 + 25 + 25 = 75 chars
1416        // Formula: 1 + (3 * 3) + 75 = 85 chars (exceeds 50)
1417        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419
1420        let fixed = rule.fix(&ctx).unwrap();
1421
1422        // Should use compact formatting (single spaces, no extra padding)
1423        assert!(
1424            fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1425        );
1426        assert!(fixed.contains("| --- | --- | --- |"));
1427        assert!(fixed.contains("| Data | Data | Data |"));
1428
1429        // Verify it's compact (lines have different lengths)
1430        let lines: Vec<&str> = fixed.lines().collect();
1431        assert!(lines[0].len() != lines[2].len());
1432    }
1433
1434    #[test]
1435    fn test_md060_stays_aligned_under_threshold() {
1436        // max_width = 100, table will be under this
1437        let config = MD060Config {
1438            enabled: true,
1439            style: "aligned".to_string(),
1440            max_width: LineLength::from_const(100),
1441            column_align: ColumnAlign::Auto,
1442            column_align_header: None,
1443            column_align_body: None,
1444            loose_last_column: false,
1445        };
1446        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1447
1448        // Small table that fits well under 100 chars
1449        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451
1452        let fixed = rule.fix(&ctx).unwrap();
1453
1454        // Should use aligned formatting (all lines same length)
1455        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
1456        assert_eq!(fixed, expected);
1457
1458        let lines: Vec<&str> = fixed.lines().collect();
1459        assert_eq!(lines[0].len(), lines[1].len());
1460        assert_eq!(lines[1].len(), lines[2].len());
1461    }
1462
1463    #[test]
1464    fn test_md060_width_calculation_formula() {
1465        // Verify the width calculation formula: 1 + (num_columns * 3) + sum(column_widths)
1466        let config = MD060Config {
1467            enabled: true,
1468            style: "aligned".to_string(),
1469            max_width: LineLength::from_const(0),
1470            column_align: ColumnAlign::Auto,
1471            column_align_header: None,
1472            column_align_body: None,
1473            loose_last_column: false,
1474        };
1475        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1476
1477        // Create a table where we know exact column widths: 5 + 5 + 5 = 15
1478        // Expected aligned width: 1 + (3 * 3) + 15 = 1 + 9 + 15 = 25 chars
1479        // This is under 30, so should stay aligned
1480        let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482
1483        let fixed = rule.fix(&ctx).unwrap();
1484
1485        // Should be aligned
1486        let lines: Vec<&str> = fixed.lines().collect();
1487        assert_eq!(lines[0].len(), lines[1].len());
1488        assert_eq!(lines[1].len(), lines[2].len());
1489        assert_eq!(lines[0].len(), 25); // Verify formula
1490
1491        // Now test with threshold = 24 (just under aligned width)
1492        let config_tight = MD060Config {
1493            enabled: true,
1494            style: "aligned".to_string(),
1495            max_width: LineLength::from_const(24),
1496            column_align: ColumnAlign::Auto,
1497            column_align_header: None,
1498            column_align_body: None,
1499            loose_last_column: false,
1500        };
1501        let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1502
1503        let fixed_compact = rule_tight.fix(&ctx).unwrap();
1504
1505        // Should be compact now (25 > 24)
1506        assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1507        assert!(fixed_compact.contains("| --- | --- | --- |"));
1508    }
1509
1510    #[test]
1511    fn test_md060_very_wide_table_auto_compacts() {
1512        let config = MD060Config {
1513            enabled: true,
1514            style: "aligned".to_string(),
1515            max_width: LineLength::from_const(0),
1516            column_align: ColumnAlign::Auto,
1517            column_align_header: None,
1518            column_align_body: None,
1519            loose_last_column: false,
1520        };
1521        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1522
1523        // Very wide table with many columns
1524        // 8 columns with widths of 12 chars each = 96 chars
1525        // Formula: 1 + (8 * 3) + 96 = 121 chars (exceeds 80)
1526        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 |";
1527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528
1529        let fixed = rule.fix(&ctx).unwrap();
1530
1531        // Should be compact (table would be way over 80 chars aligned)
1532        assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1533        assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1534    }
1535
1536    #[test]
1537    fn test_md060_inherit_from_md013_line_length() {
1538        // max_width = 0 should inherit from MD013's line_length
1539        let config = MD060Config {
1540            enabled: true,
1541            style: "aligned".to_string(),
1542            max_width: LineLength::from_const(0), // Inherit
1543            column_align: ColumnAlign::Auto,
1544            column_align_header: None,
1545            column_align_body: None,
1546            loose_last_column: false,
1547        };
1548
1549        // Test with different MD013 line_length values
1550        let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1551        let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1552
1553        // Medium-sized table
1554        let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556
1557        // With 80 char limit, likely compacts
1558        let _fixed_80 = rule_80.fix(&ctx).unwrap();
1559
1560        // With 120 char limit, likely stays aligned
1561        let fixed_120 = rule_120.fix(&ctx).unwrap();
1562
1563        // Verify 120 is aligned (all lines same length)
1564        let lines_120: Vec<&str> = fixed_120.lines().collect();
1565        assert_eq!(lines_120[0].len(), lines_120[1].len());
1566        assert_eq!(lines_120[1].len(), lines_120[2].len());
1567    }
1568
1569    #[test]
1570    fn test_md060_edge_case_exactly_at_threshold() {
1571        // Create table that's exactly at the threshold
1572        // Formula: 1 + (num_columns * 3) + sum(column_widths) = max_width
1573        // For 2 columns with widths 5 and 5: 1 + 6 + 10 = 17
1574        let config = MD060Config {
1575            enabled: true,
1576            style: "aligned".to_string(),
1577            max_width: LineLength::from_const(17),
1578            column_align: ColumnAlign::Auto,
1579            column_align_header: None,
1580            column_align_body: None,
1581            loose_last_column: false,
1582        };
1583        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1584
1585        let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1587
1588        let fixed = rule.fix(&ctx).unwrap();
1589
1590        // At threshold (17 <= 17), should stay aligned
1591        let lines: Vec<&str> = fixed.lines().collect();
1592        assert_eq!(lines[0].len(), 17);
1593        assert_eq!(lines[0].len(), lines[1].len());
1594        assert_eq!(lines[1].len(), lines[2].len());
1595
1596        // Now test with threshold = 16 (just under)
1597        let config_under = MD060Config {
1598            enabled: true,
1599            style: "aligned".to_string(),
1600            max_width: LineLength::from_const(16),
1601            column_align: ColumnAlign::Auto,
1602            column_align_header: None,
1603            column_align_body: None,
1604            loose_last_column: false,
1605        };
1606        let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1607
1608        let fixed_compact = rule_under.fix(&ctx).unwrap();
1609
1610        // Should compact (17 > 16)
1611        assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1612        assert!(fixed_compact.contains("| --- | --- |"));
1613    }
1614
1615    #[test]
1616    fn test_md060_auto_compact_warning_message() {
1617        // Verify that auto-compact generates an informative warning
1618        let config = MD060Config {
1619            enabled: true,
1620            style: "aligned".to_string(),
1621            max_width: LineLength::from_const(50),
1622            column_align: ColumnAlign::Auto,
1623            column_align_header: None,
1624            column_align_body: None,
1625            loose_last_column: false,
1626        };
1627        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1628
1629        // Table that will be auto-compacted (exceeds 50 chars when aligned)
1630        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632
1633        let warnings = rule.check(&ctx).unwrap();
1634
1635        // Should generate warnings with auto-compact message
1636        assert!(!warnings.is_empty(), "Should generate warnings");
1637
1638        let auto_compact_warnings: Vec<_> = warnings
1639            .iter()
1640            .filter(|w| w.message.contains("too wide for aligned formatting"))
1641            .collect();
1642
1643        assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1644
1645        // Verify the warning message includes the width and threshold
1646        let first_warning = auto_compact_warnings[0];
1647        assert!(first_warning.message.contains("85 chars > max-width: 50"));
1648        assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1649    }
1650
1651    #[test]
1652    fn test_md060_issue_129_detect_style_from_all_rows() {
1653        // Issue #129: detect_table_style should check all rows, not just the first row
1654        // If header row has single-space padding but content rows have extra padding,
1655        // the table should be detected as "aligned" and preserved
1656        let rule = MD060TableFormat::new(true, "any".to_string());
1657
1658        // Table where header looks compact but content is aligned
1659        let content = "| a long heading | another long heading |\n\
1660                       | -------------- | -------------------- |\n\
1661                       | a              | 1                    |\n\
1662                       | b b            | 2                    |\n\
1663                       | c c c          | 3                    |";
1664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665
1666        let fixed = rule.fix(&ctx).unwrap();
1667
1668        // Should preserve the aligned formatting of content rows
1669        assert!(
1670            fixed.contains("| a              | 1                    |"),
1671            "Should preserve aligned padding in first content row"
1672        );
1673        assert!(
1674            fixed.contains("| b b            | 2                    |"),
1675            "Should preserve aligned padding in second content row"
1676        );
1677        assert!(
1678            fixed.contains("| c c c          | 3                    |"),
1679            "Should preserve aligned padding in third content row"
1680        );
1681
1682        // Entire table should remain unchanged because it's already properly aligned
1683        assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1684    }
1685
1686    #[test]
1687    fn test_md060_regular_alignment_warning_message() {
1688        // Verify that regular alignment (not auto-compact) generates normal warning
1689        let config = MD060Config {
1690            enabled: true,
1691            style: "aligned".to_string(),
1692            max_width: LineLength::from_const(100), // Large enough to not trigger auto-compact
1693            column_align: ColumnAlign::Auto,
1694            column_align_header: None,
1695            column_align_body: None,
1696            loose_last_column: false,
1697        };
1698        let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1699
1700        // Small misaligned table
1701        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703
1704        let warnings = rule.check(&ctx).unwrap();
1705
1706        // Should generate warnings
1707        assert!(!warnings.is_empty(), "Should generate warnings");
1708
1709        // Verify it's the standard alignment message, not auto-compact
1710        assert!(warnings[0].message.contains("Table columns should be aligned"));
1711        assert!(!warnings[0].message.contains("too wide"));
1712        assert!(!warnings[0].message.contains("max-width"));
1713    }
1714
1715    // === Issue #219: Unlimited table width tests ===
1716
1717    #[test]
1718    fn test_md060_unlimited_when_md013_disabled() {
1719        // When MD013 is globally disabled, max_width should be unlimited
1720        let config = MD060Config {
1721            enabled: true,
1722            style: "aligned".to_string(),
1723            max_width: LineLength::from_const(0), // Inherit
1724            column_align: ColumnAlign::Auto,
1725            column_align_header: None,
1726            column_align_body: None,
1727            loose_last_column: false,
1728        };
1729        let md013_config = MD013Config::default();
1730        let rule = MD060TableFormat::from_config_struct(config, md013_config, true /* disabled */);
1731
1732        // Very wide table that would normally exceed 80 chars
1733        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735        let fixed = rule.fix(&ctx).unwrap();
1736
1737        // Should be aligned (not compacted) since MD013 is disabled
1738        let lines: Vec<&str> = fixed.lines().collect();
1739        // In aligned mode, all lines have the same length
1740        assert_eq!(
1741            lines[0].len(),
1742            lines[1].len(),
1743            "Table should be aligned when MD013 is disabled"
1744        );
1745    }
1746
1747    #[test]
1748    fn test_md060_unlimited_when_md013_tables_false() {
1749        // When MD013.tables = false, max_width should be unlimited
1750        let config = MD060Config {
1751            enabled: true,
1752            style: "aligned".to_string(),
1753            max_width: LineLength::from_const(0),
1754            column_align: ColumnAlign::Auto,
1755            column_align_header: None,
1756            column_align_body: None,
1757            loose_last_column: false,
1758        };
1759        let md013_config = MD013Config {
1760            tables: false, // User doesn't care about table line length
1761            line_length: LineLength::from_const(80),
1762            ..Default::default()
1763        };
1764        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1765
1766        // Wide table that would exceed 80 chars
1767        let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769        let fixed = rule.fix(&ctx).unwrap();
1770
1771        // Should be aligned (no auto-compact since tables=false)
1772        let lines: Vec<&str> = fixed.lines().collect();
1773        assert_eq!(
1774            lines[0].len(),
1775            lines[1].len(),
1776            "Table should be aligned when MD013.tables=false"
1777        );
1778    }
1779
1780    #[test]
1781    fn test_md060_unlimited_when_md013_line_length_zero() {
1782        // When MD013.line_length = 0, max_width should be unlimited
1783        let config = MD060Config {
1784            enabled: true,
1785            style: "aligned".to_string(),
1786            max_width: LineLength::from_const(0),
1787            column_align: ColumnAlign::Auto,
1788            column_align_header: None,
1789            column_align_body: None,
1790            loose_last_column: false,
1791        };
1792        let md013_config = MD013Config {
1793            tables: true,
1794            line_length: LineLength::from_const(0), // No limit
1795            ..Default::default()
1796        };
1797        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1798
1799        // Wide table
1800        let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802        let fixed = rule.fix(&ctx).unwrap();
1803
1804        // Should be aligned
1805        let lines: Vec<&str> = fixed.lines().collect();
1806        assert_eq!(
1807            lines[0].len(),
1808            lines[1].len(),
1809            "Table should be aligned when MD013.line_length=0"
1810        );
1811    }
1812
1813    #[test]
1814    fn test_md060_explicit_max_width_overrides_md013_settings() {
1815        // Explicit max_width should always take precedence
1816        let config = MD060Config {
1817            enabled: true,
1818            style: "aligned".to_string(),
1819            max_width: LineLength::from_const(50), // Explicit limit
1820            column_align: ColumnAlign::Auto,
1821            column_align_header: None,
1822            column_align_body: None,
1823            loose_last_column: false,
1824        };
1825        let md013_config = MD013Config {
1826            tables: false,                          // This would make it unlimited...
1827            line_length: LineLength::from_const(0), // ...and this too
1828            ..Default::default()
1829        };
1830        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1831
1832        // Wide table that exceeds explicit 50-char limit
1833        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835        let fixed = rule.fix(&ctx).unwrap();
1836
1837        // Should be compact (explicit max_width = 50 overrides MD013 settings)
1838        assert!(
1839            fixed.contains("| --- |"),
1840            "Should be compact format due to explicit max_width"
1841        );
1842    }
1843
1844    #[test]
1845    fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1846        // When MD013.tables = true and MD013.line_length is set, inherit that limit
1847        let config = MD060Config {
1848            enabled: true,
1849            style: "aligned".to_string(),
1850            max_width: LineLength::from_const(0), // Inherit
1851            column_align: ColumnAlign::Auto,
1852            column_align_header: None,
1853            column_align_body: None,
1854            loose_last_column: false,
1855        };
1856        let md013_config = MD013Config {
1857            tables: true,
1858            line_length: LineLength::from_const(50), // 50 char limit
1859            ..Default::default()
1860        };
1861        let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1862
1863        // Wide table that exceeds 50 chars
1864        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1866        let fixed = rule.fix(&ctx).unwrap();
1867
1868        // Should be compact (inherited 50-char limit from MD013)
1869        assert!(
1870            fixed.contains("| --- |"),
1871            "Should be compact format when inheriting MD013 limit"
1872        );
1873    }
1874
1875    // === Issue #311: aligned-no-space style tests ===
1876
1877    #[test]
1878    fn test_aligned_no_space_reformats_spaced_delimiter() {
1879        // Table with "aligned" style (spaces around dashes) should be reformatted
1880        // when target style is "aligned-no-space"
1881        let config = MD060Config {
1882            enabled: true,
1883            style: "aligned-no-space".to_string(),
1884            max_width: LineLength::from_const(0),
1885            column_align: ColumnAlign::Auto,
1886            column_align_header: None,
1887            column_align_body: None,
1888            loose_last_column: false,
1889        };
1890        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1891
1892        // Input: aligned table with spaces around dashes
1893        let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |";
1894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895        let fixed = rule.fix(&ctx).unwrap();
1896
1897        // Should have no spaces around dashes in delimiter row
1898        // The dashes may be longer to match column width, but should have no spaces
1899        assert!(
1900            !fixed.contains("| ----"),
1901            "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1902        );
1903        assert!(
1904            !fixed.contains("---- |"),
1905            "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1906        );
1907        // Verify it has the compact delimiter format (dashes touching pipes)
1908        assert!(
1909            fixed.contains("|----"),
1910            "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1911        );
1912    }
1913
1914    #[test]
1915    fn test_aligned_reformats_compact_delimiter() {
1916        // Table with "aligned-no-space" style (no spaces around dashes) should be reformatted
1917        // when target style is "aligned"
1918        let config = MD060Config {
1919            enabled: true,
1920            style: "aligned".to_string(),
1921            max_width: LineLength::from_const(0),
1922            column_align: ColumnAlign::Auto,
1923            column_align_header: None,
1924            column_align_body: None,
1925            loose_last_column: false,
1926        };
1927        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1928
1929        // Input: aligned-no-space table (no spaces around dashes)
1930        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
1931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932        let fixed = rule.fix(&ctx).unwrap();
1933
1934        // Should have spaces around dashes in delimiter row
1935        assert!(
1936            fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1937            "Delimiter should have spaces around dashes. Got:\n{fixed}"
1938        );
1939    }
1940
1941    #[test]
1942    fn test_aligned_no_space_preserves_matching_table() {
1943        // Table already in "aligned-no-space" style should be preserved
1944        let config = MD060Config {
1945            enabled: true,
1946            style: "aligned-no-space".to_string(),
1947            max_width: LineLength::from_const(0),
1948            column_align: ColumnAlign::Auto,
1949            column_align_header: None,
1950            column_align_body: None,
1951            loose_last_column: false,
1952        };
1953        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1954
1955        // Input: already in aligned-no-space style
1956        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
1957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958        let fixed = rule.fix(&ctx).unwrap();
1959
1960        // Should be preserved as-is
1961        assert_eq!(
1962            fixed, content,
1963            "Table already in aligned-no-space style should be preserved"
1964        );
1965    }
1966
1967    #[test]
1968    fn test_aligned_preserves_matching_table() {
1969        // Table already in "aligned" style should be preserved
1970        let config = MD060Config {
1971            enabled: true,
1972            style: "aligned".to_string(),
1973            max_width: LineLength::from_const(0),
1974            column_align: ColumnAlign::Auto,
1975            column_align_header: None,
1976            column_align_body: None,
1977            loose_last_column: false,
1978        };
1979        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1980
1981        // Input: already in aligned style
1982        let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1   | Cell 2   |";
1983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984        let fixed = rule.fix(&ctx).unwrap();
1985
1986        // Should be preserved as-is
1987        assert_eq!(fixed, content, "Table already in aligned style should be preserved");
1988    }
1989
1990    #[test]
1991    fn test_cjk_table_display_width_consistency() {
1992        // Test that is_table_already_aligned correctly uses display width, not byte length
1993        // CJK characters have display width of 2, but byte length of 3 in UTF-8
1994        //
1995        // This table is NOT aligned because line lengths differ
1996        // (CJK chars take 3 bytes in UTF-8 but only 2 columns in display)
1997        let table_lines = vec!["| 名前 | Age |", "|------|-----|", "| η”°δΈ­ | 25  |"];
1998
1999        // First check is raw line length equality (byte-based), which fails
2000        let is_aligned =
2001            MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2002        assert!(
2003            !is_aligned,
2004            "Table with uneven raw line lengths should NOT be considered aligned"
2005        );
2006    }
2007
2008    #[test]
2009    fn test_cjk_width_calculation_in_aligned_check() {
2010        // calculate_cell_display_width trims content before calculating width
2011        // Verify CJK width is correctly calculated (2 per character)
2012        let cjk_width = MD060TableFormat::calculate_cell_display_width("名前");
2013        assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2014
2015        let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2016        assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2017
2018        // Test that spacing is trimmed before width calculation
2019        let padded_cjk = MD060TableFormat::calculate_cell_display_width(" 名前 ");
2020        assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2021
2022        // Test mixed content
2023        let mixed = MD060TableFormat::calculate_cell_display_width(" ζ—₯本θͺžABC ");
2024        // 3 CJK chars (width 6) + 3 ASCII (width 3) = 9
2025        assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2026    }
2027
2028    // === Issue #317: column-align option tests ===
2029
2030    #[test]
2031    fn test_md060_column_align_left() {
2032        // Default/explicit left alignment
2033        let config = MD060Config {
2034            enabled: true,
2035            style: "aligned".to_string(),
2036            max_width: LineLength::from_const(0),
2037            column_align: ColumnAlign::Left,
2038            column_align_header: None,
2039            column_align_body: None,
2040            loose_last_column: false,
2041        };
2042        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2043
2044        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2045        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046
2047        let fixed = rule.fix(&ctx).unwrap();
2048        let lines: Vec<&str> = fixed.lines().collect();
2049
2050        // Left aligned: content on left, padding on right
2051        assert!(
2052            lines[2].contains("| Alice "),
2053            "Content should be left-aligned (Alice should have trailing padding)"
2054        );
2055        assert!(
2056            lines[3].contains("| Bob   "),
2057            "Content should be left-aligned (Bob should have trailing padding)"
2058        );
2059    }
2060
2061    #[test]
2062    fn test_md060_column_align_center() {
2063        // Center alignment forces all columns to center
2064        let config = MD060Config {
2065            enabled: true,
2066            style: "aligned".to_string(),
2067            max_width: LineLength::from_const(0),
2068            column_align: ColumnAlign::Center,
2069            column_align_header: None,
2070            column_align_body: None,
2071            loose_last_column: false,
2072        };
2073        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2074
2075        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2076        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2077
2078        let fixed = rule.fix(&ctx).unwrap();
2079        let lines: Vec<&str> = fixed.lines().collect();
2080
2081        // Center aligned: padding split on both sides
2082        // "Bob" (3 chars) in "Name" column (5 chars) = 2 padding total, 1 left, 1 right
2083        assert!(
2084            lines[3].contains("|  Bob  |"),
2085            "Bob should be centered with padding on both sides. Got: {}",
2086            lines[3]
2087        );
2088    }
2089
2090    #[test]
2091    fn test_md060_column_align_right() {
2092        // Right alignment forces all columns to right-align
2093        let config = MD060Config {
2094            enabled: true,
2095            style: "aligned".to_string(),
2096            max_width: LineLength::from_const(0),
2097            column_align: ColumnAlign::Right,
2098            column_align_header: None,
2099            column_align_body: None,
2100            loose_last_column: false,
2101        };
2102        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2103
2104        let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106
2107        let fixed = rule.fix(&ctx).unwrap();
2108        let lines: Vec<&str> = fixed.lines().collect();
2109
2110        // Right aligned: padding on left, content on right
2111        assert!(
2112            lines[3].contains("|   Bob |"),
2113            "Bob should be right-aligned with padding on left. Got: {}",
2114            lines[3]
2115        );
2116    }
2117
2118    #[test]
2119    fn test_md060_column_align_auto_respects_delimiter() {
2120        // Auto mode (default) should respect delimiter row alignment indicators
2121        let config = MD060Config {
2122            enabled: true,
2123            style: "aligned".to_string(),
2124            max_width: LineLength::from_const(0),
2125            column_align: ColumnAlign::Auto,
2126            column_align_header: None,
2127            column_align_body: None,
2128            loose_last_column: false,
2129        };
2130        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2131
2132        // Left, center, right columns via delimiter indicators
2133        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135
2136        let fixed = rule.fix(&ctx).unwrap();
2137
2138        // Verify alignment is applied per-column based on delimiter
2139        assert!(fixed.contains("| A "), "Left column should be left-aligned");
2140        // Center and right columns with longer content in header
2141        let lines: Vec<&str> = fixed.lines().collect();
2142        // The content row should have B centered and C right-aligned
2143        // B (1 char) in "Center" (6 chars) = 5 padding, ~2 left, ~3 right
2144        // C (1 char) in "Right" (5 chars) = 4 padding, all on left
2145        assert!(
2146            lines[2].contains(" C |"),
2147            "Right column should be right-aligned. Got: {}",
2148            lines[2]
2149        );
2150    }
2151
2152    #[test]
2153    fn test_md060_column_align_overrides_delimiter_indicators() {
2154        // column-align should override delimiter row indicators
2155        let config = MD060Config {
2156            enabled: true,
2157            style: "aligned".to_string(),
2158            max_width: LineLength::from_const(0),
2159            column_align: ColumnAlign::Right, // Override all to right
2160            column_align_header: None,
2161            column_align_body: None,
2162            loose_last_column: false,
2163        };
2164        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2165
2166        // Delimiter says left, center, right - but we override all to right
2167        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2169
2170        let fixed = rule.fix(&ctx).unwrap();
2171        let lines: Vec<&str> = fixed.lines().collect();
2172
2173        // ALL columns should be right-aligned despite delimiter indicators
2174        // "A" in "Left" column (4 chars minimum due to header length) should be right-aligned
2175        assert!(
2176            lines[2].contains("    A |") || lines[2].contains("   A |"),
2177            "Even left-indicated column should be right-aligned. Got: {}",
2178            lines[2]
2179        );
2180    }
2181
2182    #[test]
2183    fn test_md060_column_align_with_aligned_no_space() {
2184        // column-align should work with aligned-no-space style
2185        let config = MD060Config {
2186            enabled: true,
2187            style: "aligned-no-space".to_string(),
2188            max_width: LineLength::from_const(0),
2189            column_align: ColumnAlign::Center,
2190            column_align_header: None,
2191            column_align_body: None,
2192            loose_last_column: false,
2193        };
2194        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2195
2196        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198
2199        let fixed = rule.fix(&ctx).unwrap();
2200        let lines: Vec<&str> = fixed.lines().collect();
2201
2202        // Delimiter row should have no spaces (aligned-no-space)
2203        assert!(
2204            lines[1].contains("|---"),
2205            "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2206            lines[1]
2207        );
2208        // Content should still be centered
2209        assert!(
2210            lines[3].contains("|  Bob  |"),
2211            "Content should be centered. Got: {}",
2212            lines[3]
2213        );
2214    }
2215
2216    #[test]
2217    fn test_md060_column_align_config_parsing() {
2218        // Test that column-align config is correctly parsed
2219        let toml_str = r#"
2220enabled = true
2221style = "aligned"
2222column-align = "center"
2223"#;
2224        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2225        assert_eq!(config.column_align, ColumnAlign::Center);
2226
2227        let toml_str = r#"
2228enabled = true
2229style = "aligned"
2230column-align = "right"
2231"#;
2232        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2233        assert_eq!(config.column_align, ColumnAlign::Right);
2234
2235        let toml_str = r#"
2236enabled = true
2237style = "aligned"
2238column-align = "left"
2239"#;
2240        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2241        assert_eq!(config.column_align, ColumnAlign::Left);
2242
2243        let toml_str = r#"
2244enabled = true
2245style = "aligned"
2246column-align = "auto"
2247"#;
2248        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2249        assert_eq!(config.column_align, ColumnAlign::Auto);
2250    }
2251
2252    #[test]
2253    fn test_md060_column_align_default_is_auto() {
2254        // Without column-align specified, default should be Auto
2255        let toml_str = r#"
2256enabled = true
2257style = "aligned"
2258"#;
2259        let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2260        assert_eq!(config.column_align, ColumnAlign::Auto);
2261    }
2262
2263    #[test]
2264    fn test_md060_column_align_reformats_already_aligned_table() {
2265        // A table that is already aligned (left) should be reformatted when column-align=right
2266        let config = MD060Config {
2267            enabled: true,
2268            style: "aligned".to_string(),
2269            max_width: LineLength::from_const(0),
2270            column_align: ColumnAlign::Right,
2271            column_align_header: None,
2272            column_align_body: None,
2273            loose_last_column: false,
2274        };
2275        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2276
2277        // This table is already properly aligned with left alignment
2278        let content = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |\n| Bob   | 25  |";
2279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280
2281        let fixed = rule.fix(&ctx).unwrap();
2282        let lines: Vec<&str> = fixed.lines().collect();
2283
2284        // Should be reformatted with right alignment
2285        assert!(
2286            lines[2].contains("| Alice |") && lines[2].contains("|  30 |"),
2287            "Already aligned table should be reformatted with right alignment. Got: {}",
2288            lines[2]
2289        );
2290        assert!(
2291            lines[3].contains("|   Bob |") || lines[3].contains("|  Bob |"),
2292            "Bob should be right-aligned. Got: {}",
2293            lines[3]
2294        );
2295    }
2296
2297    #[test]
2298    fn test_md060_column_align_with_cjk_characters() {
2299        // CJK characters have double display width - centering should account for this
2300        let config = MD060Config {
2301            enabled: true,
2302            style: "aligned".to_string(),
2303            max_width: LineLength::from_const(0),
2304            column_align: ColumnAlign::Center,
2305            column_align_header: None,
2306            column_align_body: None,
2307            loose_last_column: false,
2308        };
2309        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2310
2311        let content = "| Name | City |\n|---|---|\n| Alice | 東京 |\n| Bob | LA |";
2312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313
2314        let fixed = rule.fix(&ctx).unwrap();
2315
2316        // Both Alice and Bob should be centered, and 東京 should be properly aligned
2317        // considering its double-width display
2318        assert!(fixed.contains("Bob"), "Table should contain Bob");
2319        assert!(fixed.contains("東京"), "Table should contain 東京");
2320    }
2321
2322    #[test]
2323    fn test_md060_column_align_ignored_for_compact_style() {
2324        // column-align should have no effect on compact style (minimal padding)
2325        let config = MD060Config {
2326            enabled: true,
2327            style: "compact".to_string(),
2328            max_width: LineLength::from_const(0),
2329            column_align: ColumnAlign::Right, // This should be ignored
2330            column_align_header: None,
2331            column_align_body: None,
2332            loose_last_column: false,
2333        };
2334        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2335
2336        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2337        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2338
2339        let fixed = rule.fix(&ctx).unwrap();
2340
2341        // Compact style: single space padding, no alignment
2342        assert!(
2343            fixed.contains("| Alice |"),
2344            "Compact style should have single space padding, not alignment. Got: {fixed}"
2345        );
2346    }
2347
2348    #[test]
2349    fn test_md060_column_align_ignored_for_tight_style() {
2350        // column-align should have no effect on tight style (no padding)
2351        let config = MD060Config {
2352            enabled: true,
2353            style: "tight".to_string(),
2354            max_width: LineLength::from_const(0),
2355            column_align: ColumnAlign::Center, // This should be ignored
2356            column_align_header: None,
2357            column_align_body: None,
2358            loose_last_column: false,
2359        };
2360        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2361
2362        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2364
2365        let fixed = rule.fix(&ctx).unwrap();
2366
2367        // Tight style: no spaces at all
2368        assert!(
2369            fixed.contains("|Alice|"),
2370            "Tight style should have no spaces. Got: {fixed}"
2371        );
2372    }
2373
2374    #[test]
2375    fn test_md060_column_align_with_empty_cells() {
2376        // Empty cells should be handled correctly with centering
2377        let config = MD060Config {
2378            enabled: true,
2379            style: "aligned".to_string(),
2380            max_width: LineLength::from_const(0),
2381            column_align: ColumnAlign::Center,
2382            column_align_header: None,
2383            column_align_body: None,
2384            loose_last_column: false,
2385        };
2386        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2387
2388        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n|  | 25 |";
2389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2390
2391        let fixed = rule.fix(&ctx).unwrap();
2392        let lines: Vec<&str> = fixed.lines().collect();
2393
2394        // Empty cell should have all padding (centered empty string)
2395        assert!(
2396            lines[3].contains("|       |") || lines[3].contains("|      |"),
2397            "Empty cell should be padded correctly. Got: {}",
2398            lines[3]
2399        );
2400    }
2401
2402    #[test]
2403    fn test_md060_column_align_auto_preserves_already_aligned() {
2404        // With column-align=auto (default), already aligned tables should be preserved
2405        let config = MD060Config {
2406            enabled: true,
2407            style: "aligned".to_string(),
2408            max_width: LineLength::from_const(0),
2409            column_align: ColumnAlign::Auto,
2410            column_align_header: None,
2411            column_align_body: None,
2412            loose_last_column: false,
2413        };
2414        let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2415
2416        // This table is already properly aligned
2417        let content = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |\n| Bob   | 25  |";
2418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419
2420        let fixed = rule.fix(&ctx).unwrap();
2421
2422        // Should be preserved as-is
2423        assert_eq!(
2424            fixed, content,
2425            "Already aligned table should be preserved with column-align=auto"
2426        );
2427    }
2428}