quickmark_core/rules/
md058.rs

1use std::rc::Rc;
2
3use tree_sitter::Node;
4
5use crate::{
6    linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation},
7    rules::{Rule, RuleType},
8};
9
10/// MD058 - Tables should be surrounded by blank lines
11///
12/// This rule checks that tables have blank lines before and after them,
13/// except when the table is at the very beginning or end of the document.
14pub(crate) struct MD058Linter {
15    context: Rc<Context>,
16    violations: Vec<RuleViolation>,
17}
18
19impl MD058Linter {
20    pub fn new(context: Rc<Context>) -> Self {
21        Self {
22            context,
23            violations: Vec::new(),
24        }
25    }
26
27    fn check_table_blanks(&mut self, table_node: &Node) {
28        let start_line = table_node.start_position().row;
29        let lines = self.context.lines.borrow();
30
31        // Find the actual last row of the table.
32        // tree-sitter can sometimes identify nodes as table rows even if they are not
33        // part of the table's syntax (e.g., surrounding text).
34        // We filter for children that are actual table components and contain a pipe character.
35        let mut cursor = table_node.walk();
36        let Some(last_row) = table_node
37            .children(&mut cursor)
38            .filter(|child| {
39                matches!(
40                    child.kind(),
41                    "pipe_table_header" | "pipe_table_row" | "pipe_table_delimiter_row"
42                )
43            })
44            .filter(|row| {
45                let row_line = row.start_position().row;
46                lines.get(row_line).is_some_and(|l| l.contains('|'))
47            })
48            .last()
49        else {
50            return; // No valid rows in table, nothing to check.
51        };
52
53        let actual_end_line = last_row.end_position().row;
54
55        // Check for a blank line above the table if it's not at the document start.
56        if start_line > 0 {
57            // A blank line is required only if there is non-blank content somewhere above the table.
58            let has_content_above = (0..start_line).any(|i| !lines[i].trim().is_empty());
59
60            if has_content_above && !lines[start_line - 1].trim().is_empty() {
61                self.violations.push(RuleViolation::new(
62                    &MD058,
63                    format!("{} [Above]", MD058.description),
64                    self.context.file_path.clone(),
65                    range_from_tree_sitter(&table_node.range()),
66                ));
67            }
68        }
69
70        // Check for a blank line below the table if it's not at the document end.
71        if actual_end_line + 1 < lines.len() {
72            // A blank line is required only if there is non-blank content somewhere below the table.
73            let has_content_below =
74                ((actual_end_line + 1)..lines.len()).any(|i| !lines[i].trim().is_empty());
75
76            if has_content_below && !lines[actual_end_line + 1].trim().is_empty() {
77                self.violations.push(RuleViolation::new(
78                    &MD058,
79                    format!("{} [Below]", MD058.description),
80                    self.context.file_path.clone(),
81                    range_from_tree_sitter(&table_node.range()),
82                ));
83            }
84        }
85    }
86}
87
88impl RuleLinter for MD058Linter {
89    fn feed(&mut self, node: &Node) {
90        if node.kind() == "pipe_table" {
91            self.check_table_blanks(node);
92        }
93    }
94
95    fn finalize(&mut self) -> Vec<RuleViolation> {
96        std::mem::take(&mut self.violations)
97    }
98}
99
100pub const MD058: Rule = Rule {
101    id: "MD058",
102    alias: "blanks-around-tables",
103    tags: &["table", "blank_lines"],
104    description: "Tables should be surrounded by blank lines",
105    rule_type: RuleType::Token,
106    required_nodes: &["pipe_table"],
107    new_linter: |context| Box::new(MD058Linter::new(context)),
108};
109
110#[cfg(test)]
111mod test {
112    use std::path::PathBuf;
113
114    use crate::{
115        config::RuleSeverity, linter::MultiRuleLinter,
116        test_utils::test_helpers::test_config_with_rules,
117    };
118
119    fn test_config() -> crate::config::QuickmarkConfig {
120        test_config_with_rules(vec![("blanks-around-tables", RuleSeverity::Error)])
121    }
122
123    #[test]
124    fn test_table_with_proper_blank_lines() {
125        let input = r#"Some text
126
127| Header 1 | Header 2 |
128| -------- | -------- |
129| Cell 1   | Cell 2   |
130
131More text"#;
132        let config = test_config();
133        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
134        let violations = linter.analyze();
135        assert_eq!(0, violations.len());
136    }
137
138    #[test]
139    fn test_table_missing_blank_line_above() {
140        let input = r#"Some text
141| Header 1 | Header 2 |
142| -------- | -------- |
143| Cell 1   | Cell 2   |
144
145More text"#;
146        let config = test_config();
147        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
148        let violations = linter.analyze();
149        assert_eq!(1, violations.len());
150        assert!(violations[0].message().contains("[Above]"));
151    }
152
153    #[test]
154    fn test_table_missing_blank_line_below() {
155        let input = r#"Some text
156
157| Header 1 | Header 2 |
158| -------- | -------- |
159| Cell 1   | Cell 2   |
160More text"#;
161        let config = test_config();
162        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
163        let violations = linter.analyze();
164        assert_eq!(1, violations.len());
165        assert!(violations[0].message().contains("[Below]"));
166    }
167
168    #[test]
169    fn test_table_missing_both_blank_lines() {
170        let input = r#"Some text
171| Header 1 | Header 2 |
172| -------- | -------- |
173| Cell 1   | Cell 2   |
174More text"#;
175        let config = test_config();
176        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
177        let violations = linter.analyze();
178        assert_eq!(2, violations.len());
179        assert!(violations[0].message().contains("[Above]"));
180        assert!(violations[1].message().contains("[Below]"));
181    }
182
183    #[test]
184    fn test_table_at_start_of_document() {
185        let input = r#"| Header 1 | Header 2 |
186| -------- | -------- |
187| Cell 1   | Cell 2   |
188
189More text"#;
190        let config = test_config();
191        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
192        let violations = linter.analyze();
193        // Should not violate - no content above to require blank line
194        assert_eq!(0, violations.len());
195    }
196
197    #[test]
198    fn test_table_at_end_of_document() {
199        let input = r#"Some text
200
201| Header 1 | Header 2 |
202| -------- | -------- |
203| Cell 1   | Cell 2   |"#;
204        let config = test_config();
205        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
206        let violations = linter.analyze();
207        // Should not violate - no content below to require blank line
208        assert_eq!(0, violations.len());
209    }
210
211    #[test]
212    fn test_table_alone_in_document() {
213        let input = r#"| Header 1 | Header 2 |
214| -------- | -------- |
215| Cell 1   | Cell 2   |"#;
216        let config = test_config();
217        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
218        let violations = linter.analyze();
219        // Should not violate - no content above or below
220        assert_eq!(0, violations.len());
221    }
222
223    #[test]
224    fn test_multiple_tables_proper_spacing() {
225        let input = r#"Some text
226
227| Table 1 | Header |
228| ------- | ------ |
229| Cell    | Value  |
230
231Text between tables
232
233| Table 2 | Header |
234| ------- | ------ |
235| Cell    | Value  |
236
237Final text"#;
238        let config = test_config();
239        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
240        let violations = linter.analyze();
241        assert_eq!(0, violations.len());
242    }
243
244    #[test]
245    fn test_multiple_tables_improper_spacing() {
246        let input = r#"Some text
247| Table 1 | Header |
248| ------- | ------ |
249| Cell    | Value  |
250Text between tables
251| Table 2 | Header |
252| ------- | ------ |
253| Cell    | Value  |
254Final text"#;
255        let config = test_config();
256        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
257        let violations = linter.analyze();
258        assert_eq!(4, violations.len()); // 2 tables × 2 violations each (above and below)
259    }
260
261    #[test]
262    fn test_table_with_only_blank_lines_above_and_below() {
263        let input = r#"
264
265
266| Header 1 | Header 2 |
267| -------- | -------- |
268| Cell 1   | Cell 2   |
269
270
271"#;
272        let config = test_config();
273        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
274        let violations = linter.analyze();
275        // Should not violate - no actual content above or below
276        assert_eq!(0, violations.len());
277    }
278}