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