1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::range_utils::calculate_match_range;
7
8pub mod md010_config;
9pub use 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 code_blocks: false,
23 ..Default::default()
24 },
25 }
26 }
27
28 pub const fn from_config_struct(config: MD010Config) -> Self {
29 Self { config }
30 }
31
32 fn count_leading_tabs(line: &str) -> usize {
33 let mut count = 0;
34 for c in line.chars() {
35 if c == '\t' {
36 count += 1;
37 } else {
38 break;
39 }
40 }
41 count
42 }
43
44 fn fence_language(info_string: &str, flavor: crate::config::MarkdownFlavor) -> std::borrow::Cow<'_, str> {
53 let info = info_string.trim();
54
55 if flavor.is_pandoc_compatible()
56 && let Some(class) = crate::utils::pandoc::pandoc_code_class_lang(info)
57 {
58 return std::borrow::Cow::Borrowed(class);
59 }
60
61 if flavor == crate::config::MarkdownFlavor::Quarto
62 && crate::utils::quarto_chunks::is_executable_chunk(info)
63 && let Some(header) = crate::utils::quarto_chunks::parse_inline_chunk_header(info)
64 {
65 return std::borrow::Cow::Owned(header.engine);
66 }
67
68 std::borrow::Cow::Borrowed(info.split_whitespace().next().unwrap_or(""))
69 }
70
71 fn ignored_language_lines(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
77 let mut ignored = std::collections::HashSet::new();
78
79 for detail in ctx.code_block_details.iter().chain(ctx.colon_fence_details()) {
80 let label = Self::fence_language(&detail.info_string, ctx.flavor);
81 if label.is_empty()
82 || !self
83 .config
84 .ignore_code_languages
85 .iter()
86 .any(|listed| listed.eq_ignore_ascii_case(label.as_ref()))
87 {
88 continue;
89 }
90
91 let start_line = ctx
92 .line_offsets
93 .partition_point(|&off| off <= detail.start)
94 .saturating_sub(1);
95 let end_byte = detail.end.saturating_sub(1);
96 let end_line = ctx
97 .line_offsets
98 .partition_point(|&off| off <= end_byte)
99 .saturating_sub(1);
100 for line in start_line..=end_line {
101 ignored.insert(line + 1);
102 }
103 }
104
105 ignored
106 }
107
108 fn find_and_group_tabs(line: &str) -> Vec<(usize, usize)> {
110 let mut groups = Vec::new();
111 let mut current_group_start: Option<usize> = None;
112 let mut last_tab_pos = 0;
113
114 for (i, byte) in line.bytes().enumerate() {
115 if byte == b'\t' {
116 if let Some(start) = current_group_start {
117 if i == last_tab_pos + 1 {
119 last_tab_pos = i;
121 } else {
122 groups.push((start, last_tab_pos + 1));
124 current_group_start = Some(i);
125 last_tab_pos = i;
126 }
127 } else {
128 current_group_start = Some(i);
130 last_tab_pos = i;
131 }
132 }
133 }
134
135 if let Some(start) = current_group_start {
137 groups.push((start, last_tab_pos + 1));
138 }
139
140 groups
141 }
142}
143
144impl Rule for MD010NoHardTabs {
145 fn name(&self) -> &'static str {
146 "MD010"
147 }
148
149 fn description(&self) -> &'static str {
150 "No tabs"
151 }
152
153 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
154 let mut warnings = Vec::new();
155
156 let mut filtered = ctx
157 .filtered_lines()
158 .skip_front_matter()
159 .skip_html_comments()
160 .skip_mdx_comments()
161 .skip_html_blocks()
162 .skip_pymdown_blocks()
163 .skip_mkdocstrings()
164 .skip_esm_blocks();
165
166 if !self.config.code_blocks {
167 filtered = filtered.skip_code_blocks();
168 }
169
170 let ignored_lines = if self.config.code_blocks && !self.config.ignore_code_languages.is_empty() {
173 self.ignored_language_lines(ctx)
174 } else {
175 std::collections::HashSet::new()
176 };
177
178 for filtered_line in filtered {
179 if ignored_lines.contains(&filtered_line.line_num) {
180 continue;
181 }
182 let line_num = filtered_line.line_num - 1;
183 let line = filtered_line.content;
184
185 let tab_groups = Self::find_and_group_tabs(line);
187 if tab_groups.is_empty() {
188 continue;
189 }
190
191 let leading_tabs = Self::count_leading_tabs(line);
192 let line_start = ctx.line_content_byte_range(line_num + 1).start;
193
194 for (start_pos, end_pos) in tab_groups {
196 let tab_count = end_pos - start_pos;
197 let is_leading = start_pos < leading_tabs;
198
199 let (start_line, start_col, end_line, end_col) =
201 calculate_match_range(line_num + 1, line, start_pos, tab_count);
202
203 let message = if line.trim().is_empty() {
204 if tab_count == 1 {
205 "Empty line contains tab".to_string()
206 } else {
207 format!("Empty line contains {tab_count} tabs")
208 }
209 } else if is_leading {
210 if tab_count == 1 {
211 format!(
212 "Found leading tab, use {} spaces instead",
213 self.config.spaces_per_tab.get()
214 )
215 } else {
216 format!(
217 "Found {} leading tabs, use {} spaces instead",
218 tab_count,
219 tab_count * self.config.spaces_per_tab.get()
220 )
221 }
222 } else if tab_count == 1 {
223 "Found tab for alignment, use spaces instead".to_string()
224 } else {
225 format!("Found {tab_count} tabs for alignment, use spaces instead")
226 };
227
228 warnings.push(LintWarning {
229 rule_name: Some(self.name().to_string()),
230 line: start_line,
231 column: start_col,
232 end_line,
233 end_column: end_col,
234 message,
235 severity: Severity::Warning,
236 fix: Some(Fix::new(
237 line_start + start_pos..line_start + end_pos,
238 " ".repeat(tab_count * self.config.spaces_per_tab.get()),
239 )),
240 });
241 }
242 }
243
244 Ok(warnings)
245 }
246
247 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
248 if self.should_skip(ctx) {
249 return Ok(ctx.content.to_string());
250 }
251 let warnings = self.check(ctx)?;
252 if warnings.is_empty() {
253 return Ok(ctx.content.to_string());
254 }
255 let warnings =
256 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
257 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
258 .map_err(crate::rule::LintError::InvalidInput)
259 }
260
261 fn as_any(&self) -> &dyn std::any::Any {
262 self
263 }
264
265 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
266 ctx.content.is_empty() || !ctx.has_char('\t')
268 }
269
270 fn category(&self) -> RuleCategory {
271 RuleCategory::Whitespace
272 }
273
274 crate::impl_rule_config_methods!(MD010Config);
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::lint_context::LintContext;
281 use crate::rule::Rule;
282
283 #[test]
284 fn test_no_tabs() {
285 let rule = MD010NoHardTabs::default();
286 let content = "This is a line\nAnother line\nNo tabs here";
287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
288 let result = rule.check(&ctx).unwrap();
289 assert!(result.is_empty());
290 }
291
292 #[test]
293 fn test_single_tab() {
294 let rule = MD010NoHardTabs::default();
295 let content = "Line with\ttab";
296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
297 let result = rule.check(&ctx).unwrap();
298 assert_eq!(result.len(), 1);
299 assert_eq!(result[0].line, 1);
300 assert_eq!(result[0].column, 10);
301 assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
302 }
303
304 #[test]
305 fn test_leading_tabs_skipped_in_indented_code_by_default() {
306 let content = "\tIndented line\n\t\tDouble indented";
309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310
311 let rule_off = MD010NoHardTabs::default();
312 let result_off = rule_off.check(&ctx).unwrap();
313 assert!(
314 result_off.is_empty(),
315 "indented code block skipped by default, got {result_off:?}"
316 );
317 assert_eq!(
318 rule_off.fix(&ctx).unwrap(),
319 "\tIndented line\n\t\tDouble indented",
320 "fix must preserve indented code block content"
321 );
322
323 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
325 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
326 code_blocks: true,
327 ..Default::default()
328 });
329 let result_on = rule_on.check(&ctx).unwrap();
330 assert_eq!(result_on.len(), 2, "got {result_on:?}");
331 assert_eq!(result_on[0].line, 1);
332 assert_eq!(result_on[0].message, "Found leading tab, use 4 spaces instead");
333 assert_eq!(result_on[1].line, 2);
334 assert_eq!(result_on[1].message, "Found 2 leading tabs, use 8 spaces instead");
335 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented line\n Double indented");
336 }
337
338 #[test]
339 fn test_fix_tabs() {
340 let content = "\tIndented\nNormal\tline\nNo tabs";
343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
344
345 let rule_off = MD010NoHardTabs::default();
346 let warnings_off = rule_off.check(&ctx).unwrap();
347 assert_eq!(warnings_off.len(), 1, "got {warnings_off:?}");
348 assert_eq!(warnings_off[0].line, 2);
349 assert_eq!(warnings_off[0].message, "Found tab for alignment, use spaces instead");
350 assert_eq!(
351 rule_off.fix(&ctx).unwrap(),
352 "\tIndented\nNormal line\nNo tabs",
353 "indented code block line preserved; alignment tab fixed"
354 );
355
356 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
358 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
359 code_blocks: true,
360 ..Default::default()
361 });
362 let warnings_on = rule_on.check(&ctx).unwrap();
363 assert_eq!(warnings_on.len(), 2, "got {warnings_on:?}");
364 assert_eq!(warnings_on[0].line, 1);
365 assert_eq!(warnings_on[1].line, 2);
366 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented\nNormal line\nNo tabs");
367 }
368
369 #[test]
370 fn test_custom_spaces_per_tab() {
371 let content = "\tIndented";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374
375 let rule_off = MD010NoHardTabs::new(4);
376 assert!(
377 rule_off.check(&ctx).unwrap().is_empty(),
378 "indented code block skipped by default"
379 );
380 assert_eq!(
381 rule_off.fix(&ctx).unwrap(),
382 "\tIndented",
383 "indented code block preserved by default"
384 );
385
386 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
388 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
389 code_blocks: true,
390 ..Default::default()
391 });
392 assert_eq!(rule_on.check(&ctx).unwrap().len(), 1);
393 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented");
394 }
395
396 #[test]
397 fn test_fenced_code_block_tabs_skipped_by_default() {
398 let rule = MD010NoHardTabs::default();
399 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401 let result = rule.check(&ctx).unwrap();
402 assert_eq!(result.len(), 2);
404 assert_eq!(result[0].line, 1);
405 assert_eq!(result[1].line, 5);
406
407 let fixed = rule.fix(&ctx).unwrap();
408 assert_eq!(fixed, "Normal line\n```\nCode\twith\ttab\n```\nAnother line");
409 }
410
411 #[test]
412 fn test_fenced_only_content_skipped_by_default() {
413 let rule = MD010NoHardTabs::default();
414 let content = "```\nCode\twith\ttab\n```";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert_eq!(result.len(), 0);
420 }
421
422 #[test]
423 fn test_html_comments_ignored() {
424 let rule = MD010NoHardTabs::default();
425 let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert_eq!(result.len(), 2);
430 assert_eq!(result[0].line, 1);
431 assert_eq!(result[1].line, 3);
432 }
433
434 #[test]
435 fn test_multiline_html_comments() {
436 let rule = MD010NoHardTabs::default();
437 let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 1);
442 assert_eq!(result[0].line, 5);
443 }
444
445 #[test]
446 fn test_empty_lines_with_tabs() {
447 let rule = MD010NoHardTabs::default();
448 let content = "Normal line\n\t\t\n\t\nAnother line";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert_eq!(result.len(), 2);
452 assert_eq!(result[0].message, "Empty line contains 2 tabs");
453 assert_eq!(result[1].message, "Empty line contains tab");
454 }
455
456 #[test]
457 fn test_mixed_tabs_and_spaces() {
458 let content = " \tMixed indentation\n\t Mixed again";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463
464 let rule_off = MD010NoHardTabs::default();
465 let result_off = rule_off.check(&ctx).unwrap();
466 assert!(
467 result_off.is_empty(),
468 "indented code block lines skipped, got {result_off:?}"
469 );
470 assert_eq!(
471 rule_off.fix(&ctx).unwrap(),
472 " \tMixed indentation\n\t Mixed again",
473 "content preserved unchanged"
474 );
475
476 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
478 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
479 code_blocks: true,
480 ..Default::default()
481 });
482 let result_on = rule_on.check(&ctx).unwrap();
483 assert_eq!(result_on.len(), 2, "got {result_on:?}");
484 assert_eq!(rule_on.fix(&ctx).unwrap(), " Mixed indentation\n Mixed again");
485 }
486
487 #[test]
488 fn test_consecutive_tabs() {
489 let rule = MD010NoHardTabs::default();
490 let content = "Text\t\t\tthree tabs\tand\tanother";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493 assert_eq!(result.len(), 3);
495 assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
496 }
497
498 #[test]
499 fn test_find_and_group_tabs() {
500 let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
502 assert_eq!(groups, vec![(1, 2), (3, 4)]);
503
504 let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
505 assert_eq!(groups, vec![(0, 2)]);
506
507 let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
508 assert!(groups.is_empty());
509
510 let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
512 assert_eq!(groups, vec![(0, 3), (4, 6)]);
513
514 let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
515 assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
516 }
517
518 #[test]
519 fn tab_after_multibyte_characters_reports_its_character_column() {
520 let rule = MD010NoHardTabs::default();
521 let content = "a\u{e9}\u{e9}b\tdef\n\u{1f600}\t\tx\n";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let warnings = rule.check(&ctx).unwrap();
524 let columns: Vec<_> = warnings.iter().map(|w| (w.line, w.column, w.end_column)).collect();
525 assert_eq!(columns, vec![(1, 5, 6), (2, 2, 4)]);
526
527 for warning in &warnings {
528 let range = warning.fix.as_ref().unwrap().range.clone();
529 assert!(content[range].chars().all(|c| c == '\t'), "fix must replace only tabs");
530 }
531 assert_eq!(rule.fix(&ctx).unwrap(), "a\u{e9}\u{e9}b def\n\u{1f600} x\n");
532 }
533
534 #[test]
535 fn test_count_leading_tabs() {
536 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
537 assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
538 assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
539 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
540 }
541
542 #[test]
543 fn test_default_config() {
544 let rule = MD010NoHardTabs::default();
545 let config = rule.default_config_section();
546 assert!(config.is_some());
547 let (name, _value) = config.unwrap();
548 assert_eq!(name, "MD010");
549 }
550
551 #[test]
552 fn test_from_config() {
553 let content_plain = "\tTab";
555 let ctx_plain = LintContext::new(content_plain, crate::config::MarkdownFlavor::Standard, None);
556 let rule_8_off = MD010NoHardTabs::new(8); assert!(
558 rule_8_off.check(&ctx_plain).unwrap().is_empty(),
559 "indented code block skipped"
560 );
561 assert_eq!(
562 rule_8_off.fix(&ctx_plain).unwrap(),
563 "\tTab",
564 "content preserved unchanged"
565 );
566
567 let rule_8_on = MD010NoHardTabs::from_config_struct(MD010Config {
569 spaces_per_tab: crate::types::PositiveUsize::from_const(8),
570 code_blocks: true,
571 ..Default::default()
572 });
573 assert_eq!(rule_8_on.check(&ctx_plain).unwrap().len(), 1);
574 assert_eq!(rule_8_on.fix(&ctx_plain).unwrap(), " Tab");
575
576 let content_fenced = "```\n\tTab in code\n```";
578 let ctx_fenced = LintContext::new(content_fenced, crate::config::MarkdownFlavor::Standard, None);
579 assert!(
580 rule_8_off.check(&ctx_fenced).unwrap().is_empty(),
581 "fenced code block skipped"
582 );
583 assert_eq!(rule_8_off.fix(&ctx_fenced).unwrap(), "```\n\tTab in code\n```");
584
585 let result_on = rule_8_on.check(&ctx_fenced).unwrap();
587 assert_eq!(result_on.len(), 1, "got {result_on:?}");
588 assert_eq!(result_on[0].line, 2);
589 assert_eq!(rule_8_on.fix(&ctx_fenced).unwrap(), "```\n Tab in code\n```");
590 }
591
592 #[test]
593 fn test_performance_large_document() {
594 let rule = MD010NoHardTabs::default();
595 let mut content = String::new();
596 for i in 0..1000 {
597 content.push_str(&format!("Line {i}\twith\ttabs\n"));
598 }
599 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert_eq!(result.len(), 2000);
602 }
603
604 #[test]
605 fn test_preserve_content() {
606 let rule = MD010NoHardTabs::default();
607 let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 let fixed = rule.fix(&ctx).unwrap();
610 assert_eq!(fixed, "**Bold** text\n*Italic* text\n[Link](url) tab");
611 }
612
613 #[test]
614 fn test_edge_cases() {
615 let rule = MD010NoHardTabs::default();
616
617 let content = "Text\t";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(result.len(), 1);
622
623 let content = "\t\t\t";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert_eq!(result.len(), 1);
628 assert_eq!(result[0].message, "Empty line contains 3 tabs");
629 }
630
631 #[test]
632 fn test_fenced_code_block_tabs_preserved_in_fix_by_default() {
633 let rule = MD010NoHardTabs::default();
634
635 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let fixed = rule.fix(&ctx).unwrap();
638
639 let expected = "Text with tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore tabs";
642 assert_eq!(fixed, expected);
643 }
644
645 #[test]
646 fn test_tilde_fence_longer_than_3() {
647 let rule = MD010NoHardTabs::default();
648 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let result = rule.check(&ctx).unwrap();
652 assert_eq!(
654 result.len(),
655 2,
656 "Expected 2 warnings but got {}: {:?}",
657 result.len(),
658 result
659 );
660 assert_eq!(result[0].line, 4);
661 assert_eq!(result[1].line, 4);
662 }
663
664 #[test]
665 fn test_backtick_fence_longer_than_3() {
666 let rule = MD010NoHardTabs::default();
667 let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert_eq!(
672 result.len(),
673 2,
674 "Expected 2 warnings but got {}: {:?}",
675 result.len(),
676 result
677 );
678 assert_eq!(result[0].line, 4);
679 assert_eq!(result[1].line, 4);
680 }
681
682 #[test]
683 fn test_indented_code_block_tabs_skipped_by_default() {
684 let content = " code\twith\ttab\n\nNormal\ttext";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688
689 let rule_off = MD010NoHardTabs::default();
690 let result_off = rule_off.check(&ctx).unwrap();
691 assert_eq!(
692 result_off.len(),
693 1,
694 "expected 1 warning (only normal-text tab), got {}: {:?}",
695 result_off.len(),
696 result_off
697 );
698 assert_eq!(result_off[0].line, 3);
699 assert_eq!(result_off[0].message, "Found tab for alignment, use spaces instead");
700
701 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
703 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
704 code_blocks: true,
705 ..Default::default()
706 });
707 let result_on = rule_on.check(&ctx).unwrap();
708 assert_eq!(
709 result_on.len(),
710 3,
711 "expected 3 warnings with code_blocks=true, got {}: {:?}",
712 result_on.len(),
713 result_on
714 );
715 assert_eq!(result_on[0].line, 1);
716 assert_eq!(result_on[1].line, 1);
717 assert_eq!(result_on[2].line, 3);
718 }
719
720 #[test]
721 fn test_html_comment_end_then_start_same_line() {
722 let rule = MD010NoHardTabs::default();
723 let content =
725 "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert!(
729 result.is_empty(),
730 "Expected 0 warnings but got {}: {:?}",
731 result.len(),
732 result
733 );
734 }
735
736 #[test]
737 fn test_fix_tilde_fence_longer_than_3() {
738 let rule = MD010NoHardTabs::default();
739 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let fixed = rule.fix(&ctx).unwrap();
742 assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext with tab");
744 }
745
746 #[test]
747 fn test_fix_indented_code_block_tabs_replaced() {
748 let content = " code\twith\ttab\n\nNormal\ttext";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751
752 let rule_off = MD010NoHardTabs::default();
753 assert_eq!(
754 rule_off.fix(&ctx).unwrap(),
755 " code\twith\ttab\n\nNormal text",
756 "indented code block preserved; only normal-text tab fixed"
757 );
758
759 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
761 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
762 code_blocks: true,
763 ..Default::default()
764 });
765 assert_eq!(
766 rule_on.fix(&ctx).unwrap(),
767 " code with tab\n\nNormal text",
768 "all tabs replaced with code_blocks=true"
769 );
770 }
771
772 #[test]
773 fn test_issue_630_default_skips_both_code_blocks() {
774 let rule = MD010NoHardTabs::default();
776 let content = "Foo bar\n\n for range 100 {\n \tfoo()\n }\n\nThis is a fenced\n\n```\nfor range 100 {\n\tfoo()\n}\n```\n";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let result = rule.check(&ctx).unwrap();
779 assert!(result.is_empty(), "both code blocks skipped, got {result:?}");
780 }
781
782 #[test]
783 fn test_issue_630_code_blocks_true_flags_both() {
784 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
786 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
787 code_blocks: true,
788 ..Default::default()
789 });
790 let content = "Foo bar\n\n for range 100 {\n \tfoo()\n }\n\nThis is a fenced\n\n```\nfor range 100 {\n\tfoo()\n}\n```\n";
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let result = rule.check(&ctx).unwrap();
793 assert_eq!(result.len(), 2, "got {result:?}");
796 assert_eq!(result[0].line, 4);
797 assert_eq!(result[1].line, 11);
798 }
799
800 #[test]
801 fn test_code_blocks_toggle_fenced() {
802 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
803
804 let off = MD010NoHardTabs::default();
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807 let r_off = off.check(&ctx).unwrap();
808 assert_eq!(r_off.len(), 2, "got {r_off:?}");
809 assert_eq!(r_off[0].line, 1);
810 assert_eq!(r_off[1].line, 5);
811 assert_eq!(
812 off.fix(&ctx).unwrap(),
813 "Normal line\n```\nCode\twith\ttab\n```\nAnother line"
814 );
815
816 let on = MD010NoHardTabs::from_config_struct(MD010Config {
818 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
819 code_blocks: true,
820 ..Default::default()
821 });
822 let r_on = on.check(&ctx).unwrap();
823 assert_eq!(r_on.len(), 4, "got {r_on:?}");
824 assert_eq!(r_on[0].line, 1);
825 assert_eq!(r_on[1].line, 3);
826 assert_eq!(r_on[2].line, 3);
827 assert_eq!(r_on[3].line, 5);
828 assert_eq!(
829 on.fix(&ctx).unwrap(),
830 "Normal line\n```\nCode with tab\n```\nAnother line"
831 );
832 }
833
834 #[test]
835 fn test_code_blocks_toggle_makefile_fence_preserved_by_default() {
836 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\nMore\ttabs";
837 let off = MD010NoHardTabs::default();
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839 assert_eq!(
841 off.fix(&ctx).unwrap(),
842 "Text with tab\n```makefile\ntarget:\n\tcommand\n```\nMore tabs"
843 );
844 }
845
846 #[test]
847 fn test_ignore_code_languages_skips_a_listed_fence() {
848 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\n```sh\necho\thello\n```";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852
853 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
854 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
855 code_blocks: true,
856 ignore_code_languages: vec!["makefile".to_string()],
857 });
858
859 let result = rule.check(&ctx).unwrap();
860 let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
861 assert_eq!(
862 lines,
863 vec![1, 1, 7],
864 "the makefile recipe tab on line 4 must be skipped, got {result:?}"
865 );
866 assert_eq!(
867 rule.fix(&ctx).unwrap(),
868 "Text with tab\n```makefile\ntarget:\n\tcommand\n```\n```sh\necho hello\n```"
869 );
870 }
871
872 #[test]
873 fn test_ignore_code_languages_matches_the_label_case_insensitively() {
874 let content = "```Makefile\ntarget:\n\tcommand\n```";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877
878 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
879 code_blocks: true,
880 ignore_code_languages: vec!["makefile".to_string()],
881 ..Default::default()
882 });
883
884 assert!(
885 rule.check(&ctx).unwrap().is_empty(),
886 "an uppercase fence label must match a lowercase configured language"
887 );
888 }
889
890 #[test]
891 fn test_ignore_code_languages_matches_only_the_first_info_string_word() {
892 let labelled = "```makefile title=\"Makefile\"\ntarget:\n\tcommand\n```";
894 let ctx = LintContext::new(labelled, crate::config::MarkdownFlavor::Standard, None);
895
896 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
897 code_blocks: true,
898 ignore_code_languages: vec!["makefile".to_string()],
899 ..Default::default()
900 });
901
902 assert!(
903 rule.check(&ctx).unwrap().is_empty(),
904 "a fence carrying attributes after its language must still match"
905 );
906
907 let other = "```shell title=\"Makefile\"\ntarget:\n\tcommand\n```";
910 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::Standard, None);
911 assert_eq!(
912 rule.check(&other_ctx).unwrap().len(),
913 1,
914 "an unlisted language must still be reported"
915 );
916 }
917
918 #[test]
919 fn test_ignore_code_languages_cannot_match_an_indented_code_block() {
920 let content = "Text.\n\n target:\n \tcommand\n";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924
925 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
926 code_blocks: true,
927 ignore_code_languages: vec!["makefile".to_string()],
928 ..Default::default()
929 });
930
931 assert_eq!(
932 rule.check(&ctx).unwrap().len(),
933 1,
934 "an indented block carries no language, so the list cannot exempt it"
935 );
936 }
937
938 #[test]
939 fn test_ignore_code_languages_is_inert_without_code_blocks() {
940 let content = "```makefile\ntarget:\n\tcommand\n```\n```sh\necho\thello\n```";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944
945 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
946 ignore_code_languages: vec!["makefile".to_string()],
947 ..Default::default()
948 });
949
950 assert!(
951 rule.check(&ctx).unwrap().is_empty(),
952 "listing a language must not start checking blocks that code-blocks skips"
953 );
954 }
955
956 #[test]
957 fn test_ignore_code_languages_matches_a_pandoc_class_attribute() {
958 let content = "```{.makefile}\ntarget:\n\tcommand\n```";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
962
963 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
964 code_blocks: true,
965 ignore_code_languages: vec!["makefile".to_string()],
966 ..Default::default()
967 });
968
969 assert!(
970 rule.check(&ctx).unwrap().is_empty(),
971 "the first .class of a Pandoc attribute block is the fence language"
972 );
973
974 let other = "```{.shell}\ntarget:\n\tcommand\n```";
976 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::Pandoc, None);
977 assert_eq!(
978 rule.check(&other_ctx).unwrap().len(),
979 1,
980 "an unlisted class must still be reported"
981 );
982 }
983
984 #[test]
985 fn test_ignore_code_languages_class_attribute_needs_a_pandoc_compatible_flavor() {
986 let content = "```{.makefile}\ntarget:\n\tcommand\n```";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990
991 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
992 code_blocks: true,
993 ignore_code_languages: vec!["makefile".to_string()],
994 ..Default::default()
995 });
996
997 assert_eq!(
998 rule.check(&ctx).unwrap().len(),
999 1,
1000 "attribute syntax must only be read under a Pandoc-compatible flavor"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_ignore_code_languages_matches_a_quarto_exec_chunk() {
1006 let content = "```{r}\nx <- 1\n\tindented\n```";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1009
1010 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1011 code_blocks: true,
1012 ignore_code_languages: vec!["r".to_string()],
1013 ..Default::default()
1014 });
1015
1016 assert!(
1017 rule.check(&ctx).unwrap().is_empty(),
1018 "a `{{r}}` chunk declares language r"
1019 );
1020
1021 let python = "```{python}\nx = 1\n\tindented\n```";
1023 let python_ctx = LintContext::new(python, crate::config::MarkdownFlavor::Quarto, None);
1024 assert_eq!(
1025 rule.check(&python_ctx).unwrap().len(),
1026 1,
1027 "an unlisted engine must still be reported"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_ignore_code_languages_matches_an_azure_colon_fence() {
1033 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1035 code_blocks: true,
1036 ignore_code_languages: vec!["makefile".to_string()],
1037 ..Default::default()
1038 });
1039
1040 for content in [
1041 ":::makefile\ntarget:\n\tcommand\n:::",
1042 "::: makefile\ntarget:\n\tcommand\n:::",
1043 ] {
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::AzureDevOps, None);
1045 assert!(
1046 rule.check(&ctx).unwrap().is_empty(),
1047 "a colon fence's language must be honoured, got {:?} for {content:?}",
1048 rule.check(&ctx).unwrap()
1049 );
1050 }
1051
1052 let other = ":::mermaid\nflowchart LR\n\tA --> B\n:::";
1054 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::AzureDevOps, None);
1055 assert_eq!(
1056 rule.check(&other_ctx).unwrap().len(),
1057 1,
1058 "an unlisted colon fence language must still be reported"
1059 );
1060 }
1061
1062 #[test]
1063 fn test_tabs_in_front_matter_are_not_flagged() {
1064 let rule = MD010NoHardTabs::default();
1067 let content = "---\ntitle:\t\"Tabbed value\"\n---\n\n# Heading\n\nBody text.\n";
1068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069 let result = rule.check(&ctx).unwrap();
1070 assert!(
1071 result.is_empty(),
1072 "tabs inside front matter must not be flagged, got: {result:?}"
1073 );
1074 }
1075}