quickmark-core 1.1.0

Lightning-fast Markdown/CommonMark linter core library with tree-sitter based parsing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
use std::rc::Rc;
use tree_sitter::Node;

use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};

use super::{Rule, RuleType};

// Pre-computed violation messages to avoid format! allocations
const MISSING_BLANK_BEFORE: &str =
    "Lists should be surrounded by blank lines [Missing blank line before]";
const MISSING_BLANK_AFTER: &str =
    "Lists should be surrounded by blank lines [Missing blank line after]";

pub(crate) struct MD032Linter {
    context: Rc<Context>,
    violations: Vec<RuleViolation>,
}

impl MD032Linter {
    pub fn new(context: Rc<Context>) -> Self {
        Self {
            context,
            violations: Vec::new(),
        }
    }

    /// Check if a line is blank, handling out-of-bounds safely and considering blockquote context.
    /// Out-of-bounds lines are considered blank to avoid false violations at document boundaries.
    /// Lines containing only blockquote markers (e.g., "> " or ">") are considered blank.
    #[inline]
    fn is_line_blank_cached(&self, line_number: usize, lines: &[String]) -> bool {
        if line_number < lines.len() {
            let line = &lines[line_number];
            let trimmed = line.trim();

            // Regular blank line
            if trimmed.is_empty() {
                return true;
            }

            // Check if this is a blockquote marker line (just >, >>, etc.)
            if trimmed == ">" || trimmed.chars().all(|c| c == '>') {
                return true;
            }

            // Check if this is a blockquote with only spaces ("> ", ">> ", etc.)
            if trimmed.starts_with('>') && trimmed.trim_start_matches('>').trim().is_empty() {
                return true;
            }

            false
        } else {
            true // Consider out-of-bounds lines as blank
        }
    }

    /// Check if a node is within another list structure by traversing up the AST.
    /// This helps identify top-level lists vs nested lists.
    /// Lists within blockquotes are still considered "top-level" for MD032 purposes.
    #[inline]
    fn is_top_level_list(&self, node: &Node) -> bool {
        let mut current = node.parent();
        while let Some(parent) = current {
            match parent.kind() {
                "list" => return false, // Found parent list, so this is nested
                // Stop searching when we hit document-level containers
                "document" | "block_quote" => return true,
                _ => current = parent.parent(),
            }
        }
        true // No parent list found, this is top-level
    }

    /// Find the visual end line of the list by examining actual content
    /// This approach looks at the lines themselves rather than relying solely on tree-sitter boundaries
    fn find_visual_end_line(&self, node: &Node) -> usize {
        let start_line = node.start_position().row;
        let tree_sitter_end_line = node.end_position().row;

        // Borrow lines to examine content
        let lines = self.context.lines.borrow();

        // For blockquoted lists, we need to handle them differently
        // If this is a blockquoted list, trust tree-sitter more
        if lines
            .get(start_line)
            .is_some_and(|line| line.trim_start().starts_with('>'))
        {
            // This is a blockquoted list - be more conservative with tree-sitter boundaries
            // but still exclude trailing blank blockquote lines
            for line_idx in (start_line..=tree_sitter_end_line).rev() {
                if line_idx < lines.len() {
                    let line = &lines[line_idx];
                    let after_quote = line.trim_start_matches('>').trim();

                    // If this line has meaningful content within the blockquote
                    if !after_quote.is_empty() {
                        return line_idx;
                    }
                }
            }
        } else {
            // Regular list - use the existing content-based detection
            for line_idx in (start_line..=tree_sitter_end_line).rev() {
                if line_idx < lines.len() {
                    let line = &lines[line_idx];
                    let trimmed = line.trim();

                    // If this line has content and looks like it could be part of a list item
                    if !trimmed.is_empty() {
                        // Check if it's definitely NOT a block element
                        let is_thematic_break = trimmed.len() >= 3
                            && (trimmed.chars().all(|c| c == '-')
                                || trimmed.chars().all(|c| c == '*')
                                || trimmed.chars().all(|c| c == '_'));

                        let is_block_element = trimmed.starts_with('#') || // headings
                            trimmed.starts_with("```") || trimmed.starts_with("~~~") || // code blocks
                            is_thematic_break; // thematic breaks

                        if !is_block_element {
                            return line_idx;
                        }
                    }
                }
            }
        }

        // Fallback to node's start line if no content found
        start_line
    }

    fn check_list(&mut self, node: &Node) {
        // Only check top-level lists
        if !self.is_top_level_list(node) {
            return;
        }

        let start_line = node.start_position().row;
        let end_line = self.find_visual_end_line(node);

        // Single borrow for the entire function to avoid multiple RefCell runtime checks
        let lines = self.context.lines.borrow();
        let total_lines = lines.len();

        // Check blank line above (only if not at document start)
        if start_line > 0 {
            let line_above = start_line - 1;
            if !self.is_line_blank_cached(line_above, &lines) {
                self.violations.push(RuleViolation::new(
                    &MD032,
                    MISSING_BLANK_BEFORE.to_string(),
                    self.context.file_path.clone(),
                    range_from_tree_sitter(&node.range()),
                ));
            }
        }

        // Check blank line below (following original markdownlint logic)
        // The original checks lines[lastLineNumber] where lastLineNumber is the line after the list
        let line_after_list_idx = end_line + 1;
        if line_after_list_idx < total_lines {
            let is_blank = self.is_line_blank_cached(line_after_list_idx, &lines);

            // If the line immediately after the list is not blank, report a violation
            // This matches the original markdownlint behavior exactly
            if !is_blank {
                self.violations.push(RuleViolation::new(
                    &MD032,
                    MISSING_BLANK_AFTER.to_string(),
                    self.context.file_path.clone(),
                    range_from_tree_sitter(&node.range()),
                ));
            }
        }
    }
}

impl RuleLinter for MD032Linter {
    fn feed(&mut self, node: &Node) {
        if node.kind() == "list" {
            self.check_list(node);
        }
    }

    fn finalize(&mut self) -> Vec<RuleViolation> {
        std::mem::take(&mut self.violations)
    }
}

pub const MD032: Rule = Rule {
    id: "MD032",
    alias: "blanks-around-lists",
    tags: &["blank_lines", "bullet", "ol", "ul"],
    description: "Lists should be surrounded by blank lines",
    rule_type: RuleType::Hybrid,
    required_nodes: &["list"],
    new_linter: |context| Box::new(MD032Linter::new(context)),
};

#[cfg(test)]
mod test {
    use std::path::PathBuf;

    use crate::config::RuleSeverity;
    use crate::linter::MultiRuleLinter;
    use crate::test_utils::test_helpers::test_config_with_settings;

    fn test_config_default() -> crate::config::QuickmarkConfig {
        test_config_with_settings(
            vec![
                ("blanks-around-lists", RuleSeverity::Error),
                ("heading-style", RuleSeverity::Off),
                ("heading-increment", RuleSeverity::Off),
            ],
            Default::default(),
        )
    }

    #[test]
    fn test_no_violation_proper_blanks() {
        let config = test_config_default();

        let input = "Some text

* List item
* List item

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(0, violations.len());
    }

    #[test]
    fn test_violation_missing_blank_above() {
        let config = test_config_default();

        let input = "Some text
* List item
* List item

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(1, violations.len());
        assert!(violations[0].message().contains("blank line before"));
    }

    #[test]
    fn test_violation_missing_blank_below() {
        let config = test_config_default();

        // Use a thematic break instead of paragraph text to avoid lazy continuation
        let input = "Some text

* List item
* List item
---";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(1, violations.len());
        assert!(violations[0].message().contains("blank line after"));
    }

    #[test]
    fn test_violation_missing_both_blanks() {
        let config = test_config_default();

        // Use a thematic break to avoid lazy continuation
        let input = "Some text
* List item
* List item
---";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(2, violations.len());
        assert!(violations[0].message().contains("blank line"));
        assert!(violations[1].message().contains("blank line"));
    }

    #[test]
    fn test_no_violation_at_document_start() {
        let config = test_config_default();

        let input = "* List item
* List item

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(0, violations.len());
    }

    #[test]
    fn test_no_violation_at_document_end() {
        let config = test_config_default();

        let input = "Some text

* List item
* List item";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(0, violations.len());
    }

    #[test]
    fn test_ordered_list_violations() {
        let config = test_config_default();

        let input = "Some text
1. List item
2. List item
---";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        assert_eq!(2, violations.len()); // Both missing blank above and below
    }

    #[test]
    fn test_mixed_list_markers() {
        let config = test_config_default();

        let input = "Some text
+ List item
- List item
More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // Original markdownlint detects 3 violations:
        // + List item (missing blank before and after), - List item (missing blank before)
        assert_eq!(3, violations.len());
    }

    #[test]
    fn test_nested_lists_no_violation() {
        let config = test_config_default();

        let input = "Some text

* List item
  * Nested item
  * Nested item
* List item

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // Should not report violations for nested lists, only top-level
        assert_eq!(0, violations.len());
    }

    #[test]
    fn test_lists_in_blockquotes() {
        let config = test_config_default();

        let input = "> Some text
>
> * List item
> * List item
>
> More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // Should handle blockquote context properly
        assert_eq!(0, violations.len());
    }

    #[test]
    fn test_lists_in_blockquotes_violation() {
        let config = test_config_default();

        let input = "> Some text
> * List item
> * List item
> More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // Should detect violations even in blockquotes (only missing blank before due to lazy continuation)
        assert_eq!(1, violations.len());
    }

    #[test]
    fn test_list_with_horizontal_rule_before() {
        let config = test_config_default();

        let input = "Some text

---
* List item
* List item

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // HR immediately before list should trigger violation
        assert_eq!(1, violations.len());
        assert!(violations[0].message().contains("blank line before"));
    }

    #[test]
    fn test_list_with_horizontal_rule_after() {
        let config = test_config_default();

        let input = "Some text

* List item
* List item
---

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // HR immediately after list should trigger violation
        assert_eq!(1, violations.len());
        assert!(violations[0].message().contains("blank line after"));
    }

    #[test]
    fn test_list_with_code_block_before() {
        let config = test_config_default();

        let input = "Some text

```
code
```
* List item
* List item

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // Code block immediately before list should trigger violation
        assert_eq!(1, violations.len());
        assert!(violations[0].message().contains("blank line before"));
    }

    #[test]
    fn test_list_with_code_block_after() {
        let config = test_config_default();

        let input = "Some text

* List item
* List item
```
code
```

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // Code block immediately after list should trigger violation
        assert_eq!(1, violations.len());
        assert!(violations[0].message().contains("blank line after"));
    }

    #[test]
    fn test_lazy_continuation_line() {
        let config = test_config_default();

        let input = "Some text

1. List item
   More item 1
2. List item
More item 2

More text";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // "More item 2" is a lazy continuation line, should not trigger violation
        assert_eq!(0, violations.len());
    }

    #[test]
    fn test_list_at_document_boundaries_complete() {
        let config = test_config_default();

        let input = "* List item
* List item";
        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
        let violations = linter.analyze();
        // List spans entire document - no violations expected
        assert_eq!(0, violations.len());
    }
}