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_mdx_comment
152 || info.in_html_block
153 || info.in_pymdown_block
154 || info.in_mkdocstrings
155 || info.in_esm_block
156 }) {
157 continue;
158 }
159
160 let tab_groups = Self::find_and_group_tabs(line);
162 if tab_groups.is_empty() {
163 continue;
164 }
165
166 let leading_tabs = Self::count_leading_tabs(line);
167
168 for (start_pos, end_pos) in tab_groups {
170 let tab_count = end_pos - start_pos;
171 let is_leading = start_pos < leading_tabs;
172
173 let (start_line, start_col, end_line, end_col) =
175 calculate_match_range(line_num + 1, line, start_pos, tab_count);
176
177 let message = if line.trim().is_empty() {
178 if tab_count == 1 {
179 "Empty line contains tab".to_string()
180 } else {
181 format!("Empty line contains {tab_count} tabs")
182 }
183 } else if is_leading {
184 if tab_count == 1 {
185 format!(
186 "Found leading tab, use {} spaces instead",
187 self.config.spaces_per_tab.get()
188 )
189 } else {
190 format!(
191 "Found {} leading tabs, use {} spaces instead",
192 tab_count,
193 tab_count * self.config.spaces_per_tab.get()
194 )
195 }
196 } else if tab_count == 1 {
197 "Found tab for alignment, use spaces instead".to_string()
198 } else {
199 format!("Found {tab_count} tabs for alignment, use spaces instead")
200 };
201
202 warnings.push(LintWarning {
203 rule_name: Some(self.name().to_string()),
204 line: start_line,
205 column: start_col,
206 end_line,
207 end_column: end_col,
208 message,
209 severity: Severity::Warning,
210 fix: Some(Fix {
211 range: line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
212 replacement: " ".repeat(tab_count * self.config.spaces_per_tab.get()),
213 }),
214 });
215 }
216 }
217
218 Ok(warnings)
219 }
220
221 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
222 if self.should_skip(ctx) {
223 return Ok(ctx.content.to_string());
224 }
225 let warnings = self.check(ctx)?;
226 if warnings.is_empty() {
227 return Ok(ctx.content.to_string());
228 }
229 let warnings =
230 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
231 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
232 .map_err(crate::rule::LintError::InvalidInput)
233 }
234
235 fn as_any(&self) -> &dyn std::any::Any {
236 self
237 }
238
239 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
240 ctx.content.is_empty() || !ctx.has_char('\t')
242 }
243
244 fn category(&self) -> RuleCategory {
245 RuleCategory::Whitespace
246 }
247
248 fn default_config_section(&self) -> Option<(String, toml::Value)> {
249 let default_config = MD010Config::default();
250 let json_value = serde_json::to_value(&default_config).ok()?;
251 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
252
253 if let toml::Value::Table(table) = toml_value {
254 if !table.is_empty() {
255 Some((MD010Config::RULE_NAME.to_string(), toml::Value::Table(table)))
256 } else {
257 None
258 }
259 } else {
260 None
261 }
262 }
263
264 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
265 where
266 Self: Sized,
267 {
268 let rule_config = crate::rule_config_serde::load_rule_config::<MD010Config>(config);
269 Box::new(Self::from_config_struct(rule_config))
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::lint_context::LintContext;
277 use crate::rule::Rule;
278
279 #[test]
280 fn test_no_tabs() {
281 let rule = MD010NoHardTabs::default();
282 let content = "This is a line\nAnother line\nNo tabs here";
283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
284 let result = rule.check(&ctx).unwrap();
285 assert!(result.is_empty());
286 }
287
288 #[test]
289 fn test_single_tab() {
290 let rule = MD010NoHardTabs::default();
291 let content = "Line with\ttab";
292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
293 let result = rule.check(&ctx).unwrap();
294 assert_eq!(result.len(), 1);
295 assert_eq!(result[0].line, 1);
296 assert_eq!(result[0].column, 10);
297 assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
298 }
299
300 #[test]
301 fn test_leading_tabs() {
302 let rule = MD010NoHardTabs::default();
303 let content = "\tIndented line\n\t\tDouble indented";
304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305 let result = rule.check(&ctx).unwrap();
306 assert_eq!(result.len(), 2);
307 assert_eq!(result[0].line, 1);
308 assert_eq!(result[0].message, "Found leading tab, use 4 spaces instead");
309 assert_eq!(result[1].line, 2);
310 assert_eq!(result[1].message, "Found 2 leading tabs, use 8 spaces instead");
311 }
312
313 #[test]
314 fn test_fix_tabs() {
315 let rule = MD010NoHardTabs::default();
316 let content = "\tIndented\nNormal\tline\nNo tabs";
317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
318 let fixed = rule.fix(&ctx).unwrap();
319 assert_eq!(fixed, " Indented\nNormal line\nNo tabs");
320 }
321
322 #[test]
323 fn test_custom_spaces_per_tab() {
324 let rule = MD010NoHardTabs::new(4);
325 let content = "\tIndented";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let fixed = rule.fix(&ctx).unwrap();
328 assert_eq!(fixed, " Indented");
329 }
330
331 #[test]
332 fn test_code_blocks_always_ignored() {
333 let rule = MD010NoHardTabs::default();
334 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336 let result = rule.check(&ctx).unwrap();
337 assert_eq!(result.len(), 2);
339 assert_eq!(result[0].line, 1);
340 assert_eq!(result[1].line, 5);
341
342 let fixed = rule.fix(&ctx).unwrap();
343 assert_eq!(fixed, "Normal line\n```\nCode\twith\ttab\n```\nAnother line");
344 }
345
346 #[test]
347 fn test_code_blocks_never_checked() {
348 let rule = MD010NoHardTabs::default();
349 let content = "```\nCode\twith\ttab\n```";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352 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, None);
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, None);
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, None);
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, None);
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, None);
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_find_and_group_tabs() {
413 let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
415 assert_eq!(groups, vec![(1, 2), (3, 4)]);
416
417 let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
418 assert_eq!(groups, vec![(0, 2)]);
419
420 let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
421 assert!(groups.is_empty());
422
423 let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
425 assert_eq!(groups, vec![(0, 3), (4, 6)]);
426
427 let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
428 assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
429 }
430
431 #[test]
432 fn test_count_leading_tabs() {
433 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
434 assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
435 assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
436 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
437 }
438
439 #[test]
440 fn test_default_config() {
441 let rule = MD010NoHardTabs::default();
442 let config = rule.default_config_section();
443 assert!(config.is_some());
444 let (name, _value) = config.unwrap();
445 assert_eq!(name, "MD010");
446 }
447
448 #[test]
449 fn test_from_config() {
450 let custom_spaces = 8;
452 let rule = MD010NoHardTabs::new(custom_spaces);
453 let content = "\tTab";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let fixed = rule.fix(&ctx).unwrap();
456 assert_eq!(fixed, " Tab");
457
458 let content_with_code = "```\n\tTab in code\n```";
460 let ctx = LintContext::new(content_with_code, crate::config::MarkdownFlavor::Standard, None);
461 let result = rule.check(&ctx).unwrap();
462 assert!(result.is_empty());
464 }
465
466 #[test]
467 fn test_performance_large_document() {
468 let rule = MD010NoHardTabs::default();
469 let mut content = String::new();
470 for i in 0..1000 {
471 content.push_str(&format!("Line {i}\twith\ttabs\n"));
472 }
473 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475 assert_eq!(result.len(), 2000);
476 }
477
478 #[test]
479 fn test_preserve_content() {
480 let rule = MD010NoHardTabs::default();
481 let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483 let fixed = rule.fix(&ctx).unwrap();
484 assert_eq!(fixed, "**Bold** text\n*Italic* text\n[Link](url) tab");
485 }
486
487 #[test]
488 fn test_edge_cases() {
489 let rule = MD010NoHardTabs::default();
490
491 let content = "Text\t";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert_eq!(result.len(), 1);
496
497 let content = "\t\t\t";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert_eq!(result.len(), 1);
502 assert_eq!(result[0].message, "Empty line contains 3 tabs");
503 }
504
505 #[test]
506 fn test_code_blocks_always_preserved_in_fix() {
507 let rule = MD010NoHardTabs::default();
508
509 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511 let fixed = rule.fix(&ctx).unwrap();
512
513 let expected = "Text with tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore tabs";
516 assert_eq!(fixed, expected);
517 }
518
519 #[test]
520 fn test_tilde_fence_longer_than_3() {
521 let rule = MD010NoHardTabs::default();
522 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525 let result = rule.check(&ctx).unwrap();
526 assert_eq!(
528 result.len(),
529 2,
530 "Expected 2 warnings but got {}: {:?}",
531 result.len(),
532 result
533 );
534 assert_eq!(result[0].line, 4);
535 assert_eq!(result[1].line, 4);
536 }
537
538 #[test]
539 fn test_backtick_fence_longer_than_3() {
540 let rule = MD010NoHardTabs::default();
541 let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545 assert_eq!(
546 result.len(),
547 2,
548 "Expected 2 warnings but got {}: {:?}",
549 result.len(),
550 result
551 );
552 assert_eq!(result[0].line, 4);
553 assert_eq!(result[1].line, 4);
554 }
555
556 #[test]
557 fn test_indented_code_block_tabs_flagged() {
558 let rule = MD010NoHardTabs::default();
559 let content = " code\twith\ttab\n\nNormal\ttext";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564 assert_eq!(
565 result.len(),
566 3,
567 "Expected 3 warnings but got {}: {:?}",
568 result.len(),
569 result
570 );
571 assert_eq!(result[0].line, 1);
572 assert_eq!(result[1].line, 1);
573 assert_eq!(result[2].line, 3);
574 }
575
576 #[test]
577 fn test_html_comment_end_then_start_same_line() {
578 let rule = MD010NoHardTabs::default();
579 let content =
581 "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert!(
585 result.is_empty(),
586 "Expected 0 warnings but got {}: {:?}",
587 result.len(),
588 result
589 );
590 }
591
592 #[test]
593 fn test_fix_tilde_fence_longer_than_3() {
594 let rule = MD010NoHardTabs::default();
595 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let fixed = rule.fix(&ctx).unwrap();
598 assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext with tab");
600 }
601
602 #[test]
603 fn test_fix_indented_code_block_tabs_replaced() {
604 let rule = MD010NoHardTabs::default();
605 let content = " code\twith\ttab\n\nNormal\ttext";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let fixed = rule.fix(&ctx).unwrap();
608 assert_eq!(fixed, " code with tab\n\nNormal text");
610 }
611}