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