Skip to main content

mdlint/lint/rules/
md010.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use pulldown_cmark::{Event, Tag, TagEnd};
5use serde_json::Value;
6
7pub struct MD010;
8
9impl Rule for MD010 {
10    fn name(&self) -> &str {
11        "MD010"
12    }
13
14    fn description(&self) -> &str {
15        "Hard tabs"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["whitespace", "hard_tab"]
20    }
21
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let code_blocks = config
24            .and_then(|c| c.get("code_blocks"))
25            .and_then(|v| v.as_bool())
26            .unwrap_or(true);
27
28        let mut violations = Vec::new();
29        let mut in_code_block = false;
30
31        // Track code blocks using the parser
32        let mut code_block_lines = std::collections::HashSet::new();
33
34        if !code_blocks {
35            for (event, range) in parser.parse_with_offsets() {
36                let current_line = parser.offset_to_line(range.start);
37
38                match event {
39                    Event::Start(Tag::CodeBlock(_)) => {
40                        in_code_block = true;
41                    }
42                    Event::End(TagEnd::CodeBlock) => {
43                        in_code_block = false;
44                    }
45                    Event::Text(_) if in_code_block => {
46                        code_block_lines.insert(current_line);
47                    }
48                    _ => {}
49                }
50            }
51        }
52
53        for (line_num, line) in parser.lines().iter().enumerate() {
54            let line_number = line_num + 1;
55
56            // Skip code blocks if configured
57            if !code_blocks && code_block_lines.contains(&line_number) {
58                continue;
59            }
60
61            if let Some(tab_pos) = line.find('\t') {
62                violations.push(Violation {
63                    line: line_number,
64                    column: Some(tab_pos + 1),
65                    rule: self.name().to_string(),
66                    message: "Hard tabs found".to_string(),
67                    fix: Some(Fix {
68                        line_start: line_number,
69                        line_end: line_number,
70                        column_start: None,
71                        column_end: None,
72                        replacement: line.replace('\t', "    "),
73                        description: "Replace tabs with spaces".to_string(),
74                    }),
75                });
76            }
77        }
78
79        violations
80    }
81
82    fn fixable(&self) -> bool {
83        true
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::fix::Fixer;
91
92    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
93        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
94        Fixer::new()
95            .apply_fixes_to_content(content, &fixes)
96            .unwrap()
97    }
98
99    #[test]
100    fn test_no_tabs() {
101        let content = "Line 1\n    Line 2\nLine 3";
102        let parser = MarkdownParser::new(content);
103        let rule = MD010;
104        let violations = rule.check(&parser, None);
105
106        assert_eq!(violations.len(), 0);
107    }
108
109    #[test]
110    fn test_hard_tabs() {
111        let content = "Line 1\n\tLine 2\nLine 3";
112        let parser = MarkdownParser::new(content);
113        let rule = MD010;
114        let violations = rule.check(&parser, None);
115
116        assert_eq!(violations.len(), 1);
117        assert_eq!(violations[0].line, 2);
118        assert_eq!(violations[0].column, Some(1));
119    }
120
121    #[test]
122    fn test_tabs_in_code_block() {
123        let content = "Text\n```\n\tcode\n```";
124        let parser = MarkdownParser::new(content);
125        let rule = MD010;
126        let violations = rule.check(&parser, None);
127
128        // By default, code_blocks is true, so tabs in code blocks are violations
129        assert_eq!(violations.len(), 1);
130    }
131
132    #[test]
133    fn test_ignore_code_blocks() {
134        let content = "Text\n```\n\tcode\n```";
135        let parser = MarkdownParser::new(content);
136        let rule = MD010;
137        let config = serde_json::json!({ "code_blocks": false });
138        let violations = rule.check(&parser, Some(&config));
139
140        assert_eq!(violations.len(), 0);
141    }
142
143    #[test]
144    fn test_fix_replaces_tab_with_spaces() {
145        let content = "# Heading\n\n\tTabbed line\n";
146        let parser = MarkdownParser::new(content);
147        let rule = MD010;
148        let violations = rule.check(&parser, None);
149        let fixed = apply_fixes(content, &violations);
150        assert!(!fixed.contains('\t'), "tabs should be replaced after fix");
151        assert!(
152            fixed.contains("    Tabbed line"),
153            "tab should become 4 spaces"
154        );
155    }
156}