mdbook_lint_core/rules/standard/
md058.rs

1//! MD058: Tables should be surrounded by blank lines
2//!
3//! This rule checks that tables are surrounded by blank lines for better readability.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check that tables are surrounded by blank lines
14pub struct MD058;
15
16impl MD058 {
17    /// Get line and column position for a node
18    fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
19        let data = node.data.borrow();
20        let pos = data.sourcepos;
21        (pos.start.line, pos.start.column)
22    }
23
24    /// Check if a line is blank (empty or whitespace only)
25    fn is_blank_line(&self, line: &str) -> bool {
26        line.trim().is_empty()
27    }
28
29    /// Walk AST and find all table violations
30    fn check_node<'a>(
31        &self,
32        node: &'a AstNode<'a>,
33        violations: &mut Vec<Violation>,
34        document: &Document,
35    ) {
36        if let NodeValue::Table(_) = &node.data.borrow().value {
37            let (start_line, _) = self.get_position(node);
38            let lines: Vec<&str> = document.content.lines().collect();
39
40            // Find all table segments within this AST node
41            let table_segments = self.find_table_segments(start_line, &lines);
42
43            for (segment_start, segment_end) in table_segments {
44                // Check line before table segment (if not at start of document)
45                if segment_start > 1 {
46                    let line_before_idx = segment_start - 2; // Convert to 0-based and go back one line
47                    if line_before_idx < lines.len() && !self.is_blank_line(lines[line_before_idx])
48                    {
49                        violations.push(self.create_violation(
50                            "Tables should be preceded by a blank line".to_string(),
51                            segment_start,
52                            1,
53                            Severity::Warning,
54                        ));
55                    }
56                }
57
58                // Check line after table segment (if not at end of document)
59                if segment_end < lines.len() {
60                    let line_after_idx = segment_end; // segment_end is 1-based, so this gets the line after
61                    if line_after_idx < lines.len() {
62                        let line_after = lines[line_after_idx];
63                        if !self.is_blank_line(line_after) {
64                            violations.push(self.create_violation(
65                                "Tables should be followed by a blank line".to_string(),
66                                segment_end + 1, // Report on the line after the table
67                                1,
68                                Severity::Warning,
69                            ));
70                        }
71                    }
72                }
73            }
74        }
75
76        // Recursively check children
77        // Continue walking through child nodes
78        for child in node.children() {
79            self.check_node(child, violations, document);
80        }
81    }
82
83    /// Find all table segments within a potentially combined table structure
84    fn find_table_segments(&self, start_line: usize, lines: &[&str]) -> Vec<(usize, usize)> {
85        let mut segments = Vec::new();
86        let mut current_line = start_line - 1; // Convert to 0-based
87
88        while current_line < lines.len() {
89            let line = lines[current_line].trim();
90
91            // Skip until we find a table-like line
92            if !line.contains('|') {
93                current_line += 1;
94                continue;
95            }
96
97            // Found start of a table segment
98            let segment_start = current_line + 1; // Convert back to 1-based
99
100            // Find end of this table segment
101            while current_line < lines.len() {
102                let line = lines[current_line].trim();
103
104                if line.contains('|') {
105                    // Check if it's a table separator
106                    if line
107                        .chars()
108                        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
109                    {
110                        current_line += 1;
111                        continue;
112                    }
113
114                    // Check if it looks like a table row
115                    let pipe_count = line.chars().filter(|&c| c == '|').count();
116                    if pipe_count >= 1 {
117                        current_line += 1;
118                        continue;
119                    }
120                }
121
122                // This line is not part of the table
123                break;
124            }
125
126            let segment_end = current_line; // This is 1-based line number after the table
127            segments.push((segment_start, segment_end));
128
129            // Look for more table segments after non-table content
130            while current_line < lines.len() {
131                let line = lines[current_line].trim();
132                if line.contains('|') {
133                    break; // Found another potential table segment
134                }
135                if line.is_empty() {
136                    break; // Blank line likely separates table segments
137                }
138                current_line += 1;
139            }
140        }
141
142        segments
143    }
144}
145
146impl AstRule for MD058 {
147    fn id(&self) -> &'static str {
148        "MD058"
149    }
150
151    fn name(&self) -> &'static str {
152        "blanks-around-tables"
153    }
154
155    fn description(&self) -> &'static str {
156        "Tables should be surrounded by blank lines"
157    }
158
159    fn metadata(&self) -> RuleMetadata {
160        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
161    }
162
163    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
164        let mut violations = Vec::new();
165        self.check_node(ast, &mut violations, document);
166        Ok(violations)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::rule::Rule;
174    use std::path::PathBuf;
175
176    fn create_test_document(content: &str) -> Document {
177        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
178    }
179
180    #[test]
181    fn test_md058_tables_with_blank_lines_valid() {
182        let content = r#"Here is some text.
183
184| Column 1 | Column 2 |
185|----------|----------|
186| Value 1  | Value 2  |
187
188More text after the table.
189"#;
190
191        let document = create_test_document(content);
192        let rule = MD058;
193        let violations = rule.check(&document).unwrap();
194        assert_eq!(violations.len(), 0);
195    }
196
197    #[test]
198    fn test_md058_table_at_start_of_document() {
199        let content = r#"| Column 1 | Column 2 |
200|----------|----------|
201| Value 1  | Value 2  |
202
203Text after the table.
204"#;
205
206        let document = create_test_document(content);
207        let rule = MD058;
208        let violations = rule.check(&document).unwrap();
209        assert_eq!(violations.len(), 0);
210    }
211
212    #[test]
213    fn test_md058_table_at_end_of_document() {
214        let content = r#"Some text before.
215
216| Column 1 | Column 2 |
217|----------|----------|
218| Value 1  | Value 2  |"#;
219
220        let document = create_test_document(content);
221        let rule = MD058;
222        let violations = rule.check(&document).unwrap();
223        assert_eq!(violations.len(), 0);
224    }
225
226    #[test]
227    fn test_md058_table_missing_blank_before() {
228        let content = r#"Here is some text.
229| Column 1 | Column 2 |
230|----------|----------|
231| Value 1  | Value 2  |
232
233More text after.
234"#;
235
236        let document = create_test_document(content);
237        let rule = MD058;
238        let violations = rule.check(&document).unwrap();
239        assert_eq!(violations.len(), 1);
240        assert_eq!(violations[0].rule_id, "MD058");
241        assert!(violations[0].message.contains("preceded by a blank line"));
242        assert_eq!(violations[0].line, 2);
243    }
244
245    #[test]
246    fn test_md058_table_missing_blank_after() {
247        let content = r#"Some text before.
248
249| Column 1 | Column 2 |
250|----------|----------|
251| Value 1  | Value 2  |
252More text after.
253"#;
254
255        let document = create_test_document(content);
256        let rule = MD058;
257        let violations = rule.check(&document).unwrap();
258
259        assert_eq!(violations.len(), 1);
260        assert!(violations[0].message.contains("followed by a blank line"));
261        assert_eq!(violations[0].line, 6);
262    }
263
264    #[test]
265    fn test_md058_table_missing_both_blanks() {
266        let content = r#"Text before.
267| Column 1 | Column 2 |
268|----------|----------|
269| Value 1  | Value 2  |
270Text after.
271"#;
272
273        let document = create_test_document(content);
274        let rule = MD058;
275        let violations = rule.check(&document).unwrap();
276        assert_eq!(violations.len(), 2);
277        assert!(violations[0].message.contains("preceded by a blank line"));
278        assert!(violations[1].message.contains("followed by a blank line"));
279    }
280
281    #[test]
282    fn test_md058_multiple_tables() {
283        let content = r#"First table with proper spacing:
284
285| Table 1  | Column 2 |
286|----------|----------|
287| Value 1  | Value 2  |
288
289Second table also with proper spacing:
290
291| Table 2  | Column 2 |
292|----------|----------|
293| Value 3  | Value 4  |
294
295End of document.
296"#;
297
298        let document = create_test_document(content);
299        let rule = MD058;
300        let violations = rule.check(&document).unwrap();
301        assert_eq!(violations.len(), 0);
302    }
303
304    #[test]
305    fn test_md058_multiple_tables_violations() {
306        let content = r#"First table:
307| Table 1  | Column 2 |
308|----------|----------|
309| Value 1  | Value 2  |
310Second table immediately after:
311| Table 2  | Column 2 |
312|----------|----------|
313| Value 3  | Value 4  |
314End text.
315"#;
316
317        let document = create_test_document(content);
318        let rule = MD058;
319        let violations = rule.check(&document).unwrap();
320        assert_eq!(violations.len(), 4); // Both tables missing before and after blanks
321    }
322
323    #[test]
324    fn test_md058_table_only_document() {
325        let content = r#"| Column 1 | Column 2 |
326|----------|----------|
327| Value 1  | Value 2  |"#;
328
329        let document = create_test_document(content);
330        let rule = MD058;
331        let violations = rule.check(&document).unwrap();
332        assert_eq!(violations.len(), 0); // Table at start and end of document is OK
333    }
334
335    #[test]
336    fn test_md058_tables_with_different_content() {
337        let content = r#"# Heading before table
338| Column 1 | Column 2 |
339|----------|----------|
340| Value 1  | Value 2  |
341
342## Heading after table
343
344Some paragraph.
345
346| Another | Table |
347|---------|-------|
348| More    | Data  |
349
350- List item after table
351"#;
352
353        let document = create_test_document(content);
354        let rule = MD058;
355        let violations = rule.check(&document).unwrap();
356        assert_eq!(violations.len(), 1); // Only first table missing blank before
357        assert!(violations[0].message.contains("preceded by a blank line"));
358    }
359
360    #[test]
361    fn test_md058_complex_table() {
362        let content = r#"Text before.
363
364| Left | Center | Right | Numbers |
365|:-----|:------:|------:|--------:|
366| L1   | C1     | R1    | 123     |
367| L2   | C2     | R2    | 456     |
368| L3   | C3     | R3    | 789     |
369
370Text after.
371"#;
372
373        let document = create_test_document(content);
374        let rule = MD058;
375        let violations = rule.check(&document).unwrap();
376        assert_eq!(violations.len(), 0);
377    }
378
379    #[test]
380    fn test_md058_table_with_empty_cells() {
381        let content = r#"Before text.
382
383| Col1 | Col2 | Col3 |
384|------|------|------|
385| A    |      | C    |
386|      | B    |      |
387| X    | Y    | Z    |
388
389After text.
390"#;
391
392        let document = create_test_document(content);
393        let rule = MD058;
394        let violations = rule.check(&document).unwrap();
395        assert_eq!(violations.len(), 0);
396    }
397}