1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::{LineIndex, calculate_match_range};
7use crate::utils::regex_cache::{HTML_COMMENT_END, HTML_COMMENT_START};
8
9mod md010_config;
10use md010_config::MD010Config;
11
12#[derive(Clone, Default)]
16pub struct MD010NoHardTabs {
17 config: MD010Config,
18}
19
20impl MD010NoHardTabs {
21 pub fn new(spaces_per_tab: usize) -> Self {
22 Self {
23 config: MD010Config { spaces_per_tab },
24 }
25 }
26
27 pub fn from_config_struct(config: MD010Config) -> Self {
28 Self { config }
29 }
30
31 fn find_html_comment_lines(lines: &[&str]) -> Vec<bool> {
33 let mut in_html_comment = false;
34 let mut html_comment_lines = vec![false; lines.len()];
35
36 for (i, line) in lines.iter().enumerate() {
37 let has_comment_start = HTML_COMMENT_START.is_match(line);
39 let has_comment_end = HTML_COMMENT_END.is_match(line);
41
42 if has_comment_start && !has_comment_end && !in_html_comment {
43 in_html_comment = true;
45 html_comment_lines[i] = true;
46 } else if has_comment_end && in_html_comment {
47 html_comment_lines[i] = true;
49 in_html_comment = false;
50 } else if has_comment_start && has_comment_end {
51 html_comment_lines[i] = true;
53 } else if in_html_comment {
54 html_comment_lines[i] = true;
56 }
57 }
58
59 html_comment_lines
60 }
61
62 fn count_leading_tabs(line: &str) -> usize {
63 let mut count = 0;
64 for c in line.chars() {
65 if c == '\t' {
66 count += 1;
67 } else {
68 break;
69 }
70 }
71 count
72 }
73
74 fn find_tab_positions(line: &str) -> Vec<usize> {
75 line.chars()
76 .enumerate()
77 .filter(|(_, c)| *c == '\t')
78 .map(|(i, _)| i)
79 .collect()
80 }
81
82 fn group_consecutive_tabs(tab_positions: &[usize]) -> Vec<(usize, usize)> {
83 if tab_positions.is_empty() {
84 return Vec::new();
85 }
86
87 let mut groups = Vec::new();
88 let mut start = tab_positions[0];
89 let mut end = tab_positions[0];
90
91 for &pos in tab_positions.iter().skip(1) {
92 if pos == end + 1 {
93 end = pos;
95 } else {
96 groups.push((start, end + 1)); start = pos;
99 end = pos;
100 }
101 }
102
103 groups.push((start, end + 1));
105 groups
106 }
107}
108
109impl Rule for MD010NoHardTabs {
110 fn name(&self) -> &'static str {
111 "MD010"
112 }
113
114 fn description(&self) -> &'static str {
115 "No tabs"
116 }
117
118 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
119 let content = ctx.content;
120 let _line_index = LineIndex::new(content.to_string());
121
122 let mut warnings = Vec::new();
123 let lines: Vec<&str> = content.lines().collect();
124
125 let html_comment_lines = Self::find_html_comment_lines(&lines);
127
128 for (line_num, &line) in lines.iter().enumerate() {
129 if html_comment_lines[line_num] {
131 continue;
132 }
133
134 if let Some(line_info) = ctx.line_info(line_num + 1)
136 && line_info.in_code_block
137 {
138 continue;
139 }
140
141 let tab_positions = Self::find_tab_positions(line);
142 if tab_positions.is_empty() {
143 continue;
144 }
145
146 let leading_tabs = Self::count_leading_tabs(line);
147 let tab_groups = Self::group_consecutive_tabs(&tab_positions);
148
149 for (start_pos, end_pos) in tab_groups {
151 let tab_count = end_pos - start_pos;
152 let is_leading = start_pos < leading_tabs;
153
154 let (start_line, start_col, end_line, end_col) =
156 calculate_match_range(line_num + 1, line, start_pos, tab_count);
157
158 let message = if line.trim().is_empty() {
159 if tab_count == 1 {
160 "Empty line contains tab".to_string()
161 } else {
162 format!("Empty line contains {tab_count} tabs")
163 }
164 } else if is_leading {
165 if tab_count == 1 {
166 format!("Found leading tab, use {} spaces instead", self.config.spaces_per_tab)
167 } else {
168 format!(
169 "Found {} leading tabs, use {} spaces instead",
170 tab_count,
171 tab_count * self.config.spaces_per_tab
172 )
173 }
174 } else if tab_count == 1 {
175 "Found tab for alignment, use spaces instead".to_string()
176 } else {
177 format!("Found {tab_count} tabs for alignment, use spaces instead")
178 };
179
180 warnings.push(LintWarning {
181 rule_name: Some(self.name()),
182 line: start_line,
183 column: start_col,
184 end_line,
185 end_column: end_col,
186 message,
187 severity: Severity::Warning,
188 fix: Some(Fix {
189 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
190 replacement: " ".repeat(tab_count * self.config.spaces_per_tab),
191 }),
192 });
193 }
194 }
195
196 Ok(warnings)
197 }
198
199 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
200 let content = ctx.content;
201 let line_index = LineIndex::new(content.to_string());
202
203 let mut result = String::new();
204 let lines: Vec<&str> = content.lines().collect();
205
206 let html_comment_lines = Self::find_html_comment_lines(&lines);
208
209 let mut line_positions = Vec::with_capacity(lines.len());
211 for i in 0..lines.len() {
212 line_positions.push(line_index.get_line_start_byte(i + 1).unwrap_or(0));
213 }
214
215 for (i, line) in lines.iter().enumerate() {
216 if html_comment_lines[i] {
217 result.push_str(line);
219 } else if ctx.is_in_code_block_or_span(line_positions[i]) {
220 result.push_str(line);
222 } else {
223 result.push_str(&line.replace('\t', &" ".repeat(self.config.spaces_per_tab)));
225 }
226
227 if i < lines.len() - 1 || content.ends_with('\n') {
229 result.push('\n');
230 }
231 }
232
233 Ok(result)
234 }
235
236 fn as_any(&self) -> &dyn std::any::Any {
237 self
238 }
239
240 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
241 ctx.content.is_empty() || !ctx.has_char('\t')
243 }
244
245 fn category(&self) -> RuleCategory {
246 RuleCategory::Whitespace
247 }
248
249 fn default_config_section(&self) -> Option<(String, toml::Value)> {
250 let default_config = MD010Config::default();
251 let json_value = serde_json::to_value(&default_config).ok()?;
252 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
253
254 if let toml::Value::Table(table) = toml_value {
255 if !table.is_empty() {
256 Some((MD010Config::RULE_NAME.to_string(), toml::Value::Table(table)))
257 } else {
258 None
259 }
260 } else {
261 None
262 }
263 }
264
265 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
266 where
267 Self: Sized,
268 {
269 let rule_config = crate::rule_config_serde::load_rule_config::<MD010Config>(config);
270 Box::new(Self::from_config_struct(rule_config))
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::lint_context::LintContext;
278 use crate::rule::Rule;
279
280 #[test]
281 fn test_no_tabs() {
282 let rule = MD010NoHardTabs::default();
283 let content = "This is a line\nAnother line\nNo tabs here";
284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
285 let result = rule.check(&ctx).unwrap();
286 assert!(result.is_empty());
287 }
288
289 #[test]
290 fn test_single_tab() {
291 let rule = MD010NoHardTabs::default();
292 let content = "Line with\ttab";
293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294 let result = rule.check(&ctx).unwrap();
295 assert_eq!(result.len(), 1);
296 assert_eq!(result[0].line, 1);
297 assert_eq!(result[0].column, 10);
298 assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
299 }
300
301 #[test]
302 fn test_leading_tabs() {
303 let rule = MD010NoHardTabs::default();
304 let content = "\tIndented line\n\t\tDouble indented";
305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
306 let result = rule.check(&ctx).unwrap();
307 assert_eq!(result.len(), 2);
308 assert_eq!(result[0].line, 1);
309 assert_eq!(result[0].message, "Found leading tab, use 4 spaces instead");
310 assert_eq!(result[1].line, 2);
311 assert_eq!(result[1].message, "Found 2 leading tabs, use 8 spaces instead");
312 }
313
314 #[test]
315 fn test_fix_tabs() {
316 let rule = MD010NoHardTabs::default();
317 let content = "\tIndented\nNormal\tline\nNo tabs";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
319 let fixed = rule.fix(&ctx).unwrap();
320 assert_eq!(fixed, " Indented\nNormal line\nNo tabs");
321 }
322
323 #[test]
324 fn test_custom_spaces_per_tab() {
325 let rule = MD010NoHardTabs::new(4);
326 let content = "\tIndented";
327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
328 let fixed = rule.fix(&ctx).unwrap();
329 assert_eq!(fixed, " Indented");
330 }
331
332 #[test]
333 fn test_code_blocks_always_ignored() {
334 let rule = MD010NoHardTabs::default();
335 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
337 let result = rule.check(&ctx).unwrap();
338 assert_eq!(result.len(), 2);
340 assert_eq!(result[0].line, 1);
341 assert_eq!(result[1].line, 5);
342
343 let fixed = rule.fix(&ctx).unwrap();
344 assert_eq!(fixed, "Normal line\n```\nCode\twith\ttab\n```\nAnother line");
345 }
346
347 #[test]
348 fn test_code_blocks_never_checked() {
349 let rule = MD010NoHardTabs::default();
350 let content = "```\nCode\twith\ttab\n```";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
352 let result = rule.check(&ctx).unwrap();
353 assert_eq!(result.len(), 0);
355 }
356
357 #[test]
358 fn test_html_comments_ignored() {
359 let rule = MD010NoHardTabs::default();
360 let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
362 let result = rule.check(&ctx).unwrap();
363 assert_eq!(result.len(), 2);
365 assert_eq!(result[0].line, 1);
366 assert_eq!(result[1].line, 3);
367 }
368
369 #[test]
370 fn test_multiline_html_comments() {
371 let rule = MD010NoHardTabs::default();
372 let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
374 let result = rule.check(&ctx).unwrap();
375 assert_eq!(result.len(), 1);
377 assert_eq!(result[0].line, 5);
378 }
379
380 #[test]
381 fn test_empty_lines_with_tabs() {
382 let rule = MD010NoHardTabs::default();
383 let content = "Normal line\n\t\t\n\t\nAnother line";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
385 let result = rule.check(&ctx).unwrap();
386 assert_eq!(result.len(), 2);
387 assert_eq!(result[0].message, "Empty line contains 2 tabs");
388 assert_eq!(result[1].message, "Empty line contains tab");
389 }
390
391 #[test]
392 fn test_mixed_tabs_and_spaces() {
393 let rule = MD010NoHardTabs::default();
394 let content = " \tMixed indentation\n\t Mixed again";
395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
396 let result = rule.check(&ctx).unwrap();
397 assert_eq!(result.len(), 2);
398 }
399
400 #[test]
401 fn test_consecutive_tabs() {
402 let rule = MD010NoHardTabs::default();
403 let content = "Text\t\t\tthree tabs\tand\tanother";
404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
405 let result = rule.check(&ctx).unwrap();
406 assert_eq!(result.len(), 3);
408 assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
409 }
410
411 #[test]
412 fn test_tab_positions() {
413 let tabs = MD010NoHardTabs::find_tab_positions("a\tb\tc");
414 assert_eq!(tabs, vec![1, 3]);
415
416 let tabs = MD010NoHardTabs::find_tab_positions("\t\tabc");
417 assert_eq!(tabs, vec![0, 1]);
418
419 let tabs = MD010NoHardTabs::find_tab_positions("no tabs");
420 assert!(tabs.is_empty());
421 }
422
423 #[test]
424 fn test_group_consecutive_tabs() {
425 let groups = MD010NoHardTabs::group_consecutive_tabs(&[0, 1, 2, 5, 6]);
426 assert_eq!(groups, vec![(0, 3), (5, 7)]);
427
428 let groups = MD010NoHardTabs::group_consecutive_tabs(&[1, 3, 5]);
429 assert_eq!(groups, vec![(1, 2), (3, 4), (5, 6)]);
430
431 let groups = MD010NoHardTabs::group_consecutive_tabs(&[]);
432 assert!(groups.is_empty());
433 }
434
435 #[test]
436 fn test_count_leading_tabs() {
437 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
438 assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
439 assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
440 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
441 }
442
443 #[test]
444 fn test_default_config() {
445 let rule = MD010NoHardTabs::default();
446 let config = rule.default_config_section();
447 assert!(config.is_some());
448 let (name, _value) = config.unwrap();
449 assert_eq!(name, "MD010");
450 }
451
452 #[test]
453 fn test_from_config() {
454 let custom_spaces = 8;
456 let rule = MD010NoHardTabs::new(custom_spaces);
457 let content = "\tTab";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459 let fixed = rule.fix(&ctx).unwrap();
460 assert_eq!(fixed, " Tab");
461
462 let content_with_code = "```\n\tTab in code\n```";
464 let ctx = LintContext::new(content_with_code, crate::config::MarkdownFlavor::Standard);
465 let result = rule.check(&ctx).unwrap();
466 assert!(result.is_empty());
468 }
469
470 #[test]
471 fn test_performance_large_document() {
472 let rule = MD010NoHardTabs::default();
473 let mut content = String::new();
474 for i in 0..1000 {
475 content.push_str(&format!("Line {i}\twith\ttabs\n"));
476 }
477 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
478 let result = rule.check(&ctx).unwrap();
479 assert_eq!(result.len(), 2000);
480 }
481
482 #[test]
483 fn test_preserve_content() {
484 let rule = MD010NoHardTabs::default();
485 let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
487 let fixed = rule.fix(&ctx).unwrap();
488 assert_eq!(fixed, "**Bold** text\n*Italic* text\n[Link](url) tab");
489 }
490
491 #[test]
492 fn test_edge_cases() {
493 let rule = MD010NoHardTabs::default();
494
495 let content = "Text\t";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
498 let result = rule.check(&ctx).unwrap();
499 assert_eq!(result.len(), 1);
500
501 let content = "\t\t\t";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504 let result = rule.check(&ctx).unwrap();
505 assert_eq!(result.len(), 1);
506 assert_eq!(result[0].message, "Empty line contains 3 tabs");
507 }
508
509 #[test]
510 fn test_code_blocks_always_preserved_in_fix() {
511 let rule = MD010NoHardTabs::default();
512
513 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515 let fixed = rule.fix(&ctx).unwrap();
516
517 let expected = "Text with tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore tabs";
519 assert_eq!(fixed, expected);
520 }
521
522 #[test]
523 fn test_find_html_comment_lines() {
524 let lines = vec!["Normal", "<!-- Start", "Middle", "End -->", "After"];
525 let result = MD010NoHardTabs::find_html_comment_lines(&lines);
526 assert_eq!(result, vec![false, true, true, true, false]);
527
528 let lines = vec!["<!-- Single line comment -->", "Normal"];
529 let result = MD010NoHardTabs::find_html_comment_lines(&lines);
530 assert_eq!(result, vec![true, false]);
531 }
532}