1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_match_range;
7
8mod md010_config;
9use md010_config::MD010Config;
10
11#[derive(Clone, Default)]
13pub struct MD010NoHardTabs {
14 config: MD010Config,
15}
16
17impl MD010NoHardTabs {
18 pub fn new(spaces_per_tab: usize) -> Self {
19 Self {
20 config: MD010Config {
21 spaces_per_tab: crate::types::PositiveUsize::from_const(spaces_per_tab),
22 },
23 }
24 }
25
26 pub const fn from_config_struct(config: MD010Config) -> Self {
27 Self { config }
28 }
29
30 fn find_fenced_code_block_lines(lines: &[&str]) -> Vec<bool> {
34 let mut in_fenced_block = false;
35 let mut fence_char: Option<char> = None;
36 let mut fence_len: usize = 0;
37 let mut result = vec![false; lines.len()];
38
39 for (i, line) in lines.iter().enumerate() {
40 let trimmed = line.trim_start();
41
42 if !in_fenced_block {
43 let first_char = trimmed.chars().next();
45 if matches!(first_char, Some('`') | Some('~')) {
46 let fc = first_char.unwrap();
47 let count = trimmed.chars().take_while(|&c| c == fc).count();
48 if count >= 3 {
49 in_fenced_block = true;
50 fence_char = Some(fc);
51 fence_len = count;
52 result[i] = true;
53 }
54 }
55 } else {
56 result[i] = true;
57 if let Some(fc) = fence_char {
59 let first = trimmed.chars().next();
60 if first == Some(fc) {
61 let count = trimmed.chars().take_while(|&c| c == fc).count();
62 if count >= fence_len && trimmed[count..].trim().is_empty() {
64 in_fenced_block = false;
65 fence_char = None;
66 fence_len = 0;
67 }
68 }
69 }
70 }
71 }
72
73 result
74 }
75
76 fn count_leading_tabs(line: &str) -> usize {
77 let mut count = 0;
78 for c in line.chars() {
79 if c == '\t' {
80 count += 1;
81 } else {
82 break;
83 }
84 }
85 count
86 }
87
88 fn find_and_group_tabs(line: &str) -> Vec<(usize, usize)> {
89 let mut groups = Vec::new();
90 let mut current_group_start: Option<usize> = None;
91 let mut last_tab_pos = 0;
92
93 for (i, c) in line.chars().enumerate() {
94 if c == '\t' {
95 if let Some(start) = current_group_start {
96 if i == last_tab_pos + 1 {
98 last_tab_pos = i;
100 } else {
101 groups.push((start, last_tab_pos + 1));
103 current_group_start = Some(i);
104 last_tab_pos = i;
105 }
106 } else {
107 current_group_start = Some(i);
109 last_tab_pos = i;
110 }
111 }
112 }
113
114 if let Some(start) = current_group_start {
116 groups.push((start, last_tab_pos + 1));
117 }
118
119 groups
120 }
121}
122
123impl Rule for MD010NoHardTabs {
124 fn name(&self) -> &'static str {
125 "MD010"
126 }
127
128 fn description(&self) -> &'static str {
129 "No tabs"
130 }
131
132 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
133 let _line_index = &ctx.line_index;
134
135 let mut warnings = Vec::new();
136 let lines = ctx.raw_lines();
137
138 let fenced_lines = Self::find_fenced_code_block_lines(lines);
141
142 for (line_num, &line) in lines.iter().enumerate() {
143 if fenced_lines[line_num] {
145 continue;
146 }
147
148 if ctx.line_info(line_num + 1).is_some_and(|info| {
150 info.in_html_comment
151 || info.in_html_block
152 || info.in_pymdown_block
153 || info.in_mkdocstrings
154 || info.in_esm_block
155 }) {
156 continue;
157 }
158
159 let tab_groups = Self::find_and_group_tabs(line);
161 if tab_groups.is_empty() {
162 continue;
163 }
164
165 let leading_tabs = Self::count_leading_tabs(line);
166
167 for (start_pos, end_pos) in tab_groups {
169 let tab_count = end_pos - start_pos;
170 let is_leading = start_pos < leading_tabs;
171
172 let (start_line, start_col, end_line, end_col) =
174 calculate_match_range(line_num + 1, line, start_pos, tab_count);
175
176 let message = if line.trim().is_empty() {
177 if tab_count == 1 {
178 "Empty line contains tab".to_string()
179 } else {
180 format!("Empty line contains {tab_count} tabs")
181 }
182 } else if is_leading {
183 if tab_count == 1 {
184 format!(
185 "Found leading tab, use {} spaces instead",
186 self.config.spaces_per_tab.get()
187 )
188 } else {
189 format!(
190 "Found {} leading tabs, use {} spaces instead",
191 tab_count,
192 tab_count * self.config.spaces_per_tab.get()
193 )
194 }
195 } else if tab_count == 1 {
196 "Found tab for alignment, use spaces instead".to_string()
197 } else {
198 format!("Found {tab_count} tabs for alignment, use spaces instead")
199 };
200
201 warnings.push(LintWarning {
202 rule_name: Some(self.name().to_string()),
203 line: start_line,
204 column: start_col,
205 end_line,
206 end_column: end_col,
207 message,
208 severity: Severity::Warning,
209 fix: Some(Fix {
210 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
211 replacement: " ".repeat(tab_count * self.config.spaces_per_tab.get()),
212 }),
213 });
214 }
215 }
216
217 Ok(warnings)
218 }
219
220 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
221 let content = ctx.content;
222
223 let mut result = String::new();
224 let lines = ctx.raw_lines();
225
226 let fenced_lines = Self::find_fenced_code_block_lines(lines);
228
229 for (i, line) in lines.iter().enumerate() {
230 let should_skip = fenced_lines[i]
232 || ctx.line_info(i + 1).is_some_and(|info| {
233 info.in_html_comment
234 || info.in_html_block
235 || info.in_pymdown_block
236 || info.in_mkdocstrings
237 || info.in_esm_block
238 });
239
240 if should_skip {
241 result.push_str(line);
242 } else {
243 result.push_str(&line.replace('\t', &" ".repeat(self.config.spaces_per_tab.get())));
245 }
246
247 if i < lines.len() - 1 || content.ends_with('\n') {
249 result.push('\n');
250 }
251 }
252
253 Ok(result)
254 }
255
256 fn as_any(&self) -> &dyn std::any::Any {
257 self
258 }
259
260 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
261 ctx.content.is_empty() || !ctx.has_char('\t')
263 }
264
265 fn category(&self) -> RuleCategory {
266 RuleCategory::Whitespace
267 }
268
269 fn default_config_section(&self) -> Option<(String, toml::Value)> {
270 let default_config = MD010Config::default();
271 let json_value = serde_json::to_value(&default_config).ok()?;
272 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
273
274 if let toml::Value::Table(table) = toml_value {
275 if !table.is_empty() {
276 Some((MD010Config::RULE_NAME.to_string(), toml::Value::Table(table)))
277 } else {
278 None
279 }
280 } else {
281 None
282 }
283 }
284
285 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
286 where
287 Self: Sized,
288 {
289 let rule_config = crate::rule_config_serde::load_rule_config::<MD010Config>(config);
290 Box::new(Self::from_config_struct(rule_config))
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::lint_context::LintContext;
298 use crate::rule::Rule;
299
300 #[test]
301 fn test_no_tabs() {
302 let rule = MD010NoHardTabs::default();
303 let content = "This is a line\nAnother line\nNo tabs here";
304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305 let result = rule.check(&ctx).unwrap();
306 assert!(result.is_empty());
307 }
308
309 #[test]
310 fn test_single_tab() {
311 let rule = MD010NoHardTabs::default();
312 let content = "Line with\ttab";
313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
314 let result = rule.check(&ctx).unwrap();
315 assert_eq!(result.len(), 1);
316 assert_eq!(result[0].line, 1);
317 assert_eq!(result[0].column, 10);
318 assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
319 }
320
321 #[test]
322 fn test_leading_tabs() {
323 let rule = MD010NoHardTabs::default();
324 let content = "\tIndented line\n\t\tDouble indented";
325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
326 let result = rule.check(&ctx).unwrap();
327 assert_eq!(result.len(), 2);
328 assert_eq!(result[0].line, 1);
329 assert_eq!(result[0].message, "Found leading tab, use 4 spaces instead");
330 assert_eq!(result[1].line, 2);
331 assert_eq!(result[1].message, "Found 2 leading tabs, use 8 spaces instead");
332 }
333
334 #[test]
335 fn test_fix_tabs() {
336 let rule = MD010NoHardTabs::default();
337 let content = "\tIndented\nNormal\tline\nNo tabs";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339 let fixed = rule.fix(&ctx).unwrap();
340 assert_eq!(fixed, " Indented\nNormal line\nNo tabs");
341 }
342
343 #[test]
344 fn test_custom_spaces_per_tab() {
345 let rule = MD010NoHardTabs::new(4);
346 let content = "\tIndented";
347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
348 let fixed = rule.fix(&ctx).unwrap();
349 assert_eq!(fixed, " Indented");
350 }
351
352 #[test]
353 fn test_code_blocks_always_ignored() {
354 let rule = MD010NoHardTabs::default();
355 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357 let result = rule.check(&ctx).unwrap();
358 assert_eq!(result.len(), 2);
360 assert_eq!(result[0].line, 1);
361 assert_eq!(result[1].line, 5);
362
363 let fixed = rule.fix(&ctx).unwrap();
364 assert_eq!(fixed, "Normal line\n```\nCode\twith\ttab\n```\nAnother line");
365 }
366
367 #[test]
368 fn test_code_blocks_never_checked() {
369 let rule = MD010NoHardTabs::default();
370 let content = "```\nCode\twith\ttab\n```";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372 let result = rule.check(&ctx).unwrap();
373 assert_eq!(result.len(), 0);
376 }
377
378 #[test]
379 fn test_html_comments_ignored() {
380 let rule = MD010NoHardTabs::default();
381 let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384 assert_eq!(result.len(), 2);
386 assert_eq!(result[0].line, 1);
387 assert_eq!(result[1].line, 3);
388 }
389
390 #[test]
391 fn test_multiline_html_comments() {
392 let rule = MD010NoHardTabs::default();
393 let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
395 let result = rule.check(&ctx).unwrap();
396 assert_eq!(result.len(), 1);
398 assert_eq!(result[0].line, 5);
399 }
400
401 #[test]
402 fn test_empty_lines_with_tabs() {
403 let rule = MD010NoHardTabs::default();
404 let content = "Normal line\n\t\t\n\t\nAnother line";
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406 let result = rule.check(&ctx).unwrap();
407 assert_eq!(result.len(), 2);
408 assert_eq!(result[0].message, "Empty line contains 2 tabs");
409 assert_eq!(result[1].message, "Empty line contains tab");
410 }
411
412 #[test]
413 fn test_mixed_tabs_and_spaces() {
414 let rule = MD010NoHardTabs::default();
415 let content = " \tMixed indentation\n\t Mixed again";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418 assert_eq!(result.len(), 2);
419 }
420
421 #[test]
422 fn test_consecutive_tabs() {
423 let rule = MD010NoHardTabs::default();
424 let content = "Text\t\t\tthree tabs\tand\tanother";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert_eq!(result.len(), 3);
429 assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
430 }
431
432 #[test]
433 fn test_find_and_group_tabs() {
434 let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
436 assert_eq!(groups, vec![(1, 2), (3, 4)]);
437
438 let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
439 assert_eq!(groups, vec![(0, 2)]);
440
441 let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
442 assert!(groups.is_empty());
443
444 let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
446 assert_eq!(groups, vec![(0, 3), (4, 6)]);
447
448 let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
449 assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
450 }
451
452 #[test]
453 fn test_count_leading_tabs() {
454 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
455 assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
456 assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
457 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
458 }
459
460 #[test]
461 fn test_default_config() {
462 let rule = MD010NoHardTabs::default();
463 let config = rule.default_config_section();
464 assert!(config.is_some());
465 let (name, _value) = config.unwrap();
466 assert_eq!(name, "MD010");
467 }
468
469 #[test]
470 fn test_from_config() {
471 let custom_spaces = 8;
473 let rule = MD010NoHardTabs::new(custom_spaces);
474 let content = "\tTab";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let fixed = rule.fix(&ctx).unwrap();
477 assert_eq!(fixed, " Tab");
478
479 let content_with_code = "```\n\tTab in code\n```";
481 let ctx = LintContext::new(content_with_code, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483 assert!(result.is_empty());
485 }
486
487 #[test]
488 fn test_performance_large_document() {
489 let rule = MD010NoHardTabs::default();
490 let mut content = String::new();
491 for i in 0..1000 {
492 content.push_str(&format!("Line {i}\twith\ttabs\n"));
493 }
494 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496 assert_eq!(result.len(), 2000);
497 }
498
499 #[test]
500 fn test_preserve_content() {
501 let rule = MD010NoHardTabs::default();
502 let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504 let fixed = rule.fix(&ctx).unwrap();
505 assert_eq!(fixed, "**Bold** text\n*Italic* text\n[Link](url) tab");
506 }
507
508 #[test]
509 fn test_edge_cases() {
510 let rule = MD010NoHardTabs::default();
511
512 let content = "Text\t";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx).unwrap();
516 assert_eq!(result.len(), 1);
517
518 let content = "\t\t\t";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1);
523 assert_eq!(result[0].message, "Empty line contains 3 tabs");
524 }
525
526 #[test]
527 fn test_code_blocks_always_preserved_in_fix() {
528 let rule = MD010NoHardTabs::default();
529
530 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let fixed = rule.fix(&ctx).unwrap();
533
534 let expected = "Text with tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore tabs";
537 assert_eq!(fixed, expected);
538 }
539
540 #[test]
541 fn test_tilde_fence_longer_than_3() {
542 let rule = MD010NoHardTabs::default();
543 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547 assert_eq!(
549 result.len(),
550 2,
551 "Expected 2 warnings but got {}: {:?}",
552 result.len(),
553 result
554 );
555 assert_eq!(result[0].line, 4);
556 assert_eq!(result[1].line, 4);
557 }
558
559 #[test]
560 fn test_backtick_fence_longer_than_3() {
561 let rule = MD010NoHardTabs::default();
562 let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert_eq!(
567 result.len(),
568 2,
569 "Expected 2 warnings but got {}: {:?}",
570 result.len(),
571 result
572 );
573 assert_eq!(result[0].line, 4);
574 assert_eq!(result[1].line, 4);
575 }
576
577 #[test]
578 fn test_indented_code_block_tabs_flagged() {
579 let rule = MD010NoHardTabs::default();
580 let content = " code\twith\ttab\n\nNormal\ttext";
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585 assert_eq!(
586 result.len(),
587 3,
588 "Expected 3 warnings but got {}: {:?}",
589 result.len(),
590 result
591 );
592 assert_eq!(result[0].line, 1);
593 assert_eq!(result[1].line, 1);
594 assert_eq!(result[2].line, 3);
595 }
596
597 #[test]
598 fn test_html_comment_end_then_start_same_line() {
599 let rule = MD010NoHardTabs::default();
600 let content =
602 "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert!(
606 result.is_empty(),
607 "Expected 0 warnings but got {}: {:?}",
608 result.len(),
609 result
610 );
611 }
612
613 #[test]
614 fn test_fix_tilde_fence_longer_than_3() {
615 let rule = MD010NoHardTabs::default();
616 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let fixed = rule.fix(&ctx).unwrap();
619 assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext with tab");
621 }
622
623 #[test]
624 fn test_fix_indented_code_block_tabs_replaced() {
625 let rule = MD010NoHardTabs::default();
626 let content = " code\twith\ttab\n\nNormal\ttext";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let fixed = rule.fix(&ctx).unwrap();
629 assert_eq!(fixed, " code with tab\n\nNormal text");
631 }
632}