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