mdbook_lint_core/rules/standard/
md055.rs1use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11
12pub struct MD055 {
14 style: PipeStyle,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum PipeStyle {
20 NoLeadingOrTrailing,
22 LeadingAndTrailing,
24 Consistent,
26}
27
28impl MD055 {
29 pub fn new() -> Self {
31 Self {
32 style: PipeStyle::Consistent,
33 }
34 }
35
36 #[allow(dead_code)]
38 pub fn with_style(style: PipeStyle) -> Self {
39 Self { style }
40 }
41
42 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 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 if !first_line.contains('|') {
69 return None;
70 }
71
72 let has_leading_trailing = first_line.starts_with('|') && first_line.ends_with('|');
76
77 if has_leading_trailing {
78 let mut end = start;
80 while end < lines.len() {
81 let line = lines[end].trim();
82 if line.is_empty() {
83 break;
84 }
85 if !line.contains('|') {
87 break;
88 }
89 end += 1;
90 }
91
92 if end > start {
93 return Some(end - 1);
94 }
95 } else {
96 if start + 1 < lines.len() {
98 let second_line = lines[start + 1].trim();
99 if self.is_table_separator(second_line) {
100 let mut end = start + 1; end += 1; while end < lines.len() {
105 let line = lines[end].trim();
106 if line.is_empty() {
107 break;
108 }
109 let pipe_count = line.chars().filter(|&c| c == '|').count();
111 if pipe_count == 0 || self.is_table_separator(line) {
112 break;
113 }
114 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 return Some(end - 1);
126 }
127 }
128 }
129 }
130
131 None
132 }
133
134 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 fn is_table_separator(&self, line: &str) -> bool {
143 let trimmed = line.trim();
144 if !trimmed.contains('|') {
145 return false;
146 }
147
148 let without_pipes = trimmed.replace('|', "");
150 without_pipes
151 .chars()
152 .all(|c| c == '-' || c == ':' || c.is_whitespace())
153 }
154
155 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 None
174 }
175 }
176
177 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 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", };
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", };
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 detected_style = Some(current_style);
214 }
215 } else if self.is_table_row_in_context(line) {
216 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 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 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 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, };
293
294 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 if in_code_block[line_idx] {
302 continue;
303 }
304
305 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 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); 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); 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); 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}