mdlint/lint/rules/
md055.rs1use 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)] 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(violations.len(), 6);
256 }
257}