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::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    /// Fix a table row to match the target style
97    /// Uses surgical fixes: only adds/removes pipes, preserves all user formatting
98    fn fix_table_row(&self, line: &str, target_style: &str) -> String {
99        let trimmed = line.trim();
100        if !trimmed.contains('|') {
101            return line.to_string();
102        }
103
104        let has_leading = trimmed.starts_with('|');
105        let has_trailing = trimmed.ends_with('|');
106
107        match target_style {
108            "leading_and_trailing" => {
109                let mut result = trimmed.to_string();
110
111                // Add leading pipe if missing
112                if !has_leading {
113                    result = format!("| {result}");
114                }
115
116                // Add trailing pipe if missing
117                if !has_trailing {
118                    result = format!("{result} |");
119                }
120
121                result
122            }
123            "no_leading_or_trailing" => {
124                let mut result = trimmed;
125
126                // Remove leading pipe if present
127                if has_leading {
128                    result = result.strip_prefix('|').unwrap_or(result);
129                    result = result.trim_start();
130                }
131
132                // Remove trailing pipe if present
133                if has_trailing {
134                    result = result.strip_suffix('|').unwrap_or(result);
135                    result = result.trim_end();
136                }
137
138                result.to_string()
139            }
140            "leading_only" => {
141                let mut result = trimmed.to_string();
142
143                // Add leading pipe if missing
144                if !has_leading {
145                    result = format!("| {result}");
146                }
147
148                // Remove trailing pipe if present
149                if has_trailing {
150                    result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
151                }
152
153                result
154            }
155            "trailing_only" => {
156                let mut result = trimmed;
157
158                // Remove leading pipe if present
159                if has_leading {
160                    result = result.strip_prefix('|').unwrap_or(result).trim_start();
161                }
162
163                let mut result = result.to_string();
164
165                // Add trailing pipe if missing
166                if !has_trailing {
167                    result = format!("{result} |");
168                }
169
170                result
171            }
172            _ => line.to_string(),
173        }
174    }
175}
176
177impl Rule for MD055TablePipeStyle {
178    fn name(&self) -> &'static str {
179        "MD055"
180    }
181
182    fn description(&self) -> &'static str {
183        "Table pipe style should be consistent"
184    }
185
186    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
187        // Skip if no tables present (uses cached pipe count)
188        !ctx.likely_has_tables()
189    }
190
191    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
192        let content = ctx.content;
193        let line_index = &ctx.line_index;
194        let mut warnings = Vec::new();
195
196        // Early return handled by should_skip()
197
198        let lines: Vec<&str> = content.lines().collect();
199
200        // Get the configured style explicitly and validate it
201        let configured_style = match self.config.style.as_str() {
202            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
203                self.config.style.as_str()
204            }
205            _ => {
206                // Invalid style provided, default to "leading_and_trailing"
207                "leading_and_trailing"
208            }
209        };
210
211        // Use pre-computed table blocks from context
212        let table_blocks = &ctx.table_blocks;
213
214        // Process each table block
215        for table_block in table_blocks {
216            let mut table_style = None;
217
218            // First pass: determine the table's style for "consistent" mode
219            // Count all rows to determine most prevalent style (prevalence-based approach)
220            if configured_style == "consistent" {
221                let mut leading_and_trailing_count = 0;
222                let mut no_leading_or_trailing_count = 0;
223                let mut leading_only_count = 0;
224                let mut trailing_only_count = 0;
225
226                // Count style of header row
227                if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
228                    match style {
229                        "leading_and_trailing" => leading_and_trailing_count += 1,
230                        "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
231                        "leading_only" => leading_only_count += 1,
232                        "trailing_only" => trailing_only_count += 1,
233                        _ => {}
234                    }
235                }
236
237                // Count style of content rows
238                for &line_idx in &table_block.content_lines {
239                    if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
240                        match style {
241                            "leading_and_trailing" => leading_and_trailing_count += 1,
242                            "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
243                            "leading_only" => leading_only_count += 1,
244                            "trailing_only" => trailing_only_count += 1,
245                            _ => {}
246                        }
247                    }
248                }
249
250                // Determine most prevalent style
251                // In case of tie, prefer leading_and_trailing (most common, widely supported)
252                let max_count = leading_and_trailing_count
253                    .max(no_leading_or_trailing_count)
254                    .max(leading_only_count)
255                    .max(trailing_only_count);
256
257                if max_count > 0 {
258                    if leading_and_trailing_count == max_count {
259                        table_style = Some("leading_and_trailing");
260                    } else if no_leading_or_trailing_count == max_count {
261                        table_style = Some("no_leading_or_trailing");
262                    } else if leading_only_count == max_count {
263                        table_style = Some("leading_only");
264                    } else if trailing_only_count == max_count {
265                        table_style = Some("trailing_only");
266                    }
267                }
268            }
269
270            // Determine target style for this table
271            let target_style = if configured_style == "consistent" {
272                table_style.unwrap_or("leading_and_trailing")
273            } else {
274                configured_style
275            };
276
277            // Check all rows in the table
278            let all_lines = std::iter::once(table_block.header_line)
279                .chain(std::iter::once(table_block.delimiter_line))
280                .chain(table_block.content_lines.iter().copied());
281
282            for line_idx in all_lines {
283                let line = lines[line_idx];
284                if let Some(current_style) = TableUtils::determine_pipe_style(line) {
285                    // Only flag lines with actual style mismatches
286                    let needs_fixing = current_style != target_style;
287
288                    if needs_fixing {
289                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
290
291                        let message = format!(
292                            "Table pipe style should be {}",
293                            match target_style {
294                                "leading_and_trailing" => "leading and trailing",
295                                "no_leading_or_trailing" => "no leading or trailing",
296                                "leading_only" => "leading only",
297                                "trailing_only" => "trailing only",
298                                _ => target_style,
299                            }
300                        );
301
302                        let fixed_line = self.fix_table_row(line, target_style);
303                        warnings.push(LintWarning {
304                            rule_name: Some(self.name().to_string()),
305                            severity: Severity::Warning,
306                            message,
307                            line: start_line,
308                            column: start_col,
309                            end_line,
310                            end_column: end_col,
311                            fix: Some(crate::rule::Fix {
312                                range: line_index.whole_line_range(line_idx + 1),
313                                replacement: if line_idx < lines.len() - 1 {
314                                    format!("{fixed_line}\n")
315                                } else {
316                                    fixed_line
317                                },
318                            }),
319                        });
320                    }
321                }
322            }
323        }
324
325        Ok(warnings)
326    }
327
328    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
329        let content = ctx.content;
330        let lines: Vec<&str> = content.lines().collect();
331
332        // Use the configured style but validate it first
333        let configured_style = match self.config.style.as_str() {
334            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
335                self.config.style.as_str()
336            }
337            _ => {
338                // Invalid style provided, default to "leading_and_trailing"
339                "leading_and_trailing"
340            }
341        };
342
343        // Use pre-computed table blocks from context
344        let table_blocks = &ctx.table_blocks;
345
346        // Create a copy of lines that we can modify
347        let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
348
349        // Process each table block
350        for table_block in table_blocks {
351            let mut table_style = None;
352
353            // First pass: determine the table's style for "consistent" mode
354            // Count all rows to determine most prevalent style (prevalence-based approach)
355            if configured_style == "consistent" {
356                let mut leading_and_trailing_count = 0;
357                let mut no_leading_or_trailing_count = 0;
358                let mut leading_only_count = 0;
359                let mut trailing_only_count = 0;
360
361                // Count style of header row
362                if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
363                    match style {
364                        "leading_and_trailing" => leading_and_trailing_count += 1,
365                        "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
366                        "leading_only" => leading_only_count += 1,
367                        "trailing_only" => trailing_only_count += 1,
368                        _ => {}
369                    }
370                }
371
372                // Count style of content rows
373                for &line_idx in &table_block.content_lines {
374                    if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
375                        match style {
376                            "leading_and_trailing" => leading_and_trailing_count += 1,
377                            "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
378                            "leading_only" => leading_only_count += 1,
379                            "trailing_only" => trailing_only_count += 1,
380                            _ => {}
381                        }
382                    }
383                }
384
385                // Determine most prevalent style
386                // In case of tie, prefer leading_and_trailing (most common, widely supported)
387                let max_count = leading_and_trailing_count
388                    .max(no_leading_or_trailing_count)
389                    .max(leading_only_count)
390                    .max(trailing_only_count);
391
392                if max_count > 0 {
393                    if leading_and_trailing_count == max_count {
394                        table_style = Some("leading_and_trailing");
395                    } else if no_leading_or_trailing_count == max_count {
396                        table_style = Some("no_leading_or_trailing");
397                    } else if leading_only_count == max_count {
398                        table_style = Some("leading_only");
399                    } else if trailing_only_count == max_count {
400                        table_style = Some("trailing_only");
401                    }
402                }
403            }
404
405            // Determine target style for this table
406            let target_style = if configured_style == "consistent" {
407                table_style.unwrap_or("leading_and_trailing")
408            } else {
409                configured_style
410            };
411
412            // Fix all rows in the table
413            let all_lines = std::iter::once(table_block.header_line)
414                .chain(std::iter::once(table_block.delimiter_line))
415                .chain(table_block.content_lines.iter().copied());
416
417            for line_idx in all_lines {
418                let line = lines[line_idx];
419                let fixed_line = self.fix_table_row(line, target_style);
420                result_lines[line_idx] = fixed_line;
421            }
422        }
423
424        let mut fixed = result_lines.join("\n");
425        // Preserve trailing newline if original content had one
426        if content.ends_with('\n') && !fixed.ends_with('\n') {
427            fixed.push('\n');
428        }
429        Ok(fixed)
430    }
431
432    fn as_any(&self) -> &dyn std::any::Any {
433        self
434    }
435
436    fn default_config_section(&self) -> Option<(String, toml::Value)> {
437        let json_value = serde_json::to_value(&self.config).ok()?;
438        Some((
439            self.name().to_string(),
440            crate::rule_config_serde::json_to_toml_value(&json_value)?,
441        ))
442    }
443
444    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
445    where
446        Self: Sized,
447    {
448        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
449        Box::new(Self::from_config_struct(rule_config))
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_md055_delimiter_row_handling() {
459        // Test with no_leading_or_trailing style
460        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
461
462        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
463        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464        let result = rule.fix(&ctx).unwrap();
465
466        // With the fixed implementation, the delimiter row should have pipes removed
467        // Spacing is preserved from original input
468        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
469
470        assert_eq!(result, expected);
471
472        // Test that the check method actually reports the delimiter row as an issue
473        let warnings = rule.check(&ctx).unwrap();
474        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
475        assert_eq!(delimiter_warning.line, 2);
476        assert_eq!(
477            delimiter_warning.message,
478            "Table pipe style should be no leading or trailing"
479        );
480
481        // Test with leading_and_trailing style
482        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
483
484        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
485        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
486        let result = rule.fix(&ctx).unwrap();
487
488        // The delimiter row should have pipes added
489        // Spacing is preserved from original input
490        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
491
492        assert_eq!(result, expected);
493    }
494
495    #[test]
496    fn test_md055_check_finds_delimiter_row_issues() {
497        // Test that check() correctly identifies delimiter rows that don't match style
498        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
499
500        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
501        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502        let warnings = rule.check(&ctx).unwrap();
503
504        // Should have 3 warnings - header row, delimiter row, and data row
505        assert_eq!(warnings.len(), 3);
506
507        // Specifically verify the delimiter row warning (line 2)
508        let delimiter_warning = &warnings[1];
509        assert_eq!(delimiter_warning.line, 2);
510        assert_eq!(
511            delimiter_warning.message,
512            "Table pipe style should be no leading or trailing"
513        );
514    }
515
516    #[test]
517    fn test_md055_real_world_example() {
518        // Test with a real-world example having content before and after the table
519        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
520
521        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.";
522        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523        let result = rule.fix(&ctx).unwrap();
524
525        // The table should be fixed, with pipes removed
526        // Spacing is preserved from original input
527        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.";
528
529        assert_eq!(result, expected);
530
531        // Ensure we get warnings for all table rows
532        let warnings = rule.check(&ctx).unwrap();
533        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
534
535        // The line numbers should match the correct positions in the original content
536        assert_eq!(warnings[0].line, 5); // Header row
537        assert_eq!(warnings[1].line, 6); // Delimiter row
538        assert_eq!(warnings[2].line, 7); // Data row 1
539        assert_eq!(warnings[3].line, 8); // Data row 2
540    }
541
542    #[test]
543    fn test_md055_invalid_style() {
544        // Test with an invalid style setting
545        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
546
547        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
548        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
549        let result = rule.fix(&ctx).unwrap();
550
551        // Should default to "leading_and_trailing"
552        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
553        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
554
555        assert_eq!(result, expected);
556
557        // Now check a content that needs actual modification
558        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
559        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
560        let result = rule.fix(&ctx2).unwrap();
561
562        // Should add pipes to match the default "leading_and_trailing" style
563        // Spacing is preserved from original input
564        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
565        assert_eq!(result, expected);
566
567        // Check that warning messages also work with the fallback style
568        let warnings = rule.check(&ctx2).unwrap();
569
570        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
571        // there should be warnings for all rows
572        assert_eq!(warnings.len(), 3);
573    }
574
575    #[test]
576    fn test_underflow_protection() {
577        // Test case to ensure no underflow when parts is empty
578        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
579
580        // Test with empty string (edge case)
581        let result = rule.fix_table_row("", "leading_and_trailing");
582        assert_eq!(result, "");
583
584        // Test with string that doesn't contain pipes
585        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
586        assert_eq!(result, "no pipes here");
587
588        // Test with minimal pipe content
589        let result = rule.fix_table_row("|", "leading_and_trailing");
590        // Should not panic and should handle gracefully
591        assert!(!result.is_empty());
592    }
593}