rumdl_lib/rules/
md055_table_pipe_style.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::{TableBlock, TableUtils};
4
5mod md055_config;
6use md055_config::MD055Config;
7
8/// Rule MD055: Table pipe style
9///
10/// See [docs/md055.md](../../docs/md055.md) for full documentation, configuration, and examples.
11///
12/// This rule enforces consistent use of leading and trailing pipe characters in Markdown tables,
13/// which improves readability and ensures uniform document styling.
14///
15/// ## Purpose
16///
17/// - **Consistency**: Ensures uniform table formatting throughout documents
18/// - **Readability**: Well-formatted tables are easier to read and understand
19/// - **Maintainability**: Consistent table syntax makes documents easier to maintain
20/// - **Compatibility**: Some Markdown processors handle different table styles differently
21///
22/// ## Configuration Options
23///
24/// The rule supports the following configuration options:
25///
26/// ```yaml
27/// MD055:
28///   style: "consistent"  # Can be "consistent", "leading_and_trailing", or "no_leading_or_trailing"
29/// ```
30///
31/// ### Style Options
32///
33/// - **consistent**: All tables must use the same style (default)
34/// - **leading_and_trailing**: All tables must have both leading and trailing pipes
35/// - **no_leading_or_trailing**: Tables must not have leading or trailing pipes
36///
37/// ## Examples
38///
39/// ### Leading and Trailing Pipes
40///
41/// ```markdown
42/// | Header 1 | Header 2 | Header 3 |
43/// |----------|----------|----------|
44/// | Cell 1   | Cell 2   | Cell 3   |
45/// | Cell 4   | Cell 5   | Cell 6   |
46/// ```
47///
48/// ### No Leading or Trailing Pipes
49///
50/// ```markdown
51/// Header 1 | Header 2 | Header 3
52/// ---------|----------|---------
53/// Cell 1   | Cell 2   | Cell 3
54/// Cell 4   | Cell 5   | Cell 6
55/// ```
56///
57/// ## Behavior Details
58///
59/// - The rule analyzes each table in the document to determine its pipe style
60/// - With "consistent" style, the first table's style is used as the standard for all others
61/// - The rule handles both the header row, separator row, and content rows
62/// - Tables inside code blocks are ignored
63///
64/// ## Fix Behavior
65///
66/// When applying automatic fixes, this rule:
67/// - Adds or removes leading and trailing pipes as needed
68/// - Preserves the content and alignment of table cells
69/// - Maintains proper spacing around pipe characters
70/// - Updates both header and content rows to match the required style
71///
72/// ## Performance Considerations
73///
74/// The rule includes performance optimizations:
75/// - Efficient table detection with quick checks before detailed analysis
76/// - Smart line-by-line processing to avoid redundant operations
77/// - Optimized string manipulation for pipe character handling
78///
79/// Enforces consistent use of leading and trailing pipe characters in tables
80#[derive(Debug, Default, Clone)]
81pub struct MD055TablePipeStyle {
82    config: MD055Config,
83}
84
85impl MD055TablePipeStyle {
86    pub fn new(style: String) -> Self {
87        Self {
88            config: MD055Config { style },
89        }
90    }
91
92    pub fn from_config_struct(config: MD055Config) -> Self {
93        Self { config }
94    }
95
96    /// Determine the most prevalent table style in a table block
97    fn determine_table_style(&self, table_block: &TableBlock, lines: &[&str]) -> Option<&'static str> {
98        let mut leading_and_trailing_count = 0;
99        let mut no_leading_or_trailing_count = 0;
100        let mut leading_only_count = 0;
101        let mut trailing_only_count = 0;
102
103        // Count style of header row
104        if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
105            match style {
106                "leading_and_trailing" => leading_and_trailing_count += 1,
107                "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
108                "leading_only" => leading_only_count += 1,
109                "trailing_only" => trailing_only_count += 1,
110                _ => {}
111            }
112        }
113
114        // Count style of content rows
115        for &line_idx in &table_block.content_lines {
116            if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
117                match style {
118                    "leading_and_trailing" => leading_and_trailing_count += 1,
119                    "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
120                    "leading_only" => leading_only_count += 1,
121                    "trailing_only" => trailing_only_count += 1,
122                    _ => {}
123                }
124            }
125        }
126
127        // Determine most prevalent style
128        // In case of tie, prefer leading_and_trailing (most common, widely supported)
129        let max_count = leading_and_trailing_count
130            .max(no_leading_or_trailing_count)
131            .max(leading_only_count)
132            .max(trailing_only_count);
133
134        if max_count > 0 {
135            if leading_and_trailing_count == max_count {
136                Some("leading_and_trailing")
137            } else if no_leading_or_trailing_count == max_count {
138                Some("no_leading_or_trailing")
139            } else if leading_only_count == max_count {
140                Some("leading_only")
141            } else if trailing_only_count == max_count {
142                Some("trailing_only")
143            } else {
144                None
145            }
146        } else {
147            None
148        }
149    }
150
151    /// Fix a table row to match the target style
152    /// Uses surgical fixes: only adds/removes pipes, preserves all user formatting
153    fn fix_table_row(&self, line: &str, target_style: &str) -> String {
154        let trimmed = line.trim();
155        if !trimmed.contains('|') {
156            return line.to_string();
157        }
158
159        let has_leading = trimmed.starts_with('|');
160        let has_trailing = trimmed.ends_with('|');
161
162        match target_style {
163            "leading_and_trailing" => {
164                let mut result = trimmed.to_string();
165
166                // Add leading pipe if missing
167                if !has_leading {
168                    result = format!("| {result}");
169                }
170
171                // Add trailing pipe if missing
172                if !has_trailing {
173                    result = format!("{result} |");
174                }
175
176                result
177            }
178            "no_leading_or_trailing" => {
179                let mut result = trimmed;
180
181                // Remove leading pipe if present
182                if has_leading {
183                    result = result.strip_prefix('|').unwrap_or(result);
184                    result = result.trim_start();
185                }
186
187                // Remove trailing pipe if present
188                if has_trailing {
189                    result = result.strip_suffix('|').unwrap_or(result);
190                    result = result.trim_end();
191                }
192
193                result.to_string()
194            }
195            "leading_only" => {
196                let mut result = trimmed.to_string();
197
198                // Add leading pipe if missing
199                if !has_leading {
200                    result = format!("| {result}");
201                }
202
203                // Remove trailing pipe if present
204                if has_trailing {
205                    result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
206                }
207
208                result
209            }
210            "trailing_only" => {
211                let mut result = trimmed;
212
213                // Remove leading pipe if present
214                if has_leading {
215                    result = result.strip_prefix('|').unwrap_or(result).trim_start();
216                }
217
218                let mut result = result.to_string();
219
220                // Add trailing pipe if missing
221                if !has_trailing {
222                    result = format!("{result} |");
223                }
224
225                result
226            }
227            _ => line.to_string(),
228        }
229    }
230}
231
232impl Rule for MD055TablePipeStyle {
233    fn name(&self) -> &'static str {
234        "MD055"
235    }
236
237    fn description(&self) -> &'static str {
238        "Table pipe style should be consistent"
239    }
240
241    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
242        // Skip if no tables present (uses cached pipe count)
243        !ctx.likely_has_tables()
244    }
245
246    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
247        let content = ctx.content;
248        let line_index = &ctx.line_index;
249        let mut warnings = Vec::new();
250
251        // Early return handled by should_skip()
252
253        let lines: Vec<&str> = content.lines().collect();
254
255        // Get the configured style explicitly and validate it
256        let configured_style = match self.config.style.as_str() {
257            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
258                self.config.style.as_str()
259            }
260            _ => {
261                // Invalid style provided, default to "leading_and_trailing"
262                "leading_and_trailing"
263            }
264        };
265
266        // Use pre-computed table blocks from context
267        let table_blocks = &ctx.table_blocks;
268
269        // Process each table block
270        for table_block in table_blocks {
271            // First pass: determine the table's style for "consistent" mode
272            // Count all rows to determine most prevalent style (prevalence-based approach)
273            let table_style = if configured_style == "consistent" {
274                self.determine_table_style(table_block, &lines)
275            } else {
276                None
277            };
278
279            // Determine target style for this table
280            let target_style = if configured_style == "consistent" {
281                table_style.unwrap_or("leading_and_trailing")
282            } else {
283                configured_style
284            };
285
286            // Collect all table lines for building the whole-table fix
287            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
288                .chain(std::iter::once(table_block.delimiter_line))
289                .chain(table_block.content_lines.iter().copied())
290                .collect();
291
292            // Build the whole-table fix once for all warnings in this table
293            // This ensures that applying Quick Fix on any row fixes the entire table
294            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
295            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
296
297            // Build the complete fixed table content
298            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
299            for &line_idx in &all_line_indices {
300                let line = lines[line_idx];
301                let fixed_line = self.fix_table_row(line, target_style);
302                if line_idx < lines.len() - 1 {
303                    fixed_table_lines.push(format!("{fixed_line}\n"));
304                } else {
305                    fixed_table_lines.push(fixed_line);
306                }
307            }
308            let table_replacement = fixed_table_lines.concat();
309            let table_range = line_index.multi_line_range(table_start_line, table_end_line);
310
311            // Check all rows in the table
312            for &line_idx in &all_line_indices {
313                let line = lines[line_idx];
314                if let Some(current_style) = TableUtils::determine_pipe_style(line) {
315                    // Only flag lines with actual style mismatches
316                    let needs_fixing = current_style != target_style;
317
318                    if needs_fixing {
319                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
320
321                        let message = format!(
322                            "Table pipe style should be {}",
323                            match target_style {
324                                "leading_and_trailing" => "leading and trailing",
325                                "no_leading_or_trailing" => "no leading or trailing",
326                                "leading_only" => "leading only",
327                                "trailing_only" => "trailing only",
328                                _ => target_style,
329                            }
330                        );
331
332                        // Each warning uses the same whole-table fix
333                        // This ensures Quick Fix on any row fixes the entire table
334                        warnings.push(LintWarning {
335                            rule_name: Some(self.name().to_string()),
336                            severity: Severity::Warning,
337                            message,
338                            line: start_line,
339                            column: start_col,
340                            end_line,
341                            end_column: end_col,
342                            fix: Some(crate::rule::Fix {
343                                range: table_range.clone(),
344                                replacement: table_replacement.clone(),
345                            }),
346                        });
347                    }
348                }
349            }
350        }
351
352        Ok(warnings)
353    }
354
355    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
356        let content = ctx.content;
357        let lines: Vec<&str> = content.lines().collect();
358
359        // Use the configured style but validate it first
360        let configured_style = match self.config.style.as_str() {
361            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
362                self.config.style.as_str()
363            }
364            _ => {
365                // Invalid style provided, default to "leading_and_trailing"
366                "leading_and_trailing"
367            }
368        };
369
370        // Use pre-computed table blocks from context
371        let table_blocks = &ctx.table_blocks;
372
373        // Create a copy of lines that we can modify
374        let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
375
376        // Process each table block
377        for table_block in table_blocks {
378            // First pass: determine the table's style for "consistent" mode
379            // Count all rows to determine most prevalent style (prevalence-based approach)
380            let table_style = if configured_style == "consistent" {
381                self.determine_table_style(table_block, &lines)
382            } else {
383                None
384            };
385
386            // Determine target style for this table
387            let target_style = if configured_style == "consistent" {
388                table_style.unwrap_or("leading_and_trailing")
389            } else {
390                configured_style
391            };
392
393            // Fix all rows in the table
394            let all_lines = std::iter::once(table_block.header_line)
395                .chain(std::iter::once(table_block.delimiter_line))
396                .chain(table_block.content_lines.iter().copied());
397
398            for line_idx in all_lines {
399                let line = lines[line_idx];
400                let fixed_line = self.fix_table_row(line, target_style);
401                result_lines[line_idx] = fixed_line;
402            }
403        }
404
405        let mut fixed = result_lines.join("\n");
406        // Preserve trailing newline if original content had one
407        if content.ends_with('\n') && !fixed.ends_with('\n') {
408            fixed.push('\n');
409        }
410        Ok(fixed)
411    }
412
413    fn as_any(&self) -> &dyn std::any::Any {
414        self
415    }
416
417    fn default_config_section(&self) -> Option<(String, toml::Value)> {
418        let json_value = serde_json::to_value(&self.config).ok()?;
419        Some((
420            self.name().to_string(),
421            crate::rule_config_serde::json_to_toml_value(&json_value)?,
422        ))
423    }
424
425    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
426    where
427        Self: Sized,
428    {
429        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
430        Box::new(Self::from_config_struct(rule_config))
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_md055_delimiter_row_handling() {
440        // Test with no_leading_or_trailing style
441        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
442
443        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
444        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445        let result = rule.fix(&ctx).unwrap();
446
447        // With the fixed implementation, the delimiter row should have pipes removed
448        // Spacing is preserved from original input
449        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
450
451        assert_eq!(result, expected);
452
453        // Test that the check method actually reports the delimiter row as an issue
454        let warnings = rule.check(&ctx).unwrap();
455        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
456        assert_eq!(delimiter_warning.line, 2);
457        assert_eq!(
458            delimiter_warning.message,
459            "Table pipe style should be no leading or trailing"
460        );
461
462        // Test with leading_and_trailing style
463        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
464
465        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
466        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467        let result = rule.fix(&ctx).unwrap();
468
469        // The delimiter row should have pipes added
470        // Spacing is preserved from original input
471        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
472
473        assert_eq!(result, expected);
474    }
475
476    #[test]
477    fn test_md055_check_finds_delimiter_row_issues() {
478        // Test that check() correctly identifies delimiter rows that don't match style
479        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
480
481        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
482        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483        let warnings = rule.check(&ctx).unwrap();
484
485        // Should have 3 warnings - header row, delimiter row, and data row
486        assert_eq!(warnings.len(), 3);
487
488        // Specifically verify the delimiter row warning (line 2)
489        let delimiter_warning = &warnings[1];
490        assert_eq!(delimiter_warning.line, 2);
491        assert_eq!(
492            delimiter_warning.message,
493            "Table pipe style should be no leading or trailing"
494        );
495    }
496
497    #[test]
498    fn test_md055_real_world_example() {
499        // Test with a real-world example having content before and after the table
500        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
501
502        let content = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |\n| Data 4   | Data 5   | Data 6   |\n\nMore content after the table.";
503        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504        let result = rule.fix(&ctx).unwrap();
505
506        // The table should be fixed, with pipes removed
507        // Spacing is preserved from original input
508        let expected = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\nHeader 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3\nData 4   | Data 5   | Data 6\n\nMore content after the table.";
509
510        assert_eq!(result, expected);
511
512        // Ensure we get warnings for all table rows
513        let warnings = rule.check(&ctx).unwrap();
514        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
515
516        // The line numbers should match the correct positions in the original content
517        assert_eq!(warnings[0].line, 5); // Header row
518        assert_eq!(warnings[1].line, 6); // Delimiter row
519        assert_eq!(warnings[2].line, 7); // Data row 1
520        assert_eq!(warnings[3].line, 8); // Data row 2
521    }
522
523    #[test]
524    fn test_md055_invalid_style() {
525        // Test with an invalid style setting
526        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
527
528        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
529        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530        let result = rule.fix(&ctx).unwrap();
531
532        // Should default to "leading_and_trailing"
533        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
534        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
535
536        assert_eq!(result, expected);
537
538        // Now check a content that needs actual modification
539        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
540        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541        let result = rule.fix(&ctx2).unwrap();
542
543        // Should add pipes to match the default "leading_and_trailing" style
544        // Spacing is preserved from original input
545        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
546        assert_eq!(result, expected);
547
548        // Check that warning messages also work with the fallback style
549        let warnings = rule.check(&ctx2).unwrap();
550
551        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
552        // there should be warnings for all rows
553        assert_eq!(warnings.len(), 3);
554    }
555
556    #[test]
557    fn test_underflow_protection() {
558        // Test case to ensure no underflow when parts is empty
559        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
560
561        // Test with empty string (edge case)
562        let result = rule.fix_table_row("", "leading_and_trailing");
563        assert_eq!(result, "");
564
565        // Test with string that doesn't contain pipes
566        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
567        assert_eq!(result, "no pipes here");
568
569        // Test with minimal pipe content
570        let result = rule.fix_table_row("|", "leading_and_trailing");
571        // Should not panic and should handle gracefully
572        assert!(!result.is_empty());
573    }
574}