rumdl_lib/rules/
md055_table_pipe_style.rs

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