mdbook_lint_core/rules/standard/
md030.rs1use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct MD030Config {
16 pub ul_single: usize,
18 pub ol_single: usize,
20 pub ul_multi: usize,
22 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
37pub struct MD030 {
39 config: MD030Config,
40}
41
42impl MD030 {
43 pub fn new() -> Self {
45 Self {
46 config: MD030Config::default(),
47 }
48 }
49
50 #[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; if line.trim_start().starts_with("```") {
93 in_code_block = !in_code_block;
94 continue;
95 }
96
97 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 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 if self.is_setext_underline(trimmed) {
119 return None;
120 }
121
122 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; 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, Severity::Warning,
147 ));
148 }
149 }
150
151 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; 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, Severity::Warning,
172 ));
173 }
174 }
175
176 None
177 }
178
179 fn get_unordered_marker(&self, trimmed: &str) -> Option<char> {
181 let first_char = trimmed.chars().next()?;
182 match first_char {
183 '-' | '*' | '+' => {
184 if self.is_emphasis_syntax(trimmed, first_char) {
186 return None;
187 }
188 Some(first_char)
189 }
190 _ => None,
191 }
192 }
193
194 fn is_emphasis_syntax(&self, trimmed: &str, marker: char) -> bool {
196 if marker == '*' && trimmed.starts_with("**") {
198 return true;
199 }
200 if marker == '_' && trimmed.starts_with("__") {
201 return true;
202 }
203
204 if marker == '*' {
206 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 let text_between = &trimmed[1..closing_pos + 2];
214 if !text_between.contains('\n') && closing_pos < 50 {
215 return true;
217 }
218 }
219 }
220
221 false
226 }
227
228 fn get_ordered_marker(&self, trimmed: &str) -> Option<(String, usize)> {
230 let dot_pos = trimmed.find('.')?;
232 let prefix = &trimmed[..dot_pos];
233
234 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 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); assert_eq!(violations[1].line, 6); assert_eq!(violations[2].line, 11); assert_eq!(violations[3].line, 13); }
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); assert_eq!(violations[1].line, 7); assert_eq!(violations[2].line, 9); }
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 assert_eq!(violations[0].line, 4); assert_eq!(violations[1].line, 6); assert_eq!(violations[2].line, 8); assert_eq!(violations[3].line, 11); assert_eq!(violations[4].line, 13); assert_eq!(violations[5].line, 14); }
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 assert_eq!(violations.len(), 1); assert_eq!(violations[0].line, 4); }
514
515 #[test]
516 fn test_md030_get_markers() {
517 let rule = MD030::new();
518
519 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 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 assert_eq!(violations.len(), 0);
565 }
566
567 #[test]
568 fn test_md030_is_setext_underline() {
569 let rule = MD030::new();
570
571 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 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 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); assert_eq!(violations[1].line, 14); }
625
626 #[test]
627 fn test_md030_emphasis_syntax_detection() {
628 let rule = MD030::new();
629
630 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 assert!(rule.is_emphasis_syntax("*italic text*", '*'));
637 assert!(rule.is_emphasis_syntax("*word*", '*'));
638
639 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 assert!(!rule.is_emphasis_syntax("* ", '*')); assert!(!rule.is_emphasis_syntax("*", '*')); assert!(!rule.is_emphasis_syntax("*text with no closing", '*')); }
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 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); assert_eq!(violations[1].line, 13); assert_eq!(violations[2].line, 14); }
685
686 #[test]
687 fn test_md030_get_unordered_marker_with_emphasis() {
688 let rule = MD030::new();
689
690 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 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 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 assert_eq!(violations.len(), 0);
734 }
735}