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            // Check all rows in the table
287            let all_lines = 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
291            for line_idx in all_lines {
292                let line = lines[line_idx];
293                if let Some(current_style) = TableUtils::determine_pipe_style(line) {
294                    // Only flag lines with actual style mismatches
295                    let needs_fixing = current_style != target_style;
296
297                    if needs_fixing {
298                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
299
300                        let message = format!(
301                            "Table pipe style should be {}",
302                            match target_style {
303                                "leading_and_trailing" => "leading and trailing",
304                                "no_leading_or_trailing" => "no leading or trailing",
305                                "leading_only" => "leading only",
306                                "trailing_only" => "trailing only",
307                                _ => target_style,
308                            }
309                        );
310
311                        let fixed_line = self.fix_table_row(line, target_style);
312                        warnings.push(LintWarning {
313                            rule_name: Some(self.name().to_string()),
314                            severity: Severity::Warning,
315                            message,
316                            line: start_line,
317                            column: start_col,
318                            end_line,
319                            end_column: end_col,
320                            fix: Some(crate::rule::Fix {
321                                range: line_index.whole_line_range(line_idx + 1),
322                                replacement: if line_idx < lines.len() - 1 {
323                                    format!("{fixed_line}\n")
324                                } else {
325                                    fixed_line
326                                },
327                            }),
328                        });
329                    }
330                }
331            }
332        }
333
334        Ok(warnings)
335    }
336
337    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
338        let content = ctx.content;
339        let lines: Vec<&str> = content.lines().collect();
340
341        // Use the configured style but validate it first
342        let configured_style = match self.config.style.as_str() {
343            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
344                self.config.style.as_str()
345            }
346            _ => {
347                // Invalid style provided, default to "leading_and_trailing"
348                "leading_and_trailing"
349            }
350        };
351
352        // Use pre-computed table blocks from context
353        let table_blocks = &ctx.table_blocks;
354
355        // Create a copy of lines that we can modify
356        let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
357
358        // Process each table block
359        for table_block in table_blocks {
360            // First pass: determine the table's style for "consistent" mode
361            // Count all rows to determine most prevalent style (prevalence-based approach)
362            let table_style = if configured_style == "consistent" {
363                self.determine_table_style(table_block, &lines)
364            } else {
365                None
366            };
367
368            // Determine target style for this table
369            let target_style = if configured_style == "consistent" {
370                table_style.unwrap_or("leading_and_trailing")
371            } else {
372                configured_style
373            };
374
375            // Fix all rows in the table
376            let all_lines = std::iter::once(table_block.header_line)
377                .chain(std::iter::once(table_block.delimiter_line))
378                .chain(table_block.content_lines.iter().copied());
379
380            for line_idx in all_lines {
381                let line = lines[line_idx];
382                let fixed_line = self.fix_table_row(line, target_style);
383                result_lines[line_idx] = fixed_line;
384            }
385        }
386
387        let mut fixed = result_lines.join("\n");
388        // Preserve trailing newline if original content had one
389        if content.ends_with('\n') && !fixed.ends_with('\n') {
390            fixed.push('\n');
391        }
392        Ok(fixed)
393    }
394
395    fn as_any(&self) -> &dyn std::any::Any {
396        self
397    }
398
399    fn default_config_section(&self) -> Option<(String, toml::Value)> {
400        let json_value = serde_json::to_value(&self.config).ok()?;
401        Some((
402            self.name().to_string(),
403            crate::rule_config_serde::json_to_toml_value(&json_value)?,
404        ))
405    }
406
407    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
408    where
409        Self: Sized,
410    {
411        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
412        Box::new(Self::from_config_struct(rule_config))
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_md055_delimiter_row_handling() {
422        // Test with no_leading_or_trailing style
423        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
424
425        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
426        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
427        let result = rule.fix(&ctx).unwrap();
428
429        // With the fixed implementation, the delimiter row should have pipes removed
430        // Spacing is preserved from original input
431        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
432
433        assert_eq!(result, expected);
434
435        // Test that the check method actually reports the delimiter row as an issue
436        let warnings = rule.check(&ctx).unwrap();
437        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
438        assert_eq!(delimiter_warning.line, 2);
439        assert_eq!(
440            delimiter_warning.message,
441            "Table pipe style should be no leading or trailing"
442        );
443
444        // Test with leading_and_trailing style
445        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
446
447        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
448        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
449        let result = rule.fix(&ctx).unwrap();
450
451        // The delimiter row should have pipes added
452        // Spacing is preserved from original input
453        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
454
455        assert_eq!(result, expected);
456    }
457
458    #[test]
459    fn test_md055_check_finds_delimiter_row_issues() {
460        // Test that check() correctly identifies delimiter rows that don't match style
461        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
462
463        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
464        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
465        let warnings = rule.check(&ctx).unwrap();
466
467        // Should have 3 warnings - header row, delimiter row, and data row
468        assert_eq!(warnings.len(), 3);
469
470        // Specifically verify the delimiter row warning (line 2)
471        let delimiter_warning = &warnings[1];
472        assert_eq!(delimiter_warning.line, 2);
473        assert_eq!(
474            delimiter_warning.message,
475            "Table pipe style should be no leading or trailing"
476        );
477    }
478
479    #[test]
480    fn test_md055_real_world_example() {
481        // Test with a real-world example having content before and after the table
482        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
483
484        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.";
485        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
486        let result = rule.fix(&ctx).unwrap();
487
488        // The table should be fixed, with pipes removed
489        // Spacing is preserved from original input
490        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.";
491
492        assert_eq!(result, expected);
493
494        // Ensure we get warnings for all table rows
495        let warnings = rule.check(&ctx).unwrap();
496        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
497
498        // The line numbers should match the correct positions in the original content
499        assert_eq!(warnings[0].line, 5); // Header row
500        assert_eq!(warnings[1].line, 6); // Delimiter row
501        assert_eq!(warnings[2].line, 7); // Data row 1
502        assert_eq!(warnings[3].line, 8); // Data row 2
503    }
504
505    #[test]
506    fn test_md055_invalid_style() {
507        // Test with an invalid style setting
508        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
509
510        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
511        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512        let result = rule.fix(&ctx).unwrap();
513
514        // Should default to "leading_and_trailing"
515        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
516        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
517
518        assert_eq!(result, expected);
519
520        // Now check a content that needs actual modification
521        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
522        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523        let result = rule.fix(&ctx2).unwrap();
524
525        // Should add pipes to match the default "leading_and_trailing" style
526        // Spacing is preserved from original input
527        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
528        assert_eq!(result, expected);
529
530        // Check that warning messages also work with the fallback style
531        let warnings = rule.check(&ctx2).unwrap();
532
533        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
534        // there should be warnings for all rows
535        assert_eq!(warnings.len(), 3);
536    }
537
538    #[test]
539    fn test_underflow_protection() {
540        // Test case to ensure no underflow when parts is empty
541        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
542
543        // Test with empty string (edge case)
544        let result = rule.fix_table_row("", "leading_and_trailing");
545        assert_eq!(result, "");
546
547        // Test with string that doesn't contain pipes
548        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
549        assert_eq!(result, "no pipes here");
550
551        // Test with minimal pipe content
552        let result = rule.fix_table_row("|", "leading_and_trailing");
553        // Should not panic and should handle gracefully
554        assert!(!result.is_empty());
555    }
556}