mdbook_lint_core/rules/standard/
md030.rs

1//! MD030: Spaces after list markers
2//!
3//! This rule checks for consistent spacing after list markers.
4//! Unordered lists should have one space after the marker, and ordered lists should have one space after the period.
5
6use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9    Document,
10    violation::{Severity, Violation},
11};
12
13/// Configuration for spaces after list markers
14#[derive(Debug, Clone, PartialEq)]
15pub struct MD030Config {
16    /// Number of spaces after unordered list markers (default: 1)
17    pub ul_single: usize,
18    /// Number of spaces after ordered list markers (default: 1)
19    pub ol_single: usize,
20    /// Number of spaces after unordered list markers in multi-item lists (default: 1)
21    pub ul_multi: usize,
22    /// Number of spaces after ordered list markers in multi-item lists (default: 1)
23    pub ol_multi: usize,
24}
25
26impl Default for MD030Config {
27    fn default() -> Self {
28        Self {
29            ul_single: 1,
30            ol_single: 1,
31            ul_multi: 1,
32            ol_multi: 1,
33        }
34    }
35}
36
37/// Rule to check for spaces after list markers
38pub struct MD030 {
39    config: MD030Config,
40}
41
42impl MD030 {
43    /// Create a new MD030 rule with default settings
44    pub fn new() -> Self {
45        Self {
46            config: MD030Config::default(),
47        }
48    }
49
50    /// Create a new MD030 rule with custom configuration
51    #[allow(dead_code)]
52    pub fn with_config(config: MD030Config) -> Self {
53        Self { config }
54    }
55}
56
57impl Default for MD030 {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl Rule for MD030 {
64    fn id(&self) -> &'static str {
65        "MD030"
66    }
67
68    fn name(&self) -> &'static str {
69        "list-marker-space"
70    }
71
72    fn description(&self) -> &'static str {
73        "Spaces after list markers"
74    }
75
76    fn metadata(&self) -> RuleMetadata {
77        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
78    }
79
80    fn check_with_ast<'a>(
81        &self,
82        document: &Document,
83        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
84    ) -> Result<Vec<Violation>> {
85        let mut violations = Vec::new();
86        let mut in_code_block = false;
87
88        for (line_number, line) in document.lines.iter().enumerate() {
89            let line_num = line_number + 1; // Convert to 1-based line numbers
90
91            // Track code block state
92            if line.trim_start().starts_with("```") {
93                in_code_block = !in_code_block;
94                continue;
95            }
96
97            // Skip lines inside code blocks
98            if in_code_block {
99                continue;
100            }
101
102            if let Some(violation) = self.check_list_marker_spacing(line, line_num) {
103                violations.push(violation);
104            }
105        }
106
107        Ok(violations)
108    }
109}
110
111impl MD030 {
112    /// Check spacing after list markers on a single line
113    fn check_list_marker_spacing(&self, line: &str, line_num: usize) -> Option<Violation> {
114        let trimmed = line.trim_start();
115        let indent_count = line.len() - trimmed.len();
116
117        // Skip setext heading underlines (lines that are all = or - characters)
118        if self.is_setext_underline(trimmed) {
119            return None;
120        }
121
122        // Check for unordered list markers
123        if let Some(marker_char) = self.get_unordered_marker(trimmed) {
124            let after_marker = &trimmed[1..];
125            let whitespace_count = after_marker
126                .chars()
127                .take_while(|&c| c.is_whitespace())
128                .count();
129            let expected_spaces = self.config.ul_single; // TODO: Determine if multi-item
130
131            // For expected_spaces = 1: accept exactly 1 space OR exactly 1 tab
132            let is_valid_spacing = if expected_spaces == 1 {
133                whitespace_count == 1
134                    && (after_marker.starts_with(' ') || after_marker.starts_with('\t'))
135            } else {
136                whitespace_count == expected_spaces
137            };
138
139            if !is_valid_spacing {
140                return Some(self.create_violation(
141                    format!(
142                        "Unordered list marker spacing: expected {expected_spaces} space(s) after '{marker_char}', found {whitespace_count}"
143                    ),
144                    line_num,
145                    indent_count + 2, // Position after the marker
146                    Severity::Warning,
147                ));
148            }
149        }
150
151        // Check for ordered list markers
152        if let Some((number, dot_pos)) = self.get_ordered_marker(trimmed) {
153            let after_dot = &trimmed[dot_pos + 1..];
154            let whitespace_count = after_dot.chars().take_while(|&c| c.is_whitespace()).count();
155            let expected_spaces = self.config.ol_single; // TODO: Determine if multi-item
156
157            // For expected_spaces = 1: accept exactly 1 space OR exactly 1 tab
158            let is_valid_spacing = if expected_spaces == 1 {
159                whitespace_count == 1 && (after_dot.starts_with(' ') || after_dot.starts_with('\t'))
160            } else {
161                whitespace_count == expected_spaces
162            };
163
164            if !is_valid_spacing {
165                return Some(self.create_violation(
166                    format!(
167                        "Ordered list marker spacing: expected {expected_spaces} space(s) after '{number}. ', found {whitespace_count}"
168                    ),
169                    line_num,
170                    indent_count + dot_pos + 2, // Position after the dot
171                    Severity::Warning,
172                ));
173            }
174        }
175
176        None
177    }
178
179    /// Get unordered list marker character if line starts with one
180    fn get_unordered_marker(&self, trimmed: &str) -> Option<char> {
181        let first_char = trimmed.chars().next()?;
182        match first_char {
183            '-' | '*' | '+' => {
184                // Check if this is actually emphasis syntax, not a list marker
185                if self.is_emphasis_syntax(trimmed, first_char) {
186                    return None;
187                }
188                Some(first_char)
189            }
190            _ => None,
191        }
192    }
193
194    /// Check if a line starting with *, -, or + is actually emphasis/bold syntax
195    fn is_emphasis_syntax(&self, trimmed: &str, marker: char) -> bool {
196        // Check for bold syntax: **text** or __text__
197        if marker == '*' && trimmed.starts_with("**") {
198            return true;
199        }
200        if marker == '_' && trimmed.starts_with("__") {
201            return true;
202        }
203
204        // Check for italic syntax that's not a list: *text* (but allow "* text" as list)
205        if marker == '*' {
206            // If there's immediately non-whitespace after the *, it's likely emphasis
207            if let Some(second_char) = trimmed.chars().nth(1)
208                && !second_char.is_whitespace()
209                && second_char != '*'
210                && let Some(closing_pos) = trimmed[2..].find('*')
211            {
212                // Make sure it's not just another list item with * in the text
213                let text_between = &trimmed[1..closing_pos + 2];
214                if !text_between.contains('\n') && closing_pos < 50 {
215                    // Likely emphasis if reasonably short and no newlines
216                    return true;
217                }
218            }
219        }
220
221        // For - and +, only consider them emphasis in very specific cases
222        // Most of the time, these should be treated as potential list markers
223        // We'll be conservative here and only exclude obvious non-list cases
224
225        false
226    }
227
228    /// Get ordered list marker number and dot position if line starts with one
229    fn get_ordered_marker(&self, trimmed: &str) -> Option<(String, usize)> {
230        // Look for pattern like "1. " or "42. "
231        let dot_pos = trimmed.find('.')?;
232        let prefix = &trimmed[..dot_pos];
233
234        // Check if prefix is all digits
235        if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
236            Some((prefix.to_string(), dot_pos))
237        } else {
238            None
239        }
240    }
241
242    /// Check if a line is a setext heading underline (all = or - characters)
243    fn is_setext_underline(&self, trimmed: &str) -> bool {
244        if trimmed.is_empty() {
245            return false;
246        }
247
248        let first_char = trimmed.chars().next().unwrap();
249        (first_char == '=' || first_char == '-') && trimmed.chars().all(|c| c == first_char)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::Document;
257    use crate::rule::Rule;
258    use std::path::PathBuf;
259
260    #[test]
261    fn test_md030_no_violations() {
262        let content = r#"# Valid List Spacing
263
264Unordered lists with single space:
265- Item 1
266* Item 2
267+ Item 3
268
269Ordered lists with single space:
2701. First item
2712. Second item
27242. Item with large number
273
274Regular text here.
275"#;
276        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
277        let rule = MD030::new();
278        let violations = rule.check(&document).unwrap();
279
280        assert_eq!(violations.len(), 0);
281    }
282
283    #[test]
284    fn test_md030_unordered_multiple_spaces() {
285        let content = r#"# Unordered List Spacing Issues
286
287- Single space is fine
288-  Two spaces after dash
289*   Three spaces after asterisk
290+    Four spaces after plus
291
292Regular text.
293"#;
294        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
295        let rule = MD030::new();
296        let violations = rule.check(&document).unwrap();
297
298        assert_eq!(violations.len(), 3);
299        assert!(
300            violations[0]
301                .message
302                .contains("expected 1 space(s) after '-', found 2")
303        );
304        assert!(
305            violations[1]
306                .message
307                .contains("expected 1 space(s) after '*', found 3")
308        );
309        assert!(
310            violations[2]
311                .message
312                .contains("expected 1 space(s) after '+', found 4")
313        );
314        assert_eq!(violations[0].line, 4);
315        assert_eq!(violations[1].line, 5);
316        assert_eq!(violations[2].line, 6);
317    }
318
319    #[test]
320    fn test_md030_ordered_multiple_spaces() {
321        let content = r#"# Ordered List Spacing Issues
322
3231. Single space is fine
3242.  Two spaces after number
32542.   Three spaces after large number
326100.    Four spaces after even larger number
327
328Regular text.
329"#;
330        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
331        let rule = MD030::new();
332        let violations = rule.check(&document).unwrap();
333
334        assert_eq!(violations.len(), 3);
335        assert!(
336            violations[0]
337                .message
338                .contains("expected 1 space(s) after '2. ', found 2")
339        );
340        assert!(
341            violations[1]
342                .message
343                .contains("expected 1 space(s) after '42. ', found 3")
344        );
345        assert!(
346            violations[2]
347                .message
348                .contains("expected 1 space(s) after '100. ', found 4")
349        );
350        assert_eq!(violations[0].line, 4);
351        assert_eq!(violations[1].line, 5);
352        assert_eq!(violations[2].line, 6);
353    }
354
355    #[test]
356    fn test_md030_no_spaces_after_marker() {
357        let content = r#"# No Spaces After Markers
358
359-No space after dash
360*No space after asterisk
361+No space after plus
3621.No space after number
36342.No space after large number
364
365Text here.
366"#;
367        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
368        let rule = MD030::new();
369        let violations = rule.check(&document).unwrap();
370
371        assert_eq!(violations.len(), 5);
372        for violation in &violations {
373            assert!(violation.message.contains("expected 1 space(s)"));
374            assert!(violation.message.contains("found 0"));
375        }
376    }
377
378    #[test]
379    fn test_md030_custom_config() {
380        let content = r#"# Custom Configuration Test
381
382- Single space (should be invalid)
383-  Two spaces (should be valid)
3841. Single space (should be invalid)
3852.  Two spaces (should be valid)
386
387Text here.
388"#;
389        let config = MD030Config {
390            ul_single: 2,
391            ol_single: 2,
392            ul_multi: 2,
393            ol_multi: 2,
394        };
395        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
396        let rule = MD030::with_config(config);
397        let violations = rule.check(&document).unwrap();
398
399        assert_eq!(violations.len(), 2);
400        assert!(
401            violations[0]
402                .message
403                .contains("expected 2 space(s) after '-', found 1")
404        );
405        assert!(
406            violations[1]
407                .message
408                .contains("expected 2 space(s) after '1. ', found 1")
409        );
410        assert_eq!(violations[0].line, 3);
411        assert_eq!(violations[1].line, 5);
412    }
413
414    #[test]
415    fn test_md030_indented_lists() {
416        let content = r#"# Moderately Indented Lists
417
418  - Moderately indented item
419  -  Too many spaces
420  * Another marker type
421  *   Too many spaces here too
422
423Regular text here.
424
4251. Regular ordered list
4262.  Too many spaces
42742. Correct spacing
428100.   Too many spaces
429
430Text here.
431"#;
432        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
433        let rule = MD030::new();
434        let violations = rule.check(&document).unwrap();
435
436        assert_eq!(violations.len(), 4);
437        assert_eq!(violations[0].line, 4); // -  Too many spaces
438        assert_eq!(violations[1].line, 6); // *   Too many spaces here too
439        assert_eq!(violations[2].line, 11); // 2.  Too many spaces
440        assert_eq!(violations[3].line, 13); // 100.   Too many spaces
441    }
442
443    #[test]
444    fn test_md030_nested_lists() {
445        let content = r#"# Nested Lists
446
447- Top level item
448  - Nested item with correct spacing
449  -  Nested item with too many spaces
450  * Different marker type
451  *   Too many spaces with asterisk
452    1. Nested ordered list
453    2.  Too many spaces in nested ordered
454    3. Correct spacing
455
456More text.
457"#;
458        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
459        let rule = MD030::new();
460        let violations = rule.check(&document).unwrap();
461
462        assert_eq!(violations.len(), 3);
463        assert_eq!(violations[0].line, 5); // -  Nested item with too many spaces
464        assert_eq!(violations[1].line, 7); // *   Too many spaces with asterisk
465        assert_eq!(violations[2].line, 9); // 2.  Too many spaces in nested ordered
466    }
467
468    #[test]
469    fn test_md030_mixed_violations() {
470        let content = r#"# Mixed Violations
471
472- Correct spacing
473-  Too many spaces
474* Correct spacing
475*No spaces
476+ Correct spacing
477+   Way too many spaces
478
4791. Correct spacing
4802.  Too many spaces
4813. Correct spacing
48242.No spaces
483100.     Many spaces
484
485Text here.
486"#;
487        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
488        let rule = MD030::new();
489        let violations = rule.check(&document).unwrap();
490
491        assert_eq!(violations.len(), 6);
492        // Unordered violations
493        assert_eq!(violations[0].line, 4); // -  Too many spaces
494        assert_eq!(violations[1].line, 6); // *No spaces
495        assert_eq!(violations[2].line, 8); // +   Way too many spaces
496        // Ordered violations
497        assert_eq!(violations[3].line, 11); // 2.  Too many spaces
498        assert_eq!(violations[4].line, 13); // 42.No spaces
499        assert_eq!(violations[5].line, 14); // 100.     Many spaces
500    }
501
502    #[test]
503    fn test_md030_tabs_after_markers() {
504        let content = "- Item with tab\t\n*\tItem starting with tab\n1.\tOrdered with tab\n42.\t\tMultiple tabs\n";
505        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
506        let rule = MD030::new();
507        let violations = rule.check(&document).unwrap();
508
509        // Single tabs should be treated as valid (equivalent to single space)
510        // Only multiple tabs should be flagged as violations
511        assert_eq!(violations.len(), 1); // Only the multiple tabs case should be flagged
512        assert_eq!(violations[0].line, 4); // 42.\t\tMultiple tabs
513    }
514
515    #[test]
516    fn test_md030_get_markers() {
517        let rule = MD030::new();
518
519        // Unordered markers
520        assert_eq!(rule.get_unordered_marker("- Item"), Some('-'));
521        assert_eq!(rule.get_unordered_marker("* Item"), Some('*'));
522        assert_eq!(rule.get_unordered_marker("+ Item"), Some('+'));
523        assert_eq!(rule.get_unordered_marker("Not a marker"), None);
524        assert_eq!(rule.get_unordered_marker("1. Ordered"), None);
525
526        // Ordered markers
527        assert_eq!(
528            rule.get_ordered_marker("1. Item"),
529            Some(("1".to_string(), 1))
530        );
531        assert_eq!(
532            rule.get_ordered_marker("42. Item"),
533            Some(("42".to_string(), 2))
534        );
535        assert_eq!(
536            rule.get_ordered_marker("100. Item"),
537            Some(("100".to_string(), 3))
538        );
539        assert_eq!(rule.get_ordered_marker("- Unordered"), None);
540        assert_eq!(rule.get_ordered_marker("Not a list"), None);
541        assert_eq!(rule.get_ordered_marker("a. Letter"), None);
542    }
543
544    #[test]
545    fn test_md030_setext_headings_ignored() {
546        let content = r#"Main Heading
547============
548
549Some content here.
550
551Subheading
552----------
553
554More content.
555
556- This is a real list
557- With proper spacing
558"#;
559        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
560        let rule = MD030::new();
561        let violations = rule.check(&document).unwrap();
562
563        // Should have no violations - setext underlines should be ignored
564        assert_eq!(violations.len(), 0);
565    }
566
567    #[test]
568    fn test_md030_is_setext_underline() {
569        let rule = MD030::new();
570
571        // Valid setext underlines
572        assert!(rule.is_setext_underline("============"));
573        assert!(rule.is_setext_underline("----------"));
574        assert!(rule.is_setext_underline("==="));
575        assert!(rule.is_setext_underline("---"));
576        assert!(rule.is_setext_underline("="));
577        assert!(rule.is_setext_underline("-"));
578
579        // Not setext underlines
580        assert!(!rule.is_setext_underline(""));
581        assert!(!rule.is_setext_underline("- Item"));
582        assert!(!rule.is_setext_underline("=-="));
583        assert!(!rule.is_setext_underline("=== Header ==="));
584        assert!(!rule.is_setext_underline("-- Comment --"));
585        assert!(!rule.is_setext_underline("* Not a setext"));
586        assert!(!rule.is_setext_underline("+ Also not"));
587    }
588
589    #[test]
590    fn test_md030_bold_text_not_flagged() {
591        let content = r#"# Bold Text Should Not Be Flagged
592
593**Types**: feat, fix, docs
594**Scopes**: cli, preprocessor, rules
595**Important**: This is bold text, not a list marker
596
597Regular bold text like **this** should be fine.
598Italic text like *this* should also be fine.
599
600But actual lists should still be checked:
601- Valid list item
602-  Invalid spacing (should be flagged)
603* Another valid item
604*  Invalid spacing (should be flagged)
605"#;
606        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
607        let rule = MD030::new();
608        let violations = rule.check(&document).unwrap();
609
610        // Should only flag the actual list items with bad spacing, not the bold text
611        assert_eq!(violations.len(), 2);
612        assert!(
613            violations[0]
614                .message
615                .contains("expected 1 space(s) after '-', found 2")
616        );
617        assert!(
618            violations[1]
619                .message
620                .contains("expected 1 space(s) after '*', found 2")
621        );
622        assert_eq!(violations[0].line, 12); // -  Invalid spacing (corrected line number)
623        assert_eq!(violations[1].line, 14); // *  Invalid spacing (corrected line number)
624    }
625
626    #[test]
627    fn test_md030_emphasis_syntax_detection() {
628        let rule = MD030::new();
629
630        // Bold syntax should be detected as emphasis
631        assert!(rule.is_emphasis_syntax("**bold text**", '*'));
632        assert!(rule.is_emphasis_syntax("**Types**: something", '*'));
633        assert!(rule.is_emphasis_syntax("__bold text__", '_'));
634
635        // Italic syntax should be detected as emphasis
636        assert!(rule.is_emphasis_syntax("*italic text*", '*'));
637        assert!(rule.is_emphasis_syntax("*word*", '*'));
638
639        // List markers should NOT be detected as emphasis
640        assert!(!rule.is_emphasis_syntax("* List item", '*'));
641        assert!(!rule.is_emphasis_syntax("- List item", '-'));
642        assert!(!rule.is_emphasis_syntax("+ List item", '+'));
643        assert!(!rule.is_emphasis_syntax("*  List with extra spaces", '*'));
644
645        // Edge cases
646        assert!(!rule.is_emphasis_syntax("* ", '*')); // Just marker and space
647        assert!(!rule.is_emphasis_syntax("*", '*')); // Just marker
648        assert!(!rule.is_emphasis_syntax("*text with no closing", '*')); // No closing marker
649    }
650
651    #[test]
652    fn test_md030_mixed_emphasis_and_lists() {
653        let content = r#"# Mixed Content
654
655**Bold**: This should not be flagged
656*Italic*: This should not be flagged
657
658Valid lists:
659- Item one
660* Item two  
661+ Item three
662
663Invalid lists:
664-  Too many spaces after dash
665*  Too many spaces after asterisk
666+  Too many spaces after plus
667
668More **bold text** that should be ignored.
669And some *italic text* that should be ignored.
670"#;
671        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
672        let rule = MD030::new();
673        let violations = rule.check(&document).unwrap();
674
675        // Should only flag the 3 invalid list items, not the emphasis text
676        assert_eq!(violations.len(), 3);
677        for violation in &violations {
678            assert!(violation.message.contains("expected 1 space(s)"));
679            assert!(violation.message.contains("found 2"));
680        }
681        assert_eq!(violations[0].line, 12); // -  Too many spaces after dash
682        assert_eq!(violations[1].line, 13); // *  Too many spaces after asterisk  
683        assert_eq!(violations[2].line, 14); // +  Too many spaces after plus
684    }
685
686    #[test]
687    fn test_md030_get_unordered_marker_with_emphasis() {
688        let rule = MD030::new();
689
690        // Should return marker for actual lists
691        assert_eq!(rule.get_unordered_marker("- List item"), Some('-'));
692        assert_eq!(rule.get_unordered_marker("* List item"), Some('*'));
693        assert_eq!(rule.get_unordered_marker("+ List item"), Some('+'));
694
695        // Should NOT return marker for emphasis syntax
696        assert_eq!(rule.get_unordered_marker("**Bold text**"), None);
697        assert_eq!(rule.get_unordered_marker("*Italic text*"), None);
698        assert_eq!(rule.get_unordered_marker("**Types**: something"), None);
699
700        // Edge cases
701        assert_eq!(rule.get_unordered_marker("Not a list"), None);
702        assert_eq!(rule.get_unordered_marker("1. Ordered list"), None);
703    }
704
705    #[test]
706    fn test_md030_code_blocks_ignored() {
707        let content = r#"# Test Code Blocks
708
709Valid list:
710- Item one
711
712```bash
713# Deploy with CLI flags - these should not trigger MD030
714rot deploy --admin-password secret123 \
715  --database-name myapp \
716  --port 6379
717
718# List items that look like markdown but are inside code
719- Not a real list item, just text
720* Also not a real list item  
7211. Not an ordered list either
722```
723
724Another list:
725- Item two
726"#;
727        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
728        let rule = MD030::new();
729        let violations = rule.check(&document).unwrap();
730
731        // Should have no violations - all apparent list markers are inside code blocks
732        // except the real list items which are properly formatted
733        assert_eq!(violations.len(), 0);
734    }
735}