Skip to main content

mdlint/lint/rules/
md055.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use serde_json::Value;
5
6pub struct MD055;
7
8impl Rule for MD055 {
9    fn name(&self) -> &'static str {
10        "MD055"
11    }
12
13    fn description(&self) -> &'static str {
14        "Table pipe style"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["table"]
19    }
20
21    #[allow(clippy::too_many_lines)] // rule logic requires tracking leading/trailing pipe state per row
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let style = config
24            .and_then(|c| c.get("style"))
25            .and_then(|v| v.as_str())
26            .unwrap_or("leading_and_trailing");
27
28        let mut violations = Vec::new();
29        let mut first_style: Option<&str> = None;
30        let code_block_lines = parser.get_code_block_line_numbers();
31        let code_ranges = parser.get_code_ranges();
32
33        for (line_num, line) in parser.lines().iter().enumerate() {
34            let line_number = line_num + 1;
35
36            if code_block_lines.contains(&line_number) {
37                continue;
38            }
39
40            // Check if line is a table row: it must contain a pipe that isn't
41            // inside an inline code span (e.g. `a | b`), otherwise it's just
42            // prose that happens to mention a pipe character.
43            let has_real_pipe = line.match_indices('|').any(|(byte_offset, _)| {
44                let absolute = parser.line_offset_to_absolute(line_number, byte_offset);
45                !code_ranges.iter().any(|range| range.contains(&absolute))
46            });
47            if !has_real_pipe {
48                continue;
49            }
50
51            let trimmed = line.trim();
52
53            // Determine the style of this line
54            let has_leading = trimmed.starts_with('|');
55            let has_trailing = trimmed.ends_with('|');
56
57            let current_style = match (has_leading, has_trailing) {
58                (true, true) => "leading_and_trailing",
59                (true, false) => "leading_only",
60                (false, true) => "trailing_only",
61                (false, false) => "no_leading_or_trailing",
62            };
63
64            if style == "consistent" {
65                if let Some(first) = first_style {
66                    if current_style != first {
67                        // Report separate violations for leading and trailing mismatches
68                        let (first_leading, first_trailing) = match first {
69                            "leading_and_trailing" => (true, true),
70                            "leading_only" => (true, false),
71                            "trailing_only" => (false, true),
72                            _ => (false, false),
73                        };
74
75                        // Check leading pipe
76                        if has_leading != first_leading {
77                            violations.push(Violation {
78                                line: line_number,
79                                column: Some(1),
80                                rule: self.name().to_owned(),
81                                message: format!(
82                                    "Table pipe style should be consistent: expected {}, found {}",
83                                    if first_leading {
84                                        "leading pipe"
85                                    } else {
86                                        "no leading pipe"
87                                    },
88                                    if has_leading {
89                                        "leading pipe"
90                                    } else {
91                                        "no leading pipe"
92                                    }
93                                ),
94                                fix: None,
95                            });
96                        }
97
98                        // Check trailing pipe
99                        if has_trailing != first_trailing {
100                            violations.push(Violation {
101                                line: line_number,
102                                column: Some(1),
103                                rule: self.name().to_owned(),
104                                message: format!(
105                                    "Table pipe style should be consistent: expected {}, found {}",
106                                    if first_trailing {
107                                        "trailing pipe"
108                                    } else {
109                                        "no trailing pipe"
110                                    },
111                                    if has_trailing {
112                                        "trailing pipe"
113                                    } else {
114                                        "no trailing pipe"
115                                    }
116                                ),
117                                fix: None,
118                            });
119                        }
120                    }
121                } else {
122                    first_style = Some(current_style);
123                }
124            } else if style == "leading_and_trailing" && current_style != "leading_and_trailing" {
125                // Report separate violations for missing leading/trailing
126                if !has_leading {
127                    violations.push(Violation {
128                        line: line_number,
129                        column: Some(1),
130                        rule: self.name().to_owned(),
131                        message: "Table should have leading pipe".to_owned(),
132                        fix: None,
133                    });
134                }
135                if !has_trailing {
136                    violations.push(Violation {
137                        line: line_number,
138                        column: Some(1),
139                        rule: self.name().to_owned(),
140                        message: "Table should have trailing pipe".to_owned(),
141                        fix: None,
142                    });
143                }
144            } else if style == "no_leading_or_trailing" && (has_leading || has_trailing) {
145                // Report separate violations for unwanted leading/trailing
146                if has_leading {
147                    violations.push(Violation {
148                        line: line_number,
149                        column: Some(1),
150                        rule: self.name().to_owned(),
151                        message: "Table should not have leading pipe".to_owned(),
152                        fix: None,
153                    });
154                }
155                if has_trailing {
156                    violations.push(Violation {
157                        line: line_number,
158                        column: Some(1),
159                        rule: self.name().to_owned(),
160                        message: "Table should not have trailing pipe".to_owned(),
161                        fix: None,
162                    });
163                }
164            }
165        }
166
167        violations
168    }
169
170    fn fixable(&self) -> bool {
171        false
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_consistent_with_pipes() {
181        let content = "| Col1 | Col2 |\n|------|------|\n| A    | B    |";
182        let parser = MarkdownParser::new(content);
183        let rule = MD055;
184        let violations = rule.check(&parser, None);
185
186        assert_eq!(violations.len(), 0);
187    }
188
189    #[test]
190    fn test_consistent_without_pipes() {
191        let content = "Col1 | Col2\n-----|-----\nA    | B";
192        let parser = MarkdownParser::new(content);
193        let rule = MD055;
194        let config = serde_json::json!({ "style": "consistent" });
195        let violations = rule.check(&parser, Some(&config));
196
197        assert_eq!(violations.len(), 0);
198    }
199
200    #[test]
201    fn test_inconsistent_pipes() {
202        let content = "| Col1 | Col2 |\n|------|------|\nA    | B";
203        let parser = MarkdownParser::new(content);
204        let rule = MD055;
205        let violations = rule.check(&parser, None);
206
207        // Last row is inconsistent: reports 2 violations (missing leading and trailing)
208        assert_eq!(violations.len(), 2);
209    }
210
211    #[test]
212    fn test_enforced_leading_and_trailing() {
213        let content = "Col1 | Col2\n-----|-----\nA | B";
214        let parser = MarkdownParser::new(content);
215        let rule = MD055;
216        let config = serde_json::json!({ "style": "leading_and_trailing" });
217        let violations = rule.check(&parser, Some(&config));
218
219        // 3 rows (header, separator, data) × 2 violations each (missing leading and trailing)
220        assert_eq!(violations.len(), 6);
221    }
222
223    #[test]
224    fn test_simple_table() {
225        let content = "| Header |\n| ------ |\n| Cell   |";
226        let parser = MarkdownParser::new(content);
227        let rule = MD055;
228        let violations = rule.check(&parser, None);
229
230        assert_eq!(violations.len(), 0);
231    }
232
233    #[test]
234    fn test_pipe_only_in_inline_code_span_ignored() {
235        // https://github.com/swanysimon/mdlint/issues/65
236        let content = "# Example\n\nThis is a line with an `a | b` inline code span.";
237        let parser = MarkdownParser::new(content);
238        let rule = MD055;
239        let violations = rule.check(&parser, None);
240
241        assert_eq!(violations.len(), 0);
242    }
243
244    #[test]
245    fn test_real_table_with_code_span_pipe_still_flagged() {
246        // A genuine table row whose leading/trailing pipes are real, even
247        // though it also contains a code span with an internal pipe.
248        let content = "Col1 | `a|b`\n-----|-----\nA | B";
249        let parser = MarkdownParser::new(content);
250        let rule = MD055;
251        let config = serde_json::json!({ "style": "leading_and_trailing" });
252        let violations = rule.check(&parser, Some(&config));
253
254        // 3 rows x 2 violations each (missing leading and trailing pipe)
255        assert_eq!(violations.len(), 6);
256    }
257}