mdbook_lint_core/rules/standard/
md055.rs

1//! MD055: Table pipe style
2//!
3//! This rule checks that table pipes are used consistently throughout the document.
4
5use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11
12/// Rule to check table pipe style consistency
13pub struct MD055 {
14    /// Preferred table pipe style
15    style: PipeStyle,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum PipeStyle {
20    /// No leading or trailing pipes
21    NoLeadingOrTrailing,
22    /// Leading and trailing pipes
23    LeadingAndTrailing,
24    /// Detect from first usage in document
25    Consistent,
26}
27
28impl MD055 {
29    /// Create a new MD055 rule with consistent style detection
30    pub fn new() -> Self {
31        Self {
32            style: PipeStyle::Consistent,
33        }
34    }
35
36    /// Create a new MD055 rule with specific style preference
37    #[allow(dead_code)]
38    pub fn with_style(style: PipeStyle) -> Self {
39        Self { style }
40    }
41
42    /// Find table blocks in the document (sequences of table-like lines)
43    fn find_table_blocks(&self, lines: &[&str]) -> Vec<(usize, usize)> {
44        let mut table_blocks = Vec::new();
45        let mut i = 0;
46
47        while i < lines.len() {
48            if let Some(block_end) = self.find_table_block_starting_at(lines, i) {
49                table_blocks.push((i, block_end));
50                i = block_end + 1;
51            } else {
52                i += 1;
53            }
54        }
55
56        table_blocks
57    }
58
59    /// Try to find a table block starting at the given line index
60    fn find_table_block_starting_at(&self, lines: &[&str], start: usize) -> Option<usize> {
61        if start >= lines.len() {
62            return None;
63        }
64
65        let first_line = lines[start].trim();
66
67        // Must start with a line that has pipes
68        if !first_line.contains('|') {
69            return None;
70        }
71
72        // Look for table patterns:
73        // 1. Lines with leading/trailing pipes
74        // 2. A header row followed by a separator row
75        let has_leading_trailing = first_line.starts_with('|') && first_line.ends_with('|');
76
77        if has_leading_trailing {
78            // Find consecutive table lines (including separators and mixed styles)
79            let mut end = start;
80            while end < lines.len() {
81                let line = lines[end].trim();
82                if line.is_empty() {
83                    break;
84                }
85                // Accept lines with pipes (including separators and mixed styles)
86                if !line.contains('|') {
87                    break;
88                }
89                end += 1;
90            }
91
92            if end > start {
93                return Some(end - 1);
94            }
95        } else {
96            // Look for header + separator pattern for tables without leading/trailing pipes
97            if start + 1 < lines.len() {
98                let second_line = lines[start + 1].trim();
99                if self.is_table_separator(second_line) {
100                    // Find consecutive table rows
101                    let mut end = start + 1; // Include separator
102                    end += 1; // Move past separator
103
104                    while end < lines.len() {
105                        let line = lines[end].trim();
106                        if line.is_empty() {
107                            break;
108                        }
109                        // Check if this looks like a table row without leading/trailing pipes
110                        let pipe_count = line.chars().filter(|&c| c == '|').count();
111                        if pipe_count == 0 || self.is_table_separator(line) {
112                            break;
113                        }
114                        // Make sure it has the same number of columns as the header
115                        let header_pipes = first_line.chars().filter(|&c| c == '|').count();
116                        let row_pipes = line.chars().filter(|&c| c == '|').count();
117                        if row_pipes != header_pipes {
118                            break;
119                        }
120                        end += 1;
121                    }
122
123                    if end > start + 2 {
124                        // At least header + separator + one data row
125                        return Some(end - 1);
126                    }
127                }
128            }
129        }
130
131        None
132    }
133
134    /// Check if a line looks like a table row within a known table context
135    fn is_table_row_in_context(&self, line: &str) -> bool {
136        let trimmed = line.trim();
137        let pipe_count = trimmed.chars().filter(|&c| c == '|').count();
138        pipe_count >= 1 && !self.is_table_separator(trimmed)
139    }
140
141    /// Check if a line is a table separator (like |---|---|)
142    fn is_table_separator(&self, line: &str) -> bool {
143        let trimmed = line.trim();
144        if !trimmed.contains('|') {
145            return false;
146        }
147
148        // Remove pipes and check if remaining chars are only - : and whitespace
149        let without_pipes = trimmed.replace('|', "");
150        without_pipes
151            .chars()
152            .all(|c| c == '-' || c == ':' || c.is_whitespace())
153    }
154
155    /// Determine the pipe style of a table row
156    fn get_pipe_style(&self, line: &str) -> Option<PipeStyle> {
157        let trimmed = line.trim();
158
159        if !self.is_table_row_in_context(line) {
160            return None;
161        }
162
163        let starts_with_pipe = trimmed.starts_with('|');
164        let ends_with_pipe = trimmed.ends_with('|');
165
166        if starts_with_pipe && ends_with_pipe {
167            Some(PipeStyle::LeadingAndTrailing)
168        } else if !starts_with_pipe && !ends_with_pipe {
169            Some(PipeStyle::NoLeadingOrTrailing)
170        } else {
171            // Mixed style (leading but not trailing, or trailing but not leading)
172            // We'll treat this as inconsistent and flag it
173            None
174        }
175    }
176
177    /// Check a line for table pipe style violations
178    fn check_line_pipes(
179        &self,
180        line: &str,
181        line_number: usize,
182        expected_style: Option<PipeStyle>,
183    ) -> (Vec<Violation>, Option<PipeStyle>) {
184        let mut violations = Vec::new();
185        let mut detected_style = expected_style;
186
187        if let Some(current_style) = self.get_pipe_style(line) {
188            if let Some(expected) = expected_style {
189                // Check consistency with established style
190                if expected != current_style {
191                    let expected_desc = match expected {
192                        PipeStyle::LeadingAndTrailing => "leading and trailing pipes",
193                        PipeStyle::NoLeadingOrTrailing => "no leading or trailing pipes",
194                        PipeStyle::Consistent => "consistent", // shouldn't happen
195                    };
196                    let found_desc = match current_style {
197                        PipeStyle::LeadingAndTrailing => "leading and trailing pipes",
198                        PipeStyle::NoLeadingOrTrailing => "no leading or trailing pipes",
199                        PipeStyle::Consistent => "consistent", // shouldn't happen
200                    };
201
202                    violations.push(self.create_violation(
203                        format!(
204                            "Table pipe style inconsistent - expected {expected_desc} but found {found_desc}"
205                        ),
206                        line_number,
207                        1,
208                        Severity::Warning,
209                    ));
210                }
211            } else {
212                // First table found - establish the style
213                detected_style = Some(current_style);
214            }
215        } else if self.is_table_row_in_context(line) {
216            // This is a table row but with mixed pipe style
217            violations.push(self.create_violation(
218                "Table row has inconsistent pipe style (mixed leading/trailing)".to_string(),
219                line_number,
220                1,
221                Severity::Warning,
222            ));
223        }
224
225        (violations, detected_style)
226    }
227
228    /// Get code block ranges to exclude from checking
229    fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
230        let mut in_code_block = vec![false; lines.len()];
231        let mut in_fenced_block = false;
232
233        for (i, line) in lines.iter().enumerate() {
234            let trimmed = line.trim();
235
236            // Check for fenced code blocks
237            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
238                in_fenced_block = !in_fenced_block;
239                in_code_block[i] = true;
240                continue;
241            }
242
243            if in_fenced_block {
244                in_code_block[i] = true;
245                continue;
246            }
247        }
248
249        in_code_block
250    }
251}
252
253impl Default for MD055 {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259impl Rule for MD055 {
260    fn id(&self) -> &'static str {
261        "MD055"
262    }
263
264    fn name(&self) -> &'static str {
265        "table-pipe-style"
266    }
267
268    fn description(&self) -> &'static str {
269        "Table pipe style should be consistent"
270    }
271
272    fn metadata(&self) -> RuleMetadata {
273        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
274    }
275
276    fn check_with_ast<'a>(
277        &self,
278        document: &Document,
279        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
280    ) -> Result<Vec<Violation>> {
281        let mut violations = Vec::new();
282        let lines: Vec<&str> = document.content.lines().collect();
283        let in_code_block = self.get_code_block_ranges(&lines);
284
285        // Find all table blocks first
286        let table_blocks = self.find_table_blocks(&lines);
287
288        let mut expected_style = match self.style {
289            PipeStyle::LeadingAndTrailing => Some(PipeStyle::LeadingAndTrailing),
290            PipeStyle::NoLeadingOrTrailing => Some(PipeStyle::NoLeadingOrTrailing),
291            PipeStyle::Consistent => None, // Detect from first usage
292        };
293
294        // Process each table block
295        for (start, end) in table_blocks {
296            for line_idx in start..=end {
297                let line_number = line_idx + 1;
298                let line = lines[line_idx];
299
300                // Skip lines inside code blocks
301                if in_code_block[line_idx] {
302                    continue;
303                }
304
305                // Only check actual table rows (not separators)
306                if self.is_table_row_in_context(line) {
307                    let (line_violations, detected_style) =
308                        self.check_line_pipes(line, line_number, expected_style);
309                    violations.extend(line_violations);
310
311                    // Update expected style if we detected one
312                    if expected_style.is_none() && detected_style.is_some() {
313                        expected_style = detected_style;
314                    }
315                }
316            }
317        }
318
319        Ok(violations)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::rule::Rule;
327    use std::path::PathBuf;
328
329    fn create_test_document(content: &str) -> Document {
330        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
331    }
332
333    #[test]
334    fn test_md055_consistent_leading_trailing_pipes() {
335        let content = r#"| Column 1 | Column 2 | Column 3 |
336|----------|----------|----------|
337| Value 1  | Value 2  | Value 3  |
338| Value 4  | Value 5  | Value 6  |
339"#;
340
341        let document = create_test_document(content);
342        let rule = MD055::new();
343        let violations = rule.check(&document).unwrap();
344        assert_eq!(violations.len(), 0);
345    }
346
347    #[test]
348    fn test_md055_consistent_no_leading_trailing_pipes() {
349        let content = r#"Column 1 | Column 2 | Column 3
350---------|----------|----------
351Value 1  | Value 2  | Value 3
352Value 4  | Value 5  | Value 6
353"#;
354
355        let document = create_test_document(content);
356        let rule = MD055::new();
357        let violations = rule.check(&document).unwrap();
358        assert_eq!(violations.len(), 0);
359    }
360
361    #[test]
362    fn test_md055_mixed_styles_violation() {
363        let content = r#"| Column 1 | Column 2 | Column 3 |
364|----------|----------|----------|
365Value 1  | Value 2  | Value 3
366| Value 4  | Value 5  | Value 6  |
367"#;
368
369        let document = create_test_document(content);
370        let rule = MD055::new();
371        let violations = rule.check(&document).unwrap();
372        assert_eq!(violations.len(), 1);
373        assert_eq!(violations[0].rule_id, "MD055");
374        assert_eq!(violations[0].line, 3);
375        assert!(
376            violations[0]
377                .message
378                .contains("expected leading and trailing pipes")
379        );
380    }
381
382    #[test]
383    fn test_md055_preferred_leading_trailing_style() {
384        let content = r#"Column 1 | Column 2 | Column 3
385---------|----------|----------
386Value 1  | Value 2  | Value 3
387"#;
388
389        let document = create_test_document(content);
390        let rule = MD055::with_style(PipeStyle::LeadingAndTrailing);
391        let violations = rule.check(&document).unwrap();
392        assert_eq!(violations.len(), 2); // Header and data rows
393        assert!(
394            violations[0]
395                .message
396                .contains("expected leading and trailing pipes")
397        );
398    }
399
400    #[test]
401    fn test_md055_preferred_no_leading_trailing_style() {
402        let content = r#"| Column 1 | Column 2 | Column 3 |
403|----------|----------|----------|
404| Value 1  | Value 2  | Value 3  |
405"#;
406
407        let document = create_test_document(content);
408        let rule = MD055::with_style(PipeStyle::NoLeadingOrTrailing);
409        let violations = rule.check(&document).unwrap();
410        assert_eq!(violations.len(), 2); // Header and data rows
411        assert!(
412            violations[0]
413                .message
414                .contains("expected no leading or trailing pipes")
415        );
416    }
417
418    #[test]
419    fn test_md055_mixed_leading_trailing_on_same_row() {
420        let content = r#"| Column 1 | Column 2 | Column 3
421|----------|----------|----------|
422 Value 1  | Value 2  | Value 3  |
423"#;
424
425        let document = create_test_document(content);
426        let rule = MD055::new();
427        let violations = rule.check(&document).unwrap();
428        assert_eq!(violations.len(), 2);
429        assert!(violations[0].message.contains("mixed leading/trailing"));
430        assert!(violations[1].message.contains("mixed leading/trailing"));
431    }
432
433    #[test]
434    fn test_md055_multiple_tables_consistent() {
435        let content = r#"| Table 1  | Column 2 |
436|----------|----------|
437| Value 1  | Value 2  |
438
439Some text between tables.
440
441| Table 2  | Column 2 |
442|----------|----------|
443| Value 3  | Value 4  |
444"#;
445
446        let document = create_test_document(content);
447        let rule = MD055::new();
448        let violations = rule.check(&document).unwrap();
449        assert_eq!(violations.len(), 0);
450    }
451
452    #[test]
453    fn test_md055_multiple_tables_inconsistent() {
454        let content = r#"| Table 1  | Column 2 |
455|----------|----------|
456| Value 1  | Value 2  |
457
458Some text between tables.
459
460Table 2  | Column 2
461---------|----------
462Value 3  | Value 4
463"#;
464
465        let document = create_test_document(content);
466        let rule = MD055::new();
467        let violations = rule.check(&document).unwrap();
468
469        assert_eq!(violations.len(), 2); // Second table has different style
470        assert_eq!(violations[0].line, 7);
471        assert_eq!(violations[1].line, 9);
472    }
473
474    #[test]
475    fn test_md055_code_blocks_ignored() {
476        let content = r#"| Good table | Column 2 |
477|-------------|----------|
478| Value 1     | Value 2  |
479
480```
481Bad table | Column 2
482----------|----------
483Value 3   | Value 4
484```
485
486| Another good | Column 2 |
487|--------------|----------|
488| Value 5      | Value 6  |
489"#;
490
491        let document = create_test_document(content);
492        let rule = MD055::new();
493        let violations = rule.check(&document).unwrap();
494        assert_eq!(violations.len(), 0);
495    }
496
497    #[test]
498    fn test_md055_non_table_content_ignored() {
499        let content = r#"This is regular text with | pipes | in it.
500
501| But this | is a table |
502|----------|------------|
503| Value 1  | Value 2    |
504
505And this is more text with | random | pipes |.
506"#;
507
508        let document = create_test_document(content);
509        let rule = MD055::new();
510        let violations = rule.check(&document).unwrap();
511        assert_eq!(violations.len(), 0);
512    }
513
514    #[test]
515    fn test_md055_table_separators_ignored() {
516        let content = r#"| Column 1 | Column 2 |
517|:---------|----------:|
518| Value 1  | Value 2   |
519"#;
520
521        let document = create_test_document(content);
522        let rule = MD055::new();
523        let violations = rule.check(&document).unwrap();
524        assert_eq!(violations.len(), 0);
525    }
526
527    #[test]
528    fn test_md055_complex_table_separators() {
529        let content = r#"| Left | Center | Right |
530|:-----|:------:|------:|
531| L1   | C1     | R1    |
532| L2   | C2     | R2    |
533"#;
534
535        let document = create_test_document(content);
536        let rule = MD055::new();
537        let violations = rule.check(&document).unwrap();
538        assert_eq!(violations.len(), 0);
539    }
540}