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::table_utils::TableUtils;
4use unicode_width::UnicodeWidthStr;
5
6mod md060_config;
7use crate::md013_line_length::MD013Config;
8use md060_config::MD060Config;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11enum ColumnAlignment {
12    Left,
13    Center,
14    Right,
15}
16
17#[derive(Debug, Clone)]
18struct TableFormatResult {
19    lines: Vec<String>,
20    auto_compacted: bool,
21    aligned_width: Option<usize>,
22}
23
24/// Rule MD060: Table Column Alignment
25///
26/// See [docs/md060.md](../../docs/md060.md) for full documentation, configuration, and examples.
27///
28/// This rule enforces consistent column alignment in Markdown tables for improved readability
29/// in source form. When enabled, it ensures table columns are properly aligned with appropriate
30/// padding.
31///
32/// ## Purpose
33///
34/// - **Readability**: Aligned tables are significantly easier to read in source form
35/// - **Maintainability**: Properly formatted tables are easier to edit and review
36/// - **Consistency**: Ensures uniform table formatting throughout documents
37/// - **Developer Experience**: Makes working with tables in plain text more pleasant
38///
39/// ## Configuration Options
40///
41/// The rule supports the following configuration options:
42///
43/// ```toml
44/// [MD013]
45/// line-length = 100  # MD060 inherits this by default
46///
47/// [MD060]
48/// enabled = false      # Default: opt-in for conservative adoption
49/// style = "aligned"    # Can be "aligned", "compact", "tight", or "any"
50/// max-width = 0        # Default: inherit from MD013's line-length
51/// ```
52///
53/// ### Style Options
54///
55/// - **aligned**: Columns are padded with spaces for visual alignment (default)
56/// - **compact**: Minimal spacing with single spaces
57/// - **tight**: No spacing, pipes directly adjacent to content
58/// - **any**: Preserve existing formatting style
59///
60/// ### Max Width (auto-compact threshold)
61///
62/// Controls when tables automatically switch from aligned to compact formatting:
63///
64/// - **`max-width = 0`** (default): Inherits from MD013's `line-length` setting (default 80)
65/// - **`max-width = N`**: Explicit threshold, independent of MD013
66///
67/// When a table's aligned width would exceed this limit, MD060 automatically
68/// uses compact formatting instead to prevent excessively long lines. This matches
69/// the behavior of Prettier's table formatting.
70///
71/// #### Examples
72///
73/// ```toml
74/// # Inherit from MD013 (recommended)
75/// [MD013]
76/// line-length = 100
77///
78/// [MD060]
79/// style = "aligned"
80/// max-width = 0  # Tables exceeding 100 chars will be compacted
81/// ```
82///
83/// ```toml
84/// # Explicit threshold
85/// [MD060]
86/// style = "aligned"
87/// max-width = 120  # Independent of MD013
88/// ```
89///
90/// ## Examples
91///
92/// ### Aligned Style (Good)
93///
94/// ```markdown
95/// | Name  | Age | City      |
96/// |-------|-----|-----------|
97/// | Alice | 30  | Seattle   |
98/// | Bob   | 25  | Portland  |
99/// ```
100///
101/// ### Unaligned (Bad)
102///
103/// ```markdown
104/// | Name | Age | City |
105/// |---|---|---|
106/// | Alice | 30 | Seattle |
107/// | Bob | 25 | Portland |
108/// ```
109///
110/// ## Unicode Support
111///
112/// This rule properly handles:
113/// - **CJK Characters**: Chinese, Japanese, Korean characters are correctly measured as double-width
114/// - **Basic Emoji**: Most emoji are handled correctly
115/// - **Inline Code**: Pipes in inline code blocks are properly masked
116///
117/// ## Known Limitations
118///
119/// **Complex Unicode Sequences**: Tables containing certain Unicode characters are automatically
120/// skipped to prevent alignment corruption. These include:
121/// - Zero-Width Joiner (ZWJ) emoji: πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦, πŸ‘©β€πŸ’»
122/// - Zero-Width Space (ZWS): Invisible word break opportunities
123/// - Zero-Width Non-Joiner (ZWNJ): Ligature prevention marks
124/// - Word Joiner (WJ): Non-breaking invisible characters
125///
126/// These characters have inconsistent or zero display widths across terminals and fonts,
127/// making accurate alignment impossible. The rule preserves these tables as-is rather than
128/// risk corrupting them.
129///
130/// This is an honest limitation of terminal display technology, similar to what other tools
131/// like markdownlint experience.
132///
133/// ## Fix Behavior
134///
135/// When applying automatic fixes, this rule:
136/// - Calculates proper display width for each column using Unicode width measurements
137/// - Pads cells with trailing spaces to align columns
138/// - Preserves cell content exactly (only spacing is modified)
139/// - Respects alignment indicators in delimiter rows (`:---`, `:---:`, `---:`)
140/// - Automatically switches to compact mode for tables exceeding max_width
141/// - Skips tables with ZWJ emoji to prevent corruption
142#[derive(Debug, Clone)]
143pub struct MD060TableFormat {
144    config: MD060Config,
145    md013_line_length: usize,
146}
147
148impl Default for MD060TableFormat {
149    fn default() -> Self {
150        Self {
151            config: MD060Config::default(),
152            md013_line_length: 80,
153        }
154    }
155}
156
157impl MD060TableFormat {
158    pub fn new(enabled: bool, style: String) -> Self {
159        Self {
160            config: MD060Config {
161                enabled,
162                style,
163                max_width: 0,
164            },
165            md013_line_length: 80, // Default MD013 line_length
166        }
167    }
168
169    pub fn from_config_struct(config: MD060Config, md013_line_length: usize) -> Self {
170        Self {
171            config,
172            md013_line_length,
173        }
174    }
175
176    /// Get the effective max width for table formatting.
177    ///
178    /// - If `max_width` is 0, inherits from MD013's `line_length`
179    /// - Otherwise, uses the explicitly configured `max_width`
180    fn effective_max_width(&self) -> usize {
181        if self.config.max_width == 0 {
182            self.md013_line_length
183        } else {
184            self.config.max_width
185        }
186    }
187
188    /// Check if text contains characters that break Unicode width calculations
189    ///
190    /// Tables with these characters are skipped to avoid alignment corruption:
191    /// - Zero-Width Joiner (ZWJ, U+200D): Complex emoji like πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
192    /// - Zero-Width Space (ZWS, U+200B): Invisible word break opportunity
193    /// - Zero-Width Non-Joiner (ZWNJ, U+200C): Prevents ligature formation
194    /// - Word Joiner (WJ, U+2060): Prevents line breaks without taking space
195    ///
196    /// These characters have inconsistent display widths across terminals,
197    /// making accurate alignment impossible.
198    fn contains_problematic_chars(text: &str) -> bool {
199        text.contains('\u{200D}')  // ZWJ
200            || text.contains('\u{200B}')  // ZWS
201            || text.contains('\u{200C}')  // ZWNJ
202            || text.contains('\u{2060}') // Word Joiner
203    }
204
205    fn calculate_cell_display_width(cell_content: &str) -> usize {
206        let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
207        masked.trim().width()
208    }
209
210    fn parse_table_row(line: &str) -> Vec<String> {
211        let trimmed = line.trim();
212        let masked = TableUtils::mask_pipes_for_table_parsing(trimmed);
213
214        let has_leading = masked.starts_with('|');
215        let has_trailing = masked.ends_with('|');
216
217        let mut masked_content = masked.as_str();
218        let mut orig_content = trimmed;
219
220        if has_leading {
221            masked_content = &masked_content[1..];
222            orig_content = &orig_content[1..];
223        }
224        if has_trailing && !masked_content.is_empty() {
225            masked_content = &masked_content[..masked_content.len() - 1];
226            orig_content = &orig_content[..orig_content.len() - 1];
227        }
228
229        let masked_parts: Vec<&str> = masked_content.split('|').collect();
230        let mut cells = Vec::new();
231        let mut pos = 0;
232
233        for masked_cell in masked_parts {
234            let cell_len = masked_cell.len();
235            let orig_cell = if pos + cell_len <= orig_content.len() {
236                &orig_content[pos..pos + cell_len]
237            } else {
238                masked_cell
239            };
240            cells.push(orig_cell.to_string());
241            pos += cell_len + 1;
242        }
243
244        cells
245    }
246
247    fn is_delimiter_row(row: &[String]) -> bool {
248        if row.is_empty() {
249            return false;
250        }
251        row.iter().all(|cell| {
252            let trimmed = cell.trim();
253            // A delimiter cell must contain at least one dash
254            // Empty cells are not delimiter cells
255            !trimmed.is_empty()
256                && trimmed.contains('-')
257                && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
258        })
259    }
260
261    fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
262        delimiter_row
263            .iter()
264            .map(|cell| {
265                let trimmed = cell.trim();
266                let has_left_colon = trimmed.starts_with(':');
267                let has_right_colon = trimmed.ends_with(':');
268
269                match (has_left_colon, has_right_colon) {
270                    (true, true) => ColumnAlignment::Center,
271                    (false, true) => ColumnAlignment::Right,
272                    _ => ColumnAlignment::Left,
273                }
274            })
275            .collect()
276    }
277
278    fn calculate_column_widths(table_lines: &[&str]) -> Vec<usize> {
279        let mut column_widths = Vec::new();
280        let mut delimiter_cells: Option<Vec<String>> = None;
281
282        for line in table_lines {
283            let cells = Self::parse_table_row(line);
284
285            // Save delimiter row for later processing, but don't use it for width calculation
286            if Self::is_delimiter_row(&cells) {
287                delimiter_cells = Some(cells);
288                continue;
289            }
290
291            for (i, cell) in cells.iter().enumerate() {
292                let width = Self::calculate_cell_display_width(cell);
293                if i >= column_widths.len() {
294                    column_widths.push(width);
295                } else {
296                    column_widths[i] = column_widths[i].max(width);
297                }
298            }
299        }
300
301        // GFM requires delimiter rows to have at least 3 dashes per column.
302        // To ensure visual alignment, all columns must be at least width 3.
303        let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
304
305        // Adjust column widths to accommodate alignment indicators (colons) in delimiter row
306        // This ensures the delimiter row has the same length as content rows
307        if let Some(delimiter_cells) = delimiter_cells {
308            for (i, cell) in delimiter_cells.iter().enumerate() {
309                if i < final_widths.len() {
310                    let trimmed = cell.trim();
311                    let has_left_colon = trimmed.starts_with(':');
312                    let has_right_colon = trimmed.ends_with(':');
313                    let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
314
315                    // Minimum width needed: 3 dashes + colons
316                    let min_width_for_delimiter = 3 + colon_count;
317                    final_widths[i] = final_widths[i].max(min_width_for_delimiter);
318                }
319            }
320        }
321
322        final_widths
323    }
324
325    fn format_table_row(
326        cells: &[String],
327        column_widths: &[usize],
328        column_alignments: &[ColumnAlignment],
329        is_delimiter: bool,
330    ) -> String {
331        let formatted_cells: Vec<String> = cells
332            .iter()
333            .enumerate()
334            .map(|(i, cell)| {
335                let target_width = column_widths.get(i).copied().unwrap_or(0);
336                if is_delimiter {
337                    let trimmed = cell.trim();
338                    let has_left_colon = trimmed.starts_with(':');
339                    let has_right_colon = trimmed.ends_with(':');
340
341                    // Delimiter rows use the same cell format as content rows: | content |
342                    // The "content" is dashes, possibly with colons for alignment
343                    let dash_count = if has_left_colon && has_right_colon {
344                        target_width.saturating_sub(2)
345                    } else if has_left_colon || has_right_colon {
346                        target_width.saturating_sub(1)
347                    } else {
348                        target_width
349                    };
350
351                    let dashes = "-".repeat(dash_count.max(3)); // Minimum 3 dashes
352                    let delimiter_content = if has_left_colon && has_right_colon {
353                        format!(":{dashes}:")
354                    } else if has_left_colon {
355                        format!(":{dashes}")
356                    } else if has_right_colon {
357                        format!("{dashes}:")
358                    } else {
359                        dashes
360                    };
361
362                    // Add spaces around delimiter content, just like content cells
363                    format!(" {delimiter_content} ")
364                } else {
365                    let trimmed = cell.trim();
366                    let current_width = Self::calculate_cell_display_width(cell);
367                    let padding = target_width.saturating_sub(current_width);
368
369                    // Apply alignment based on column's alignment indicator
370                    let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
371                    match alignment {
372                        ColumnAlignment::Left => {
373                            // Left: content on left, padding on right
374                            format!(" {trimmed}{} ", " ".repeat(padding))
375                        }
376                        ColumnAlignment::Center => {
377                            // Center: split padding on both sides
378                            let left_padding = padding / 2;
379                            let right_padding = padding - left_padding;
380                            format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
381                        }
382                        ColumnAlignment::Right => {
383                            // Right: padding on left, content on right
384                            format!(" {}{trimmed} ", " ".repeat(padding))
385                        }
386                    }
387                }
388            })
389            .collect();
390
391        format!("|{}|", formatted_cells.join("|"))
392    }
393
394    fn format_table_compact(cells: &[String]) -> String {
395        let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
396        format!("|{}|", formatted_cells.join("|"))
397    }
398
399    fn format_table_tight(cells: &[String]) -> String {
400        let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
401        format!("|{}|", formatted_cells.join("|"))
402    }
403
404    fn detect_table_style(table_lines: &[&str]) -> Option<String> {
405        if table_lines.is_empty() {
406            return None;
407        }
408
409        let first_line = table_lines[0];
410        let cells = Self::parse_table_row(first_line);
411
412        if cells.is_empty() {
413            return None;
414        }
415
416        let has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
417
418        let has_single_space = cells.iter().all(|cell| {
419            let trimmed = cell.trim();
420            cell == &format!(" {trimmed} ")
421        });
422
423        if has_no_padding {
424            Some("tight".to_string())
425        } else if has_single_space {
426            Some("compact".to_string())
427        } else {
428            Some("aligned".to_string())
429        }
430    }
431
432    fn fix_table_block(
433        &self,
434        lines: &[&str],
435        table_block: &crate::utils::table_utils::TableBlock,
436    ) -> TableFormatResult {
437        let mut result = Vec::new();
438        let mut auto_compacted = false;
439        let mut aligned_width = None;
440
441        let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
442            .chain(std::iter::once(lines[table_block.delimiter_line]))
443            .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
444            .collect();
445
446        if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
447            return TableFormatResult {
448                lines: table_lines.iter().map(|s| s.to_string()).collect(),
449                auto_compacted: false,
450                aligned_width: None,
451            };
452        }
453
454        let style = self.config.style.as_str();
455
456        match style {
457            "any" => {
458                let detected_style = Self::detect_table_style(&table_lines);
459                if detected_style.is_none() {
460                    return TableFormatResult {
461                        lines: table_lines.iter().map(|s| s.to_string()).collect(),
462                        auto_compacted: false,
463                        aligned_width: None,
464                    };
465                }
466
467                let target_style = detected_style.unwrap();
468
469                // Parse column alignments from delimiter row (always at index 1)
470                let delimiter_cells = Self::parse_table_row(table_lines[1]);
471                let column_alignments = Self::parse_column_alignments(&delimiter_cells);
472
473                for line in &table_lines {
474                    let cells = Self::parse_table_row(line);
475                    match target_style.as_str() {
476                        "tight" => result.push(Self::format_table_tight(&cells)),
477                        "compact" => result.push(Self::format_table_compact(&cells)),
478                        _ => {
479                            let column_widths = Self::calculate_column_widths(&table_lines);
480                            let is_delimiter = Self::is_delimiter_row(&cells);
481                            result.push(Self::format_table_row(
482                                &cells,
483                                &column_widths,
484                                &column_alignments,
485                                is_delimiter,
486                            ));
487                        }
488                    }
489                }
490            }
491            "compact" => {
492                for line in table_lines {
493                    let cells = Self::parse_table_row(line);
494                    result.push(Self::format_table_compact(&cells));
495                }
496            }
497            "tight" => {
498                for line in table_lines {
499                    let cells = Self::parse_table_row(line);
500                    result.push(Self::format_table_tight(&cells));
501                }
502            }
503            "aligned" => {
504                let column_widths = Self::calculate_column_widths(&table_lines);
505
506                // Calculate aligned table width: 1 (leading pipe) + num_columns * 3 (| cell |) + sum(column_widths)
507                let num_columns = column_widths.len();
508                let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
509                aligned_width = Some(calc_aligned_width);
510
511                // Auto-compact: if aligned table exceeds max width, use compact formatting instead
512                if calc_aligned_width > self.effective_max_width() {
513                    auto_compacted = true;
514                    for line in table_lines {
515                        let cells = Self::parse_table_row(line);
516                        result.push(Self::format_table_compact(&cells));
517                    }
518                } else {
519                    // Parse column alignments from delimiter row (always at index 1)
520                    let delimiter_cells = Self::parse_table_row(table_lines[1]);
521                    let column_alignments = Self::parse_column_alignments(&delimiter_cells);
522
523                    for line in table_lines {
524                        let cells = Self::parse_table_row(line);
525                        let is_delimiter = Self::is_delimiter_row(&cells);
526                        result.push(Self::format_table_row(
527                            &cells,
528                            &column_widths,
529                            &column_alignments,
530                            is_delimiter,
531                        ));
532                    }
533                }
534            }
535            _ => {
536                return TableFormatResult {
537                    lines: table_lines.iter().map(|s| s.to_string()).collect(),
538                    auto_compacted: false,
539                    aligned_width: None,
540                };
541            }
542        }
543
544        TableFormatResult {
545            lines: result,
546            auto_compacted,
547            aligned_width,
548        }
549    }
550}
551
552impl Rule for MD060TableFormat {
553    fn name(&self) -> &'static str {
554        "MD060"
555    }
556
557    fn description(&self) -> &'static str {
558        "Table columns should be consistently aligned"
559    }
560
561    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
562        !self.config.enabled || !ctx.likely_has_tables()
563    }
564
565    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
566        if !self.config.enabled {
567            return Ok(Vec::new());
568        }
569
570        let content = ctx.content;
571        let line_index = &ctx.line_index;
572        let mut warnings = Vec::new();
573
574        let lines: Vec<&str> = content.lines().collect();
575        let table_blocks = &ctx.table_blocks;
576
577        for table_block in table_blocks {
578            let format_result = self.fix_table_block(&lines, table_block);
579
580            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
581                .chain(std::iter::once(table_block.delimiter_line))
582                .chain(table_block.content_lines.iter().copied())
583                .collect();
584
585            for (i, &line_idx) in table_line_indices.iter().enumerate() {
586                let original = lines[line_idx];
587                let fixed = &format_result.lines[i];
588
589                if original != fixed {
590                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
591
592                    let message = if format_result.auto_compacted {
593                        if let Some(width) = format_result.aligned_width {
594                            format!(
595                                "Table too wide for aligned formatting ({} chars > max-width: {})",
596                                width,
597                                self.effective_max_width()
598                            )
599                        } else {
600                            "Table too wide for aligned formatting".to_string()
601                        }
602                    } else {
603                        "Table columns should be aligned".to_string()
604                    };
605
606                    warnings.push(LintWarning {
607                        rule_name: Some(self.name().to_string()),
608                        severity: Severity::Warning,
609                        message,
610                        line: start_line,
611                        column: start_col,
612                        end_line,
613                        end_column: end_col,
614                        fix: Some(crate::rule::Fix {
615                            range: line_index.whole_line_range(line_idx + 1),
616                            replacement: if line_idx < lines.len() - 1 {
617                                format!("{fixed}\n")
618                            } else {
619                                fixed.clone()
620                            },
621                        }),
622                    });
623                }
624            }
625        }
626
627        Ok(warnings)
628    }
629
630    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
631        if !self.config.enabled {
632            return Ok(ctx.content.to_string());
633        }
634
635        let content = ctx.content;
636        let lines: Vec<&str> = content.lines().collect();
637        let table_blocks = &ctx.table_blocks;
638
639        let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
640
641        for table_block in table_blocks {
642            let format_result = self.fix_table_block(&lines, table_block);
643
644            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
645                .chain(std::iter::once(table_block.delimiter_line))
646                .chain(table_block.content_lines.iter().copied())
647                .collect();
648
649            for (i, &line_idx) in table_line_indices.iter().enumerate() {
650                result_lines[line_idx] = format_result.lines[i].clone();
651            }
652        }
653
654        let mut fixed = result_lines.join("\n");
655        if content.ends_with('\n') && !fixed.ends_with('\n') {
656            fixed.push('\n');
657        }
658        Ok(fixed)
659    }
660
661    fn as_any(&self) -> &dyn std::any::Any {
662        self
663    }
664
665    fn default_config_section(&self) -> Option<(String, toml::Value)> {
666        let json_value = serde_json::to_value(&self.config).ok()?;
667        Some((
668            self.name().to_string(),
669            crate::rule_config_serde::json_to_toml_value(&json_value)?,
670        ))
671    }
672
673    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
674    where
675        Self: Sized,
676    {
677        let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
678        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
679        Box::new(Self::from_config_struct(rule_config, md013_config.line_length))
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::lint_context::LintContext;
687
688    #[test]
689    fn test_md060_disabled_by_default() {
690        let rule = MD060TableFormat::default();
691        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
693
694        let warnings = rule.check(&ctx).unwrap();
695        assert_eq!(warnings.len(), 0);
696
697        let fixed = rule.fix(&ctx).unwrap();
698        assert_eq!(fixed, content);
699    }
700
701    #[test]
702    fn test_md060_align_simple_ascii_table() {
703        let rule = MD060TableFormat::new(true, "aligned".to_string());
704
705        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
707
708        let fixed = rule.fix(&ctx).unwrap();
709        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
710        assert_eq!(fixed, expected);
711
712        // Verify all rows have equal length in aligned mode
713        let lines: Vec<&str> = fixed.lines().collect();
714        assert_eq!(lines[0].len(), lines[1].len());
715        assert_eq!(lines[1].len(), lines[2].len());
716    }
717
718    #[test]
719    fn test_md060_cjk_characters_aligned_correctly() {
720        let rule = MD060TableFormat::new(true, "aligned".to_string());
721
722        let content = "| Name | Age |\n|---|---|\n| δΈ­ζ–‡ | 30 |";
723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724
725        let fixed = rule.fix(&ctx).unwrap();
726
727        let lines: Vec<&str> = fixed.lines().collect();
728        let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
729        let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
730
731        let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
732        let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
733
734        assert_eq!(width1, width3);
735    }
736
737    #[test]
738    fn test_md060_basic_emoji() {
739        let rule = MD060TableFormat::new(true, "aligned".to_string());
740
741        let content = "| Status | Name |\n|---|---|\n| βœ… | Test |";
742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
743
744        let fixed = rule.fix(&ctx).unwrap();
745        assert!(fixed.contains("Status"));
746    }
747
748    #[test]
749    fn test_md060_zwj_emoji_skipped() {
750        let rule = MD060TableFormat::new(true, "aligned".to_string());
751
752        let content = "| Emoji | Name |\n|---|---|\n| πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ | Family |";
753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
754
755        let fixed = rule.fix(&ctx).unwrap();
756        assert_eq!(fixed, content);
757    }
758
759    #[test]
760    fn test_md060_inline_code_with_pipes() {
761        let rule = MD060TableFormat::new(true, "aligned".to_string());
762
763        let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]|[0-9]` |";
764        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
765
766        let fixed = rule.fix(&ctx).unwrap();
767        assert!(fixed.contains("`[0-9]|[0-9]`"));
768    }
769
770    #[test]
771    fn test_md060_compact_style() {
772        let rule = MD060TableFormat::new(true, "compact".to_string());
773
774        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
775        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
776
777        let fixed = rule.fix(&ctx).unwrap();
778        let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
779        assert_eq!(fixed, expected);
780    }
781
782    #[test]
783    fn test_md060_tight_style() {
784        let rule = MD060TableFormat::new(true, "tight".to_string());
785
786        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
787        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
788
789        let fixed = rule.fix(&ctx).unwrap();
790        let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
791        assert_eq!(fixed, expected);
792    }
793
794    #[test]
795    fn test_md060_any_style_consistency() {
796        let rule = MD060TableFormat::new(true, "any".to_string());
797
798        // Table is already compact, should stay compact
799        let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
801
802        let fixed = rule.fix(&ctx).unwrap();
803        assert_eq!(fixed, content);
804
805        // Table is aligned, should stay aligned
806        let content_aligned = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
807        let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard);
808
809        let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
810        assert_eq!(fixed_aligned, content_aligned);
811    }
812
813    #[test]
814    fn test_md060_empty_cells() {
815        let rule = MD060TableFormat::new(true, "aligned".to_string());
816
817        let content = "| A | B |\n|---|---|\n|  | X |";
818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
819
820        let fixed = rule.fix(&ctx).unwrap();
821        assert!(fixed.contains("|"));
822    }
823
824    #[test]
825    fn test_md060_mixed_content() {
826        let rule = MD060TableFormat::new(true, "aligned".to_string());
827
828        let content = "| Name | Age | City |\n|---|---|---|\n| δΈ­ζ–‡ | 30 | NYC |";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830
831        let fixed = rule.fix(&ctx).unwrap();
832        assert!(fixed.contains("δΈ­ζ–‡"));
833        assert!(fixed.contains("NYC"));
834    }
835
836    #[test]
837    fn test_md060_preserve_alignment_indicators() {
838        let rule = MD060TableFormat::new(true, "aligned".to_string());
839
840        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
842
843        let fixed = rule.fix(&ctx).unwrap();
844
845        assert!(fixed.contains(":---"), "Should contain left alignment");
846        assert!(fixed.contains(":----:"), "Should contain center alignment");
847        assert!(fixed.contains("----:"), "Should contain right alignment");
848    }
849
850    #[test]
851    fn test_md060_minimum_column_width() {
852        let rule = MD060TableFormat::new(true, "aligned".to_string());
853
854        // Test with very short column content to ensure minimum width of 3
855        // GFM requires at least 3 dashes in delimiter rows
856        let content = "| ID | Name |\n|-|-|\n| 1 | A |";
857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
858
859        let fixed = rule.fix(&ctx).unwrap();
860
861        let lines: Vec<&str> = fixed.lines().collect();
862        assert_eq!(lines[0].len(), lines[1].len());
863        assert_eq!(lines[1].len(), lines[2].len());
864
865        // Verify minimum width is enforced
866        assert!(fixed.contains("ID "), "Short content should be padded");
867        assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
868    }
869
870    #[test]
871    fn test_md060_auto_compact_exceeds_default_threshold() {
872        // Default max_width = 0, which inherits from default MD013 line_length = 80
873        let config = MD060Config {
874            enabled: true,
875            style: "aligned".to_string(),
876            max_width: 0,
877        };
878        let rule = MD060TableFormat::from_config_struct(config, 80);
879
880        // Table that would be 85 chars when aligned (exceeds 80)
881        // Formula: 1 + (3 * 3) + (20 + 20 + 30) = 1 + 9 + 70 = 80 chars
882        // But with actual content padding it will exceed
883        let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
885
886        let fixed = rule.fix(&ctx).unwrap();
887
888        // Should use compact formatting (single spaces)
889        assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
890        assert!(fixed.contains("| --- | --- | --- |"));
891        assert!(fixed.contains("| Short | Data | Here |"));
892
893        // Verify it's compact (no extra padding)
894        let lines: Vec<&str> = fixed.lines().collect();
895        // In compact mode, lines can have different lengths
896        assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
897    }
898
899    #[test]
900    fn test_md060_auto_compact_exceeds_explicit_threshold() {
901        // Explicit max_width = 50
902        let config = MD060Config {
903            enabled: true,
904            style: "aligned".to_string(),
905            max_width: 50,
906        };
907        let rule = MD060TableFormat::from_config_struct(config, 80); // MD013 setting doesn't matter
908
909        // Table that would exceed 50 chars when aligned
910        // Column widths: 25 + 25 + 25 = 75 chars
911        // Formula: 1 + (3 * 3) + 75 = 85 chars (exceeds 50)
912        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
913        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
914
915        let fixed = rule.fix(&ctx).unwrap();
916
917        // Should use compact formatting (single spaces, no extra padding)
918        assert!(
919            fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
920        );
921        assert!(fixed.contains("| --- | --- | --- |"));
922        assert!(fixed.contains("| Data | Data | Data |"));
923
924        // Verify it's compact (lines have different lengths)
925        let lines: Vec<&str> = fixed.lines().collect();
926        assert!(lines[0].len() != lines[2].len());
927    }
928
929    #[test]
930    fn test_md060_stays_aligned_under_threshold() {
931        // max_width = 100, table will be under this
932        let config = MD060Config {
933            enabled: true,
934            style: "aligned".to_string(),
935            max_width: 100,
936        };
937        let rule = MD060TableFormat::from_config_struct(config, 80);
938
939        // Small table that fits well under 100 chars
940        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
941        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
942
943        let fixed = rule.fix(&ctx).unwrap();
944
945        // Should use aligned formatting (all lines same length)
946        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
947        assert_eq!(fixed, expected);
948
949        let lines: Vec<&str> = fixed.lines().collect();
950        assert_eq!(lines[0].len(), lines[1].len());
951        assert_eq!(lines[1].len(), lines[2].len());
952    }
953
954    #[test]
955    fn test_md060_width_calculation_formula() {
956        // Verify the width calculation formula: 1 + (num_columns * 3) + sum(column_widths)
957        let config = MD060Config {
958            enabled: true,
959            style: "aligned".to_string(),
960            max_width: 0,
961        };
962        let rule = MD060TableFormat::from_config_struct(config, 30);
963
964        // Create a table where we know exact column widths: 5 + 5 + 5 = 15
965        // Expected aligned width: 1 + (3 * 3) + 15 = 1 + 9 + 15 = 25 chars
966        // This is under 30, so should stay aligned
967        let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
968        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
969
970        let fixed = rule.fix(&ctx).unwrap();
971
972        // Should be aligned
973        let lines: Vec<&str> = fixed.lines().collect();
974        assert_eq!(lines[0].len(), lines[1].len());
975        assert_eq!(lines[1].len(), lines[2].len());
976        assert_eq!(lines[0].len(), 25); // Verify formula
977
978        // Now test with threshold = 24 (just under aligned width)
979        let config_tight = MD060Config {
980            enabled: true,
981            style: "aligned".to_string(),
982            max_width: 24,
983        };
984        let rule_tight = MD060TableFormat::from_config_struct(config_tight, 80);
985
986        let fixed_compact = rule_tight.fix(&ctx).unwrap();
987
988        // Should be compact now (25 > 24)
989        assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
990        assert!(fixed_compact.contains("| --- | --- | --- |"));
991    }
992
993    #[test]
994    fn test_md060_very_wide_table_auto_compacts() {
995        let config = MD060Config {
996            enabled: true,
997            style: "aligned".to_string(),
998            max_width: 0,
999        };
1000        let rule = MD060TableFormat::from_config_struct(config, 80);
1001
1002        // Very wide table with many columns
1003        // 8 columns with widths of 12 chars each = 96 chars
1004        // Formula: 1 + (8 * 3) + 96 = 121 chars (exceeds 80)
1005        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 |";
1006        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1007
1008        let fixed = rule.fix(&ctx).unwrap();
1009
1010        // Should be compact (table would be way over 80 chars aligned)
1011        assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1012        assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1013    }
1014
1015    #[test]
1016    fn test_md060_inherit_from_md013_line_length() {
1017        // max_width = 0 should inherit from MD013's line_length
1018        let config = MD060Config {
1019            enabled: true,
1020            style: "aligned".to_string(),
1021            max_width: 0, // Inherit
1022        };
1023
1024        // Test with different MD013 line_length values
1025        let rule_80 = MD060TableFormat::from_config_struct(config.clone(), 80);
1026        let rule_120 = MD060TableFormat::from_config_struct(config.clone(), 120);
1027
1028        // Medium-sized table
1029        let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1030        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1031
1032        // With 80 char limit, likely compacts
1033        let _fixed_80 = rule_80.fix(&ctx).unwrap();
1034
1035        // With 120 char limit, likely stays aligned
1036        let fixed_120 = rule_120.fix(&ctx).unwrap();
1037
1038        // Verify 120 is aligned (all lines same length)
1039        let lines_120: Vec<&str> = fixed_120.lines().collect();
1040        assert_eq!(lines_120[0].len(), lines_120[1].len());
1041        assert_eq!(lines_120[1].len(), lines_120[2].len());
1042    }
1043
1044    #[test]
1045    fn test_md060_edge_case_exactly_at_threshold() {
1046        // Create table that's exactly at the threshold
1047        // Formula: 1 + (num_columns * 3) + sum(column_widths) = max_width
1048        // For 2 columns with widths 5 and 5: 1 + 6 + 10 = 17
1049        let config = MD060Config {
1050            enabled: true,
1051            style: "aligned".to_string(),
1052            max_width: 17,
1053        };
1054        let rule = MD060TableFormat::from_config_struct(config, 80);
1055
1056        let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1057        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1058
1059        let fixed = rule.fix(&ctx).unwrap();
1060
1061        // At threshold (17 <= 17), should stay aligned
1062        let lines: Vec<&str> = fixed.lines().collect();
1063        assert_eq!(lines[0].len(), 17);
1064        assert_eq!(lines[0].len(), lines[1].len());
1065        assert_eq!(lines[1].len(), lines[2].len());
1066
1067        // Now test with threshold = 16 (just under)
1068        let config_under = MD060Config {
1069            enabled: true,
1070            style: "aligned".to_string(),
1071            max_width: 16,
1072        };
1073        let rule_under = MD060TableFormat::from_config_struct(config_under, 80);
1074
1075        let fixed_compact = rule_under.fix(&ctx).unwrap();
1076
1077        // Should compact (17 > 16)
1078        assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1079        assert!(fixed_compact.contains("| --- | --- |"));
1080    }
1081
1082    #[test]
1083    fn test_md060_auto_compact_warning_message() {
1084        // Verify that auto-compact generates an informative warning
1085        let config = MD060Config {
1086            enabled: true,
1087            style: "aligned".to_string(),
1088            max_width: 50,
1089        };
1090        let rule = MD060TableFormat::from_config_struct(config, 80);
1091
1092        // Table that will be auto-compacted (exceeds 50 chars when aligned)
1093        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1094        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1095
1096        let warnings = rule.check(&ctx).unwrap();
1097
1098        // Should generate warnings with auto-compact message
1099        assert!(!warnings.is_empty(), "Should generate warnings");
1100
1101        let auto_compact_warnings: Vec<_> = warnings
1102            .iter()
1103            .filter(|w| w.message.contains("too wide for aligned formatting"))
1104            .collect();
1105
1106        assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1107
1108        // Verify the warning message includes the width and threshold
1109        let first_warning = auto_compact_warnings[0];
1110        assert!(first_warning.message.contains("85 chars > max-width: 50"));
1111        assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1112    }
1113
1114    #[test]
1115    fn test_md060_regular_alignment_warning_message() {
1116        // Verify that regular alignment (not auto-compact) generates normal warning
1117        let config = MD060Config {
1118            enabled: true,
1119            style: "aligned".to_string(),
1120            max_width: 100, // Large enough to not trigger auto-compact
1121        };
1122        let rule = MD060TableFormat::from_config_struct(config, 80);
1123
1124        // Small misaligned table
1125        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1127
1128        let warnings = rule.check(&ctx).unwrap();
1129
1130        // Should generate warnings
1131        assert!(!warnings.is_empty(), "Should generate warnings");
1132
1133        // Verify it's the standard alignment message, not auto-compact
1134        assert!(warnings[0].message.contains("Table columns should be aligned"));
1135        assert!(!warnings[0].message.contains("too wide"));
1136        assert!(!warnings[0].message.contains("max-width"));
1137    }
1138}