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