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        use crate::types::LineLength;
160        Self {
161            config: MD060Config {
162                enabled,
163                style,
164                max_width: LineLength::from_const(0),
165            },
166            md013_line_length: 80, // Default MD013 line_length
167        }
168    }
169
170    pub fn from_config_struct(config: MD060Config, md013_line_length: usize) -> Self {
171        Self {
172            config,
173            md013_line_length,
174        }
175    }
176
177    /// Get the effective max width for table formatting.
178    ///
179    /// - If `max_width` is 0, inherits from MD013's `line_length`
180    /// - Otherwise, uses the explicitly configured `max_width`
181    fn effective_max_width(&self) -> usize {
182        if self.config.max_width.is_unlimited() {
183            self.md013_line_length
184        } else {
185            self.config.max_width.get()
186        }
187    }
188
189    /// Check if text contains characters that break Unicode width calculations
190    ///
191    /// Tables with these characters are skipped to avoid alignment corruption:
192    /// - Zero-Width Joiner (ZWJ, U+200D): Complex emoji like πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
193    /// - Zero-Width Space (ZWS, U+200B): Invisible word break opportunity
194    /// - Zero-Width Non-Joiner (ZWNJ, U+200C): Prevents ligature formation
195    /// - Word Joiner (WJ, U+2060): Prevents line breaks without taking space
196    ///
197    /// These characters have inconsistent display widths across terminals,
198    /// making accurate alignment impossible.
199    fn contains_problematic_chars(text: &str) -> bool {
200        text.contains('\u{200D}')  // ZWJ
201            || text.contains('\u{200B}')  // ZWS
202            || text.contains('\u{200C}')  // ZWNJ
203            || text.contains('\u{2060}') // Word Joiner
204    }
205
206    fn calculate_cell_display_width(cell_content: &str) -> usize {
207        let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
208        masked.trim().width()
209    }
210
211    fn parse_table_row(line: &str) -> Vec<String> {
212        let trimmed = line.trim();
213        let masked = TableUtils::mask_pipes_for_table_parsing(trimmed);
214
215        let has_leading = masked.starts_with('|');
216        let has_trailing = masked.ends_with('|');
217
218        let mut masked_content = masked.as_str();
219        let mut orig_content = trimmed;
220
221        if has_leading {
222            masked_content = &masked_content[1..];
223            orig_content = &orig_content[1..];
224        }
225        if has_trailing && !masked_content.is_empty() {
226            masked_content = &masked_content[..masked_content.len() - 1];
227            orig_content = &orig_content[..orig_content.len() - 1];
228        }
229
230        let masked_parts: Vec<&str> = masked_content.split('|').collect();
231        let mut cells = Vec::new();
232        let mut pos = 0;
233
234        for masked_cell in masked_parts {
235            let cell_len = masked_cell.len();
236            let orig_cell = if pos + cell_len <= orig_content.len() {
237                &orig_content[pos..pos + cell_len]
238            } else {
239                masked_cell
240            };
241            cells.push(orig_cell.to_string());
242            pos += cell_len + 1;
243        }
244
245        cells
246    }
247
248    fn is_delimiter_row(row: &[String]) -> bool {
249        if row.is_empty() {
250            return false;
251        }
252        row.iter().all(|cell| {
253            let trimmed = cell.trim();
254            // A delimiter cell must contain at least one dash
255            // Empty cells are not delimiter cells
256            !trimmed.is_empty()
257                && trimmed.contains('-')
258                && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
259        })
260    }
261
262    fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
263        delimiter_row
264            .iter()
265            .map(|cell| {
266                let trimmed = cell.trim();
267                let has_left_colon = trimmed.starts_with(':');
268                let has_right_colon = trimmed.ends_with(':');
269
270                match (has_left_colon, has_right_colon) {
271                    (true, true) => ColumnAlignment::Center,
272                    (false, true) => ColumnAlignment::Right,
273                    _ => ColumnAlignment::Left,
274                }
275            })
276            .collect()
277    }
278
279    fn calculate_column_widths(table_lines: &[&str]) -> Vec<usize> {
280        let mut column_widths = Vec::new();
281        let mut delimiter_cells: Option<Vec<String>> = None;
282
283        for line in table_lines {
284            let cells = Self::parse_table_row(line);
285
286            // Save delimiter row for later processing, but don't use it for width calculation
287            if Self::is_delimiter_row(&cells) {
288                delimiter_cells = Some(cells);
289                continue;
290            }
291
292            for (i, cell) in cells.iter().enumerate() {
293                let width = Self::calculate_cell_display_width(cell);
294                if i >= column_widths.len() {
295                    column_widths.push(width);
296                } else {
297                    column_widths[i] = column_widths[i].max(width);
298                }
299            }
300        }
301
302        // GFM requires delimiter rows to have at least 3 dashes per column.
303        // To ensure visual alignment, all columns must be at least width 3.
304        let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
305
306        // Adjust column widths to accommodate alignment indicators (colons) in delimiter row
307        // This ensures the delimiter row has the same length as content rows
308        if let Some(delimiter_cells) = delimiter_cells {
309            for (i, cell) in delimiter_cells.iter().enumerate() {
310                if i < final_widths.len() {
311                    let trimmed = cell.trim();
312                    let has_left_colon = trimmed.starts_with(':');
313                    let has_right_colon = trimmed.ends_with(':');
314                    let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
315
316                    // Minimum width needed: 3 dashes + colons
317                    let min_width_for_delimiter = 3 + colon_count;
318                    final_widths[i] = final_widths[i].max(min_width_for_delimiter);
319                }
320            }
321        }
322
323        final_widths
324    }
325
326    fn format_table_row(
327        cells: &[String],
328        column_widths: &[usize],
329        column_alignments: &[ColumnAlignment],
330        is_delimiter: bool,
331    ) -> String {
332        let formatted_cells: Vec<String> = cells
333            .iter()
334            .enumerate()
335            .map(|(i, cell)| {
336                let target_width = column_widths.get(i).copied().unwrap_or(0);
337                if is_delimiter {
338                    let trimmed = cell.trim();
339                    let has_left_colon = trimmed.starts_with(':');
340                    let has_right_colon = trimmed.ends_with(':');
341
342                    // Delimiter rows use the same cell format as content rows: | content |
343                    // The "content" is dashes, possibly with colons for alignment
344                    let dash_count = if has_left_colon && has_right_colon {
345                        target_width.saturating_sub(2)
346                    } else if has_left_colon || has_right_colon {
347                        target_width.saturating_sub(1)
348                    } else {
349                        target_width
350                    };
351
352                    let dashes = "-".repeat(dash_count.max(3)); // Minimum 3 dashes
353                    let delimiter_content = if has_left_colon && has_right_colon {
354                        format!(":{dashes}:")
355                    } else if has_left_colon {
356                        format!(":{dashes}")
357                    } else if has_right_colon {
358                        format!("{dashes}:")
359                    } else {
360                        dashes
361                    };
362
363                    // Add spaces around delimiter content, just like content cells
364                    format!(" {delimiter_content} ")
365                } else {
366                    let trimmed = cell.trim();
367                    let current_width = Self::calculate_cell_display_width(cell);
368                    let padding = target_width.saturating_sub(current_width);
369
370                    // Apply alignment based on column's alignment indicator
371                    let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
372                    match alignment {
373                        ColumnAlignment::Left => {
374                            // Left: content on left, padding on right
375                            format!(" {trimmed}{} ", " ".repeat(padding))
376                        }
377                        ColumnAlignment::Center => {
378                            // Center: split padding on both sides
379                            let left_padding = padding / 2;
380                            let right_padding = padding - left_padding;
381                            format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
382                        }
383                        ColumnAlignment::Right => {
384                            // Right: padding on left, content on right
385                            format!(" {}{trimmed} ", " ".repeat(padding))
386                        }
387                    }
388                }
389            })
390            .collect();
391
392        format!("|{}|", formatted_cells.join("|"))
393    }
394
395    fn format_table_compact(cells: &[String]) -> String {
396        let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
397        format!("|{}|", formatted_cells.join("|"))
398    }
399
400    fn format_table_tight(cells: &[String]) -> String {
401        let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
402        format!("|{}|", formatted_cells.join("|"))
403    }
404
405    fn detect_table_style(table_lines: &[&str]) -> Option<String> {
406        if table_lines.is_empty() {
407            return None;
408        }
409
410        // Check all rows (except delimiter) to determine consistent style
411        // A table is only "tight" or "compact" if ALL rows follow that pattern
412        let mut is_tight = true;
413        let mut is_compact = true;
414
415        for line in table_lines {
416            let cells = Self::parse_table_row(line);
417
418            if cells.is_empty() {
419                continue;
420            }
421
422            // Skip delimiter rows when detecting style
423            if Self::is_delimiter_row(&cells) {
424                continue;
425            }
426
427            // Check if this row has no padding
428            let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
429
430            // Check if this row has exactly single-space padding
431            let row_has_single_space = cells.iter().all(|cell| {
432                let trimmed = cell.trim();
433                cell == &format!(" {trimmed} ")
434            });
435
436            // If any row doesn't match tight, the table isn't tight
437            if !row_has_no_padding {
438                is_tight = false;
439            }
440
441            // If any row doesn't match compact, the table isn't compact
442            if !row_has_single_space {
443                is_compact = false;
444            }
445
446            // Early exit: if neither tight nor compact, it must be aligned
447            if !is_tight && !is_compact {
448                return Some("aligned".to_string());
449            }
450        }
451
452        // Return the most restrictive style that matches
453        if is_tight {
454            Some("tight".to_string())
455        } else if is_compact {
456            Some("compact".to_string())
457        } else {
458            Some("aligned".to_string())
459        }
460    }
461
462    fn fix_table_block(
463        &self,
464        lines: &[&str],
465        table_block: &crate::utils::table_utils::TableBlock,
466    ) -> TableFormatResult {
467        let mut result = Vec::new();
468        let mut auto_compacted = false;
469        let mut aligned_width = None;
470
471        let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
472            .chain(std::iter::once(lines[table_block.delimiter_line]))
473            .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
474            .collect();
475
476        if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
477            return TableFormatResult {
478                lines: table_lines.iter().map(|s| s.to_string()).collect(),
479                auto_compacted: false,
480                aligned_width: None,
481            };
482        }
483
484        let style = self.config.style.as_str();
485
486        match style {
487            "any" => {
488                let detected_style = Self::detect_table_style(&table_lines);
489                if detected_style.is_none() {
490                    return TableFormatResult {
491                        lines: table_lines.iter().map(|s| s.to_string()).collect(),
492                        auto_compacted: false,
493                        aligned_width: None,
494                    };
495                }
496
497                let target_style = detected_style.unwrap();
498
499                // Parse column alignments from delimiter row (always at index 1)
500                let delimiter_cells = Self::parse_table_row(table_lines[1]);
501                let column_alignments = Self::parse_column_alignments(&delimiter_cells);
502
503                for line in &table_lines {
504                    let cells = Self::parse_table_row(line);
505                    match target_style.as_str() {
506                        "tight" => result.push(Self::format_table_tight(&cells)),
507                        "compact" => result.push(Self::format_table_compact(&cells)),
508                        _ => {
509                            let column_widths = Self::calculate_column_widths(&table_lines);
510                            let is_delimiter = Self::is_delimiter_row(&cells);
511                            result.push(Self::format_table_row(
512                                &cells,
513                                &column_widths,
514                                &column_alignments,
515                                is_delimiter,
516                            ));
517                        }
518                    }
519                }
520            }
521            "compact" => {
522                for line in table_lines {
523                    let cells = Self::parse_table_row(line);
524                    result.push(Self::format_table_compact(&cells));
525                }
526            }
527            "tight" => {
528                for line in table_lines {
529                    let cells = Self::parse_table_row(line);
530                    result.push(Self::format_table_tight(&cells));
531                }
532            }
533            "aligned" => {
534                let column_widths = Self::calculate_column_widths(&table_lines);
535
536                // Calculate aligned table width: 1 (leading pipe) + num_columns * 3 (| cell |) + sum(column_widths)
537                let num_columns = column_widths.len();
538                let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
539                aligned_width = Some(calc_aligned_width);
540
541                // Auto-compact: if aligned table exceeds max width, use compact formatting instead
542                if calc_aligned_width > self.effective_max_width() {
543                    auto_compacted = true;
544                    for line in table_lines {
545                        let cells = Self::parse_table_row(line);
546                        result.push(Self::format_table_compact(&cells));
547                    }
548                } else {
549                    // Parse column alignments from delimiter row (always at index 1)
550                    let delimiter_cells = Self::parse_table_row(table_lines[1]);
551                    let column_alignments = Self::parse_column_alignments(&delimiter_cells);
552
553                    for line in table_lines {
554                        let cells = Self::parse_table_row(line);
555                        let is_delimiter = Self::is_delimiter_row(&cells);
556                        result.push(Self::format_table_row(
557                            &cells,
558                            &column_widths,
559                            &column_alignments,
560                            is_delimiter,
561                        ));
562                    }
563                }
564            }
565            _ => {
566                return TableFormatResult {
567                    lines: table_lines.iter().map(|s| s.to_string()).collect(),
568                    auto_compacted: false,
569                    aligned_width: None,
570                };
571            }
572        }
573
574        TableFormatResult {
575            lines: result,
576            auto_compacted,
577            aligned_width,
578        }
579    }
580}
581
582impl Rule for MD060TableFormat {
583    fn name(&self) -> &'static str {
584        "MD060"
585    }
586
587    fn description(&self) -> &'static str {
588        "Table columns should be consistently aligned"
589    }
590
591    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
592        !self.config.enabled || !ctx.likely_has_tables()
593    }
594
595    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
596        if !self.config.enabled {
597            return Ok(Vec::new());
598        }
599
600        let content = ctx.content;
601        let line_index = &ctx.line_index;
602        let mut warnings = Vec::new();
603
604        let lines: Vec<&str> = content.lines().collect();
605        let table_blocks = &ctx.table_blocks;
606
607        for table_block in table_blocks {
608            let format_result = self.fix_table_block(&lines, table_block);
609
610            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
611                .chain(std::iter::once(table_block.delimiter_line))
612                .chain(table_block.content_lines.iter().copied())
613                .collect();
614
615            for (i, &line_idx) in table_line_indices.iter().enumerate() {
616                let original = lines[line_idx];
617                let fixed = &format_result.lines[i];
618
619                if original != fixed {
620                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
621
622                    let message = if format_result.auto_compacted {
623                        if let Some(width) = format_result.aligned_width {
624                            format!(
625                                "Table too wide for aligned formatting ({} chars > max-width: {})",
626                                width,
627                                self.effective_max_width()
628                            )
629                        } else {
630                            "Table too wide for aligned formatting".to_string()
631                        }
632                    } else {
633                        "Table columns should be aligned".to_string()
634                    };
635
636                    warnings.push(LintWarning {
637                        rule_name: Some(self.name().to_string()),
638                        severity: Severity::Warning,
639                        message,
640                        line: start_line,
641                        column: start_col,
642                        end_line,
643                        end_column: end_col,
644                        fix: Some(crate::rule::Fix {
645                            range: line_index.whole_line_range(line_idx + 1),
646                            replacement: if line_idx < lines.len() - 1 {
647                                format!("{fixed}\n")
648                            } else {
649                                fixed.clone()
650                            },
651                        }),
652                    });
653                }
654            }
655        }
656
657        Ok(warnings)
658    }
659
660    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
661        if !self.config.enabled {
662            return Ok(ctx.content.to_string());
663        }
664
665        let content = ctx.content;
666        let lines: Vec<&str> = content.lines().collect();
667        let table_blocks = &ctx.table_blocks;
668
669        let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
670
671        for table_block in table_blocks {
672            let format_result = self.fix_table_block(&lines, table_block);
673
674            let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
675                .chain(std::iter::once(table_block.delimiter_line))
676                .chain(table_block.content_lines.iter().copied())
677                .collect();
678
679            for (i, &line_idx) in table_line_indices.iter().enumerate() {
680                result_lines[line_idx] = format_result.lines[i].clone();
681            }
682        }
683
684        let mut fixed = result_lines.join("\n");
685        if content.ends_with('\n') && !fixed.ends_with('\n') {
686            fixed.push('\n');
687        }
688        Ok(fixed)
689    }
690
691    fn as_any(&self) -> &dyn std::any::Any {
692        self
693    }
694
695    fn default_config_section(&self) -> Option<(String, toml::Value)> {
696        let json_value = serde_json::to_value(&self.config).ok()?;
697        Some((
698            self.name().to_string(),
699            crate::rule_config_serde::json_to_toml_value(&json_value)?,
700        ))
701    }
702
703    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
704    where
705        Self: Sized,
706    {
707        let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
708        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
709        Box::new(Self::from_config_struct(rule_config, md013_config.line_length.get()))
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::lint_context::LintContext;
717    use crate::types::LineLength;
718
719    #[test]
720    fn test_md060_disabled_by_default() {
721        let rule = MD060TableFormat::default();
722        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724
725        let warnings = rule.check(&ctx).unwrap();
726        assert_eq!(warnings.len(), 0);
727
728        let fixed = rule.fix(&ctx).unwrap();
729        assert_eq!(fixed, content);
730    }
731
732    #[test]
733    fn test_md060_align_simple_ascii_table() {
734        let rule = MD060TableFormat::new(true, "aligned".to_string());
735
736        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
738
739        let fixed = rule.fix(&ctx).unwrap();
740        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
741        assert_eq!(fixed, expected);
742
743        // Verify all rows have equal length in aligned mode
744        let lines: Vec<&str> = fixed.lines().collect();
745        assert_eq!(lines[0].len(), lines[1].len());
746        assert_eq!(lines[1].len(), lines[2].len());
747    }
748
749    #[test]
750    fn test_md060_cjk_characters_aligned_correctly() {
751        let rule = MD060TableFormat::new(true, "aligned".to_string());
752
753        let content = "| Name | Age |\n|---|---|\n| δΈ­ζ–‡ | 30 |";
754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
755
756        let fixed = rule.fix(&ctx).unwrap();
757
758        let lines: Vec<&str> = fixed.lines().collect();
759        let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
760        let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
761
762        let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
763        let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
764
765        assert_eq!(width1, width3);
766    }
767
768    #[test]
769    fn test_md060_basic_emoji() {
770        let rule = MD060TableFormat::new(true, "aligned".to_string());
771
772        let content = "| Status | Name |\n|---|---|\n| βœ… | Test |";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
774
775        let fixed = rule.fix(&ctx).unwrap();
776        assert!(fixed.contains("Status"));
777    }
778
779    #[test]
780    fn test_md060_zwj_emoji_skipped() {
781        let rule = MD060TableFormat::new(true, "aligned".to_string());
782
783        let content = "| Emoji | Name |\n|---|---|\n| πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ | Family |";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785
786        let fixed = rule.fix(&ctx).unwrap();
787        assert_eq!(fixed, content);
788    }
789
790    #[test]
791    fn test_md060_inline_code_with_pipes() {
792        let rule = MD060TableFormat::new(true, "aligned".to_string());
793
794        let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]|[0-9]` |";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
796
797        let fixed = rule.fix(&ctx).unwrap();
798        assert!(fixed.contains("`[0-9]|[0-9]`"));
799    }
800
801    #[test]
802    fn test_md060_compact_style() {
803        let rule = MD060TableFormat::new(true, "compact".to_string());
804
805        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
806        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
807
808        let fixed = rule.fix(&ctx).unwrap();
809        let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
810        assert_eq!(fixed, expected);
811    }
812
813    #[test]
814    fn test_md060_tight_style() {
815        let rule = MD060TableFormat::new(true, "tight".to_string());
816
817        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
819
820        let fixed = rule.fix(&ctx).unwrap();
821        let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
822        assert_eq!(fixed, expected);
823    }
824
825    #[test]
826    fn test_md060_any_style_consistency() {
827        let rule = MD060TableFormat::new(true, "any".to_string());
828
829        // Table is already compact, should stay compact
830        let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
832
833        let fixed = rule.fix(&ctx).unwrap();
834        assert_eq!(fixed, content);
835
836        // Table is aligned, should stay aligned
837        let content_aligned = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
838        let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard);
839
840        let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
841        assert_eq!(fixed_aligned, content_aligned);
842    }
843
844    #[test]
845    fn test_md060_empty_cells() {
846        let rule = MD060TableFormat::new(true, "aligned".to_string());
847
848        let content = "| A | B |\n|---|---|\n|  | X |";
849        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
850
851        let fixed = rule.fix(&ctx).unwrap();
852        assert!(fixed.contains("|"));
853    }
854
855    #[test]
856    fn test_md060_mixed_content() {
857        let rule = MD060TableFormat::new(true, "aligned".to_string());
858
859        let content = "| Name | Age | City |\n|---|---|---|\n| δΈ­ζ–‡ | 30 | NYC |";
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
861
862        let fixed = rule.fix(&ctx).unwrap();
863        assert!(fixed.contains("δΈ­ζ–‡"));
864        assert!(fixed.contains("NYC"));
865    }
866
867    #[test]
868    fn test_md060_preserve_alignment_indicators() {
869        let rule = MD060TableFormat::new(true, "aligned".to_string());
870
871        let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
873
874        let fixed = rule.fix(&ctx).unwrap();
875
876        assert!(fixed.contains(":---"), "Should contain left alignment");
877        assert!(fixed.contains(":----:"), "Should contain center alignment");
878        assert!(fixed.contains("----:"), "Should contain right alignment");
879    }
880
881    #[test]
882    fn test_md060_minimum_column_width() {
883        let rule = MD060TableFormat::new(true, "aligned".to_string());
884
885        // Test with very short column content to ensure minimum width of 3
886        // GFM requires at least 3 dashes in delimiter rows
887        let content = "| ID | Name |\n|-|-|\n| 1 | A |";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889
890        let fixed = rule.fix(&ctx).unwrap();
891
892        let lines: Vec<&str> = fixed.lines().collect();
893        assert_eq!(lines[0].len(), lines[1].len());
894        assert_eq!(lines[1].len(), lines[2].len());
895
896        // Verify minimum width is enforced
897        assert!(fixed.contains("ID "), "Short content should be padded");
898        assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
899    }
900
901    #[test]
902    fn test_md060_auto_compact_exceeds_default_threshold() {
903        // Default max_width = 0, which inherits from default MD013 line_length = 80
904        let config = MD060Config {
905            enabled: true,
906            style: "aligned".to_string(),
907            max_width: LineLength::from_const(0),
908        };
909        let rule = MD060TableFormat::from_config_struct(config, 80);
910
911        // Table that would be 85 chars when aligned (exceeds 80)
912        // Formula: 1 + (3 * 3) + (20 + 20 + 30) = 1 + 9 + 70 = 80 chars
913        // But with actual content padding it will exceed
914        let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
916
917        let fixed = rule.fix(&ctx).unwrap();
918
919        // Should use compact formatting (single spaces)
920        assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
921        assert!(fixed.contains("| --- | --- | --- |"));
922        assert!(fixed.contains("| Short | Data | Here |"));
923
924        // Verify it's compact (no extra padding)
925        let lines: Vec<&str> = fixed.lines().collect();
926        // In compact mode, lines can have different lengths
927        assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
928    }
929
930    #[test]
931    fn test_md060_auto_compact_exceeds_explicit_threshold() {
932        // Explicit max_width = 50
933        let config = MD060Config {
934            enabled: true,
935            style: "aligned".to_string(),
936            max_width: LineLength::from_const(50),
937        };
938        let rule = MD060TableFormat::from_config_struct(config, 80); // MD013 setting doesn't matter
939
940        // Table that would exceed 50 chars when aligned
941        // Column widths: 25 + 25 + 25 = 75 chars
942        // Formula: 1 + (3 * 3) + 75 = 85 chars (exceeds 50)
943        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
944        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
945
946        let fixed = rule.fix(&ctx).unwrap();
947
948        // Should use compact formatting (single spaces, no extra padding)
949        assert!(
950            fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
951        );
952        assert!(fixed.contains("| --- | --- | --- |"));
953        assert!(fixed.contains("| Data | Data | Data |"));
954
955        // Verify it's compact (lines have different lengths)
956        let lines: Vec<&str> = fixed.lines().collect();
957        assert!(lines[0].len() != lines[2].len());
958    }
959
960    #[test]
961    fn test_md060_stays_aligned_under_threshold() {
962        // max_width = 100, table will be under this
963        let config = MD060Config {
964            enabled: true,
965            style: "aligned".to_string(),
966            max_width: LineLength::from_const(100),
967        };
968        let rule = MD060TableFormat::from_config_struct(config, 80);
969
970        // Small table that fits well under 100 chars
971        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
973
974        let fixed = rule.fix(&ctx).unwrap();
975
976        // Should use aligned formatting (all lines same length)
977        let expected = "| Name  | Age |\n| ----- | --- |\n| Alice | 30  |";
978        assert_eq!(fixed, expected);
979
980        let lines: Vec<&str> = fixed.lines().collect();
981        assert_eq!(lines[0].len(), lines[1].len());
982        assert_eq!(lines[1].len(), lines[2].len());
983    }
984
985    #[test]
986    fn test_md060_width_calculation_formula() {
987        // Verify the width calculation formula: 1 + (num_columns * 3) + sum(column_widths)
988        let config = MD060Config {
989            enabled: true,
990            style: "aligned".to_string(),
991            max_width: LineLength::from_const(0),
992        };
993        let rule = MD060TableFormat::from_config_struct(config, 30);
994
995        // Create a table where we know exact column widths: 5 + 5 + 5 = 15
996        // Expected aligned width: 1 + (3 * 3) + 15 = 1 + 9 + 15 = 25 chars
997        // This is under 30, so should stay aligned
998        let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000
1001        let fixed = rule.fix(&ctx).unwrap();
1002
1003        // Should be aligned
1004        let lines: Vec<&str> = fixed.lines().collect();
1005        assert_eq!(lines[0].len(), lines[1].len());
1006        assert_eq!(lines[1].len(), lines[2].len());
1007        assert_eq!(lines[0].len(), 25); // Verify formula
1008
1009        // Now test with threshold = 24 (just under aligned width)
1010        let config_tight = MD060Config {
1011            enabled: true,
1012            style: "aligned".to_string(),
1013            max_width: LineLength::from_const(24),
1014        };
1015        let rule_tight = MD060TableFormat::from_config_struct(config_tight, 80);
1016
1017        let fixed_compact = rule_tight.fix(&ctx).unwrap();
1018
1019        // Should be compact now (25 > 24)
1020        assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1021        assert!(fixed_compact.contains("| --- | --- | --- |"));
1022    }
1023
1024    #[test]
1025    fn test_md060_very_wide_table_auto_compacts() {
1026        let config = MD060Config {
1027            enabled: true,
1028            style: "aligned".to_string(),
1029            max_width: LineLength::from_const(0),
1030        };
1031        let rule = MD060TableFormat::from_config_struct(config, 80);
1032
1033        // Very wide table with many columns
1034        // 8 columns with widths of 12 chars each = 96 chars
1035        // Formula: 1 + (8 * 3) + 96 = 121 chars (exceeds 80)
1036        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 |";
1037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1038
1039        let fixed = rule.fix(&ctx).unwrap();
1040
1041        // Should be compact (table would be way over 80 chars aligned)
1042        assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1043        assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1044    }
1045
1046    #[test]
1047    fn test_md060_inherit_from_md013_line_length() {
1048        // max_width = 0 should inherit from MD013's line_length
1049        let config = MD060Config {
1050            enabled: true,
1051            style: "aligned".to_string(),
1052            max_width: LineLength::from_const(0), // Inherit
1053        };
1054
1055        // Test with different MD013 line_length values
1056        let rule_80 = MD060TableFormat::from_config_struct(config.clone(), 80);
1057        let rule_120 = MD060TableFormat::from_config_struct(config.clone(), 120);
1058
1059        // Medium-sized table
1060        let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062
1063        // With 80 char limit, likely compacts
1064        let _fixed_80 = rule_80.fix(&ctx).unwrap();
1065
1066        // With 120 char limit, likely stays aligned
1067        let fixed_120 = rule_120.fix(&ctx).unwrap();
1068
1069        // Verify 120 is aligned (all lines same length)
1070        let lines_120: Vec<&str> = fixed_120.lines().collect();
1071        assert_eq!(lines_120[0].len(), lines_120[1].len());
1072        assert_eq!(lines_120[1].len(), lines_120[2].len());
1073    }
1074
1075    #[test]
1076    fn test_md060_edge_case_exactly_at_threshold() {
1077        // Create table that's exactly at the threshold
1078        // Formula: 1 + (num_columns * 3) + sum(column_widths) = max_width
1079        // For 2 columns with widths 5 and 5: 1 + 6 + 10 = 17
1080        let config = MD060Config {
1081            enabled: true,
1082            style: "aligned".to_string(),
1083            max_width: LineLength::from_const(17),
1084        };
1085        let rule = MD060TableFormat::from_config_struct(config, 80);
1086
1087        let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1088        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1089
1090        let fixed = rule.fix(&ctx).unwrap();
1091
1092        // At threshold (17 <= 17), should stay aligned
1093        let lines: Vec<&str> = fixed.lines().collect();
1094        assert_eq!(lines[0].len(), 17);
1095        assert_eq!(lines[0].len(), lines[1].len());
1096        assert_eq!(lines[1].len(), lines[2].len());
1097
1098        // Now test with threshold = 16 (just under)
1099        let config_under = MD060Config {
1100            enabled: true,
1101            style: "aligned".to_string(),
1102            max_width: LineLength::from_const(16),
1103        };
1104        let rule_under = MD060TableFormat::from_config_struct(config_under, 80);
1105
1106        let fixed_compact = rule_under.fix(&ctx).unwrap();
1107
1108        // Should compact (17 > 16)
1109        assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1110        assert!(fixed_compact.contains("| --- | --- |"));
1111    }
1112
1113    #[test]
1114    fn test_md060_auto_compact_warning_message() {
1115        // Verify that auto-compact generates an informative warning
1116        let config = MD060Config {
1117            enabled: true,
1118            style: "aligned".to_string(),
1119            max_width: LineLength::from_const(50),
1120        };
1121        let rule = MD060TableFormat::from_config_struct(config, 80);
1122
1123        // Table that will be auto-compacted (exceeds 50 chars when aligned)
1124        let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1126
1127        let warnings = rule.check(&ctx).unwrap();
1128
1129        // Should generate warnings with auto-compact message
1130        assert!(!warnings.is_empty(), "Should generate warnings");
1131
1132        let auto_compact_warnings: Vec<_> = warnings
1133            .iter()
1134            .filter(|w| w.message.contains("too wide for aligned formatting"))
1135            .collect();
1136
1137        assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1138
1139        // Verify the warning message includes the width and threshold
1140        let first_warning = auto_compact_warnings[0];
1141        assert!(first_warning.message.contains("85 chars > max-width: 50"));
1142        assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1143    }
1144
1145    #[test]
1146    fn test_md060_issue_129_detect_style_from_all_rows() {
1147        // Issue #129: detect_table_style should check all rows, not just the first row
1148        // If header row has single-space padding but content rows have extra padding,
1149        // the table should be detected as "aligned" and preserved
1150        let rule = MD060TableFormat::new(true, "any".to_string());
1151
1152        // Table where header looks compact but content is aligned
1153        let content = "| a long heading | another long heading |\n\
1154                       | -------------- | -------------------- |\n\
1155                       | a              | 1                    |\n\
1156                       | b b            | 2                    |\n\
1157                       | c c c          | 3                    |";
1158        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1159
1160        let fixed = rule.fix(&ctx).unwrap();
1161
1162        // Should preserve the aligned formatting of content rows
1163        assert!(
1164            fixed.contains("| a              | 1                    |"),
1165            "Should preserve aligned padding in first content row"
1166        );
1167        assert!(
1168            fixed.contains("| b b            | 2                    |"),
1169            "Should preserve aligned padding in second content row"
1170        );
1171        assert!(
1172            fixed.contains("| c c c          | 3                    |"),
1173            "Should preserve aligned padding in third content row"
1174        );
1175
1176        // Entire table should remain unchanged because it's already properly aligned
1177        assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1178    }
1179
1180    #[test]
1181    fn test_md060_regular_alignment_warning_message() {
1182        // Verify that regular alignment (not auto-compact) generates normal warning
1183        let config = MD060Config {
1184            enabled: true,
1185            style: "aligned".to_string(),
1186            max_width: LineLength::from_const(100), // Large enough to not trigger auto-compact
1187        };
1188        let rule = MD060TableFormat::from_config_struct(config, 80);
1189
1190        // Small misaligned table
1191        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1193
1194        let warnings = rule.check(&ctx).unwrap();
1195
1196        // Should generate warnings
1197        assert!(!warnings.is_empty(), "Should generate warnings");
1198
1199        // Verify it's the standard alignment message, not auto-compact
1200        assert!(warnings[0].message.contains("Table columns should be aligned"));
1201        assert!(!warnings[0].message.contains("too wide"));
1202        assert!(!warnings[0].message.contains("max-width"));
1203    }
1204}