mdlint/lint/rules/
md010.rs1use 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 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 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 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}