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)> {
109 let mut groups = Vec::new();
110 let mut current_group_start: Option<usize> = None;
111 let mut last_tab_pos = 0;
112
113 for (i, c) in line.chars().enumerate() {
114 if c == '\t' {
115 if let Some(start) = current_group_start {
116 if i == last_tab_pos + 1 {
118 last_tab_pos = i;
120 } else {
121 groups.push((start, last_tab_pos + 1));
123 current_group_start = Some(i);
124 last_tab_pos = i;
125 }
126 } else {
127 current_group_start = Some(i);
129 last_tab_pos = i;
130 }
131 }
132 }
133
134 if let Some(start) = current_group_start {
136 groups.push((start, last_tab_pos + 1));
137 }
138
139 groups
140 }
141}
142
143impl Rule for MD010NoHardTabs {
144 fn name(&self) -> &'static str {
145 "MD010"
146 }
147
148 fn description(&self) -> &'static str {
149 "No tabs"
150 }
151
152 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
153 let mut warnings = Vec::new();
154
155 let mut filtered = ctx
156 .filtered_lines()
157 .skip_front_matter()
158 .skip_html_comments()
159 .skip_mdx_comments()
160 .skip_html_blocks()
161 .skip_pymdown_blocks()
162 .skip_mkdocstrings()
163 .skip_esm_blocks();
164
165 if !self.config.code_blocks {
166 filtered = filtered.skip_code_blocks();
167 }
168
169 let ignored_lines = if self.config.code_blocks && !self.config.ignore_code_languages.is_empty() {
172 self.ignored_language_lines(ctx)
173 } else {
174 std::collections::HashSet::new()
175 };
176
177 for filtered_line in filtered {
178 if ignored_lines.contains(&filtered_line.line_num) {
179 continue;
180 }
181 let line_num = filtered_line.line_num - 1;
182 let line = filtered_line.content;
183
184 let tab_groups = Self::find_and_group_tabs(line);
186 if tab_groups.is_empty() {
187 continue;
188 }
189
190 let leading_tabs = Self::count_leading_tabs(line);
191
192 for (start_pos, end_pos) in tab_groups {
194 let tab_count = end_pos - start_pos;
195 let is_leading = start_pos < leading_tabs;
196
197 let (start_line, start_col, end_line, end_col) =
199 calculate_match_range(line_num + 1, line, start_pos, tab_count);
200
201 let message = if line.trim().is_empty() {
202 if tab_count == 1 {
203 "Empty line contains tab".to_string()
204 } else {
205 format!("Empty line contains {tab_count} tabs")
206 }
207 } else if is_leading {
208 if tab_count == 1 {
209 format!(
210 "Found leading tab, use {} spaces instead",
211 self.config.spaces_per_tab.get()
212 )
213 } else {
214 format!(
215 "Found {} leading tabs, use {} spaces instead",
216 tab_count,
217 tab_count * self.config.spaces_per_tab.get()
218 )
219 }
220 } else if tab_count == 1 {
221 "Found tab for alignment, use spaces instead".to_string()
222 } else {
223 format!("Found {tab_count} tabs for alignment, use spaces instead")
224 };
225
226 warnings.push(LintWarning {
227 rule_name: Some(self.name().to_string()),
228 line: start_line,
229 column: start_col,
230 end_line,
231 end_column: end_col,
232 message,
233 severity: Severity::Warning,
234 fix: Some(Fix::new(
235 ctx.line_column_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
236 " ".repeat(tab_count * self.config.spaces_per_tab.get()),
237 )),
238 });
239 }
240 }
241
242 Ok(warnings)
243 }
244
245 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
246 if self.should_skip(ctx) {
247 return Ok(ctx.content.to_string());
248 }
249 let warnings = self.check(ctx)?;
250 if warnings.is_empty() {
251 return Ok(ctx.content.to_string());
252 }
253 let warnings =
254 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
255 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
256 .map_err(crate::rule::LintError::InvalidInput)
257 }
258
259 fn as_any(&self) -> &dyn std::any::Any {
260 self
261 }
262
263 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
264 ctx.content.is_empty() || !ctx.has_char('\t')
266 }
267
268 fn category(&self) -> RuleCategory {
269 RuleCategory::Whitespace
270 }
271
272 crate::impl_rule_config_methods!(MD010Config);
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::lint_context::LintContext;
279 use crate::rule::Rule;
280
281 #[test]
282 fn test_no_tabs() {
283 let rule = MD010NoHardTabs::default();
284 let content = "This is a line\nAnother line\nNo tabs here";
285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
286 let result = rule.check(&ctx).unwrap();
287 assert!(result.is_empty());
288 }
289
290 #[test]
291 fn test_single_tab() {
292 let rule = MD010NoHardTabs::default();
293 let content = "Line with\ttab";
294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
295 let result = rule.check(&ctx).unwrap();
296 assert_eq!(result.len(), 1);
297 assert_eq!(result[0].line, 1);
298 assert_eq!(result[0].column, 10);
299 assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
300 }
301
302 #[test]
303 fn test_leading_tabs_skipped_in_indented_code_by_default() {
304 let content = "\tIndented line\n\t\tDouble indented";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308
309 let rule_off = MD010NoHardTabs::default();
310 let result_off = rule_off.check(&ctx).unwrap();
311 assert!(
312 result_off.is_empty(),
313 "indented code block skipped by default, got {result_off:?}"
314 );
315 assert_eq!(
316 rule_off.fix(&ctx).unwrap(),
317 "\tIndented line\n\t\tDouble indented",
318 "fix must preserve indented code block content"
319 );
320
321 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
323 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
324 code_blocks: true,
325 ..Default::default()
326 });
327 let result_on = rule_on.check(&ctx).unwrap();
328 assert_eq!(result_on.len(), 2, "got {result_on:?}");
329 assert_eq!(result_on[0].line, 1);
330 assert_eq!(result_on[0].message, "Found leading tab, use 4 spaces instead");
331 assert_eq!(result_on[1].line, 2);
332 assert_eq!(result_on[1].message, "Found 2 leading tabs, use 8 spaces instead");
333 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented line\n Double indented");
334 }
335
336 #[test]
337 fn test_fix_tabs() {
338 let content = "\tIndented\nNormal\tline\nNo tabs";
341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342
343 let rule_off = MD010NoHardTabs::default();
344 let warnings_off = rule_off.check(&ctx).unwrap();
345 assert_eq!(warnings_off.len(), 1, "got {warnings_off:?}");
346 assert_eq!(warnings_off[0].line, 2);
347 assert_eq!(warnings_off[0].message, "Found tab for alignment, use spaces instead");
348 assert_eq!(
349 rule_off.fix(&ctx).unwrap(),
350 "\tIndented\nNormal line\nNo tabs",
351 "indented code block line preserved; alignment tab fixed"
352 );
353
354 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
356 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
357 code_blocks: true,
358 ..Default::default()
359 });
360 let warnings_on = rule_on.check(&ctx).unwrap();
361 assert_eq!(warnings_on.len(), 2, "got {warnings_on:?}");
362 assert_eq!(warnings_on[0].line, 1);
363 assert_eq!(warnings_on[1].line, 2);
364 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented\nNormal line\nNo tabs");
365 }
366
367 #[test]
368 fn test_custom_spaces_per_tab() {
369 let content = "\tIndented";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372
373 let rule_off = MD010NoHardTabs::new(4);
374 assert!(
375 rule_off.check(&ctx).unwrap().is_empty(),
376 "indented code block skipped by default"
377 );
378 assert_eq!(
379 rule_off.fix(&ctx).unwrap(),
380 "\tIndented",
381 "indented code block preserved by default"
382 );
383
384 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
386 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
387 code_blocks: true,
388 ..Default::default()
389 });
390 assert_eq!(rule_on.check(&ctx).unwrap().len(), 1);
391 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented");
392 }
393
394 #[test]
395 fn test_fenced_code_block_tabs_skipped_by_default() {
396 let rule = MD010NoHardTabs::default();
397 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400 assert_eq!(result.len(), 2);
402 assert_eq!(result[0].line, 1);
403 assert_eq!(result[1].line, 5);
404
405 let fixed = rule.fix(&ctx).unwrap();
406 assert_eq!(fixed, "Normal line\n```\nCode\twith\ttab\n```\nAnother line");
407 }
408
409 #[test]
410 fn test_fenced_only_content_skipped_by_default() {
411 let rule = MD010NoHardTabs::default();
412 let content = "```\nCode\twith\ttab\n```";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let result = rule.check(&ctx).unwrap();
415 assert_eq!(result.len(), 0);
418 }
419
420 #[test]
421 fn test_html_comments_ignored() {
422 let rule = MD010NoHardTabs::default();
423 let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425 let result = rule.check(&ctx).unwrap();
426 assert_eq!(result.len(), 2);
428 assert_eq!(result[0].line, 1);
429 assert_eq!(result[1].line, 3);
430 }
431
432 #[test]
433 fn test_multiline_html_comments() {
434 let rule = MD010NoHardTabs::default();
435 let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert_eq!(result.len(), 1);
440 assert_eq!(result[0].line, 5);
441 }
442
443 #[test]
444 fn test_empty_lines_with_tabs() {
445 let rule = MD010NoHardTabs::default();
446 let content = "Normal line\n\t\t\n\t\nAnother line";
447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448 let result = rule.check(&ctx).unwrap();
449 assert_eq!(result.len(), 2);
450 assert_eq!(result[0].message, "Empty line contains 2 tabs");
451 assert_eq!(result[1].message, "Empty line contains tab");
452 }
453
454 #[test]
455 fn test_mixed_tabs_and_spaces() {
456 let content = " \tMixed indentation\n\t Mixed again";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
461
462 let rule_off = MD010NoHardTabs::default();
463 let result_off = rule_off.check(&ctx).unwrap();
464 assert!(
465 result_off.is_empty(),
466 "indented code block lines skipped, got {result_off:?}"
467 );
468 assert_eq!(
469 rule_off.fix(&ctx).unwrap(),
470 " \tMixed indentation\n\t Mixed again",
471 "content preserved unchanged"
472 );
473
474 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
476 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
477 code_blocks: true,
478 ..Default::default()
479 });
480 let result_on = rule_on.check(&ctx).unwrap();
481 assert_eq!(result_on.len(), 2, "got {result_on:?}");
482 assert_eq!(rule_on.fix(&ctx).unwrap(), " Mixed indentation\n Mixed again");
483 }
484
485 #[test]
486 fn test_consecutive_tabs() {
487 let rule = MD010NoHardTabs::default();
488 let content = "Text\t\t\tthree tabs\tand\tanother";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490 let result = rule.check(&ctx).unwrap();
491 assert_eq!(result.len(), 3);
493 assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
494 }
495
496 #[test]
497 fn test_find_and_group_tabs() {
498 let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
500 assert_eq!(groups, vec![(1, 2), (3, 4)]);
501
502 let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
503 assert_eq!(groups, vec![(0, 2)]);
504
505 let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
506 assert!(groups.is_empty());
507
508 let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
510 assert_eq!(groups, vec![(0, 3), (4, 6)]);
511
512 let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
513 assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
514 }
515
516 #[test]
517 fn test_count_leading_tabs() {
518 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
519 assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
520 assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
521 assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
522 }
523
524 #[test]
525 fn test_default_config() {
526 let rule = MD010NoHardTabs::default();
527 let config = rule.default_config_section();
528 assert!(config.is_some());
529 let (name, _value) = config.unwrap();
530 assert_eq!(name, "MD010");
531 }
532
533 #[test]
534 fn test_from_config() {
535 let content_plain = "\tTab";
537 let ctx_plain = LintContext::new(content_plain, crate::config::MarkdownFlavor::Standard, None);
538 let rule_8_off = MD010NoHardTabs::new(8); assert!(
540 rule_8_off.check(&ctx_plain).unwrap().is_empty(),
541 "indented code block skipped"
542 );
543 assert_eq!(
544 rule_8_off.fix(&ctx_plain).unwrap(),
545 "\tTab",
546 "content preserved unchanged"
547 );
548
549 let rule_8_on = MD010NoHardTabs::from_config_struct(MD010Config {
551 spaces_per_tab: crate::types::PositiveUsize::from_const(8),
552 code_blocks: true,
553 ..Default::default()
554 });
555 assert_eq!(rule_8_on.check(&ctx_plain).unwrap().len(), 1);
556 assert_eq!(rule_8_on.fix(&ctx_plain).unwrap(), " Tab");
557
558 let content_fenced = "```\n\tTab in code\n```";
560 let ctx_fenced = LintContext::new(content_fenced, crate::config::MarkdownFlavor::Standard, None);
561 assert!(
562 rule_8_off.check(&ctx_fenced).unwrap().is_empty(),
563 "fenced code block skipped"
564 );
565 assert_eq!(rule_8_off.fix(&ctx_fenced).unwrap(), "```\n\tTab in code\n```");
566
567 let result_on = rule_8_on.check(&ctx_fenced).unwrap();
569 assert_eq!(result_on.len(), 1, "got {result_on:?}");
570 assert_eq!(result_on[0].line, 2);
571 assert_eq!(rule_8_on.fix(&ctx_fenced).unwrap(), "```\n Tab in code\n```");
572 }
573
574 #[test]
575 fn test_performance_large_document() {
576 let rule = MD010NoHardTabs::default();
577 let mut content = String::new();
578 for i in 0..1000 {
579 content.push_str(&format!("Line {i}\twith\ttabs\n"));
580 }
581 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert_eq!(result.len(), 2000);
584 }
585
586 #[test]
587 fn test_preserve_content() {
588 let rule = MD010NoHardTabs::default();
589 let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let fixed = rule.fix(&ctx).unwrap();
592 assert_eq!(fixed, "**Bold** text\n*Italic* text\n[Link](url) tab");
593 }
594
595 #[test]
596 fn test_edge_cases() {
597 let rule = MD010NoHardTabs::default();
598
599 let content = "Text\t";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert_eq!(result.len(), 1);
604
605 let content = "\t\t\t";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609 assert_eq!(result.len(), 1);
610 assert_eq!(result[0].message, "Empty line contains 3 tabs");
611 }
612
613 #[test]
614 fn test_fenced_code_block_tabs_preserved_in_fix_by_default() {
615 let rule = MD010NoHardTabs::default();
616
617 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let fixed = rule.fix(&ctx).unwrap();
620
621 let expected = "Text with tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore tabs";
624 assert_eq!(fixed, expected);
625 }
626
627 #[test]
628 fn test_tilde_fence_longer_than_3() {
629 let rule = MD010NoHardTabs::default();
630 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(
636 result.len(),
637 2,
638 "Expected 2 warnings but got {}: {:?}",
639 result.len(),
640 result
641 );
642 assert_eq!(result[0].line, 4);
643 assert_eq!(result[1].line, 4);
644 }
645
646 #[test]
647 fn test_backtick_fence_longer_than_3() {
648 let rule = MD010NoHardTabs::default();
649 let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let result = rule.check(&ctx).unwrap();
653 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_indented_code_block_tabs_skipped_by_default() {
666 let content = " code\twith\ttab\n\nNormal\ttext";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670
671 let rule_off = MD010NoHardTabs::default();
672 let result_off = rule_off.check(&ctx).unwrap();
673 assert_eq!(
674 result_off.len(),
675 1,
676 "expected 1 warning (only normal-text tab), got {}: {:?}",
677 result_off.len(),
678 result_off
679 );
680 assert_eq!(result_off[0].line, 3);
681 assert_eq!(result_off[0].message, "Found tab for alignment, use spaces instead");
682
683 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
685 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
686 code_blocks: true,
687 ..Default::default()
688 });
689 let result_on = rule_on.check(&ctx).unwrap();
690 assert_eq!(
691 result_on.len(),
692 3,
693 "expected 3 warnings with code_blocks=true, got {}: {:?}",
694 result_on.len(),
695 result_on
696 );
697 assert_eq!(result_on[0].line, 1);
698 assert_eq!(result_on[1].line, 1);
699 assert_eq!(result_on[2].line, 3);
700 }
701
702 #[test]
703 fn test_html_comment_end_then_start_same_line() {
704 let rule = MD010NoHardTabs::default();
705 let content =
707 "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710 assert!(
711 result.is_empty(),
712 "Expected 0 warnings but got {}: {:?}",
713 result.len(),
714 result
715 );
716 }
717
718 #[test]
719 fn test_fix_tilde_fence_longer_than_3() {
720 let rule = MD010NoHardTabs::default();
721 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let fixed = rule.fix(&ctx).unwrap();
724 assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext with tab");
726 }
727
728 #[test]
729 fn test_fix_indented_code_block_tabs_replaced() {
730 let content = " code\twith\ttab\n\nNormal\ttext";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733
734 let rule_off = MD010NoHardTabs::default();
735 assert_eq!(
736 rule_off.fix(&ctx).unwrap(),
737 " code\twith\ttab\n\nNormal text",
738 "indented code block preserved; only normal-text tab fixed"
739 );
740
741 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
743 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
744 code_blocks: true,
745 ..Default::default()
746 });
747 assert_eq!(
748 rule_on.fix(&ctx).unwrap(),
749 " code with tab\n\nNormal text",
750 "all tabs replaced with code_blocks=true"
751 );
752 }
753
754 #[test]
755 fn test_issue_630_default_skips_both_code_blocks() {
756 let rule = MD010NoHardTabs::default();
758 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";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert!(result.is_empty(), "both code blocks skipped, got {result:?}");
762 }
763
764 #[test]
765 fn test_issue_630_code_blocks_true_flags_both() {
766 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
768 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
769 code_blocks: true,
770 ..Default::default()
771 });
772 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";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775 assert_eq!(result.len(), 2, "got {result:?}");
778 assert_eq!(result[0].line, 4);
779 assert_eq!(result[1].line, 11);
780 }
781
782 #[test]
783 fn test_code_blocks_toggle_fenced() {
784 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
785
786 let off = MD010NoHardTabs::default();
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let r_off = off.check(&ctx).unwrap();
790 assert_eq!(r_off.len(), 2, "got {r_off:?}");
791 assert_eq!(r_off[0].line, 1);
792 assert_eq!(r_off[1].line, 5);
793 assert_eq!(
794 off.fix(&ctx).unwrap(),
795 "Normal line\n```\nCode\twith\ttab\n```\nAnother line"
796 );
797
798 let on = MD010NoHardTabs::from_config_struct(MD010Config {
800 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
801 code_blocks: true,
802 ..Default::default()
803 });
804 let r_on = on.check(&ctx).unwrap();
805 assert_eq!(r_on.len(), 4, "got {r_on:?}");
806 assert_eq!(r_on[0].line, 1);
807 assert_eq!(r_on[1].line, 3);
808 assert_eq!(r_on[2].line, 3);
809 assert_eq!(r_on[3].line, 5);
810 assert_eq!(
811 on.fix(&ctx).unwrap(),
812 "Normal line\n```\nCode with tab\n```\nAnother line"
813 );
814 }
815
816 #[test]
817 fn test_code_blocks_toggle_makefile_fence_preserved_by_default() {
818 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\nMore\ttabs";
819 let off = MD010NoHardTabs::default();
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 assert_eq!(
823 off.fix(&ctx).unwrap(),
824 "Text with tab\n```makefile\ntarget:\n\tcommand\n```\nMore tabs"
825 );
826 }
827
828 #[test]
829 fn test_ignore_code_languages_skips_a_listed_fence() {
830 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\n```sh\necho\thello\n```";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834
835 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
836 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
837 code_blocks: true,
838 ignore_code_languages: vec!["makefile".to_string()],
839 });
840
841 let result = rule.check(&ctx).unwrap();
842 let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
843 assert_eq!(
844 lines,
845 vec![1, 1, 7],
846 "the makefile recipe tab on line 4 must be skipped, got {result:?}"
847 );
848 assert_eq!(
849 rule.fix(&ctx).unwrap(),
850 "Text with tab\n```makefile\ntarget:\n\tcommand\n```\n```sh\necho hello\n```"
851 );
852 }
853
854 #[test]
855 fn test_ignore_code_languages_matches_the_label_case_insensitively() {
856 let content = "```Makefile\ntarget:\n\tcommand\n```";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859
860 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
861 code_blocks: true,
862 ignore_code_languages: vec!["makefile".to_string()],
863 ..Default::default()
864 });
865
866 assert!(
867 rule.check(&ctx).unwrap().is_empty(),
868 "an uppercase fence label must match a lowercase configured language"
869 );
870 }
871
872 #[test]
873 fn test_ignore_code_languages_matches_only_the_first_info_string_word() {
874 let labelled = "```makefile title=\"Makefile\"\ntarget:\n\tcommand\n```";
876 let ctx = LintContext::new(labelled, 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 "a fence carrying attributes after its language must still match"
887 );
888
889 let other = "```shell title=\"Makefile\"\ntarget:\n\tcommand\n```";
892 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::Standard, None);
893 assert_eq!(
894 rule.check(&other_ctx).unwrap().len(),
895 1,
896 "an unlisted language must still be reported"
897 );
898 }
899
900 #[test]
901 fn test_ignore_code_languages_cannot_match_an_indented_code_block() {
902 let content = "Text.\n\n target:\n \tcommand\n";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906
907 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
908 code_blocks: true,
909 ignore_code_languages: vec!["makefile".to_string()],
910 ..Default::default()
911 });
912
913 assert_eq!(
914 rule.check(&ctx).unwrap().len(),
915 1,
916 "an indented block carries no language, so the list cannot exempt it"
917 );
918 }
919
920 #[test]
921 fn test_ignore_code_languages_is_inert_without_code_blocks() {
922 let content = "```makefile\ntarget:\n\tcommand\n```\n```sh\necho\thello\n```";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926
927 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
928 ignore_code_languages: vec!["makefile".to_string()],
929 ..Default::default()
930 });
931
932 assert!(
933 rule.check(&ctx).unwrap().is_empty(),
934 "listing a language must not start checking blocks that code-blocks skips"
935 );
936 }
937
938 #[test]
939 fn test_ignore_code_languages_matches_a_pandoc_class_attribute() {
940 let content = "```{.makefile}\ntarget:\n\tcommand\n```";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
944
945 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
946 code_blocks: true,
947 ignore_code_languages: vec!["makefile".to_string()],
948 ..Default::default()
949 });
950
951 assert!(
952 rule.check(&ctx).unwrap().is_empty(),
953 "the first .class of a Pandoc attribute block is the fence language"
954 );
955
956 let other = "```{.shell}\ntarget:\n\tcommand\n```";
958 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::Pandoc, None);
959 assert_eq!(
960 rule.check(&other_ctx).unwrap().len(),
961 1,
962 "an unlisted class must still be reported"
963 );
964 }
965
966 #[test]
967 fn test_ignore_code_languages_class_attribute_needs_a_pandoc_compatible_flavor() {
968 let content = "```{.makefile}\ntarget:\n\tcommand\n```";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972
973 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
974 code_blocks: true,
975 ignore_code_languages: vec!["makefile".to_string()],
976 ..Default::default()
977 });
978
979 assert_eq!(
980 rule.check(&ctx).unwrap().len(),
981 1,
982 "attribute syntax must only be read under a Pandoc-compatible flavor"
983 );
984 }
985
986 #[test]
987 fn test_ignore_code_languages_matches_a_quarto_exec_chunk() {
988 let content = "```{r}\nx <- 1\n\tindented\n```";
990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
991
992 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
993 code_blocks: true,
994 ignore_code_languages: vec!["r".to_string()],
995 ..Default::default()
996 });
997
998 assert!(
999 rule.check(&ctx).unwrap().is_empty(),
1000 "a `{{r}}` chunk declares language r"
1001 );
1002
1003 let python = "```{python}\nx = 1\n\tindented\n```";
1005 let python_ctx = LintContext::new(python, crate::config::MarkdownFlavor::Quarto, None);
1006 assert_eq!(
1007 rule.check(&python_ctx).unwrap().len(),
1008 1,
1009 "an unlisted engine must still be reported"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_ignore_code_languages_matches_an_azure_colon_fence() {
1015 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1017 code_blocks: true,
1018 ignore_code_languages: vec!["makefile".to_string()],
1019 ..Default::default()
1020 });
1021
1022 for content in [
1023 ":::makefile\ntarget:\n\tcommand\n:::",
1024 "::: makefile\ntarget:\n\tcommand\n:::",
1025 ] {
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::AzureDevOps, None);
1027 assert!(
1028 rule.check(&ctx).unwrap().is_empty(),
1029 "a colon fence's language must be honoured, got {:?} for {content:?}",
1030 rule.check(&ctx).unwrap()
1031 );
1032 }
1033
1034 let other = ":::mermaid\nflowchart LR\n\tA --> B\n:::";
1036 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::AzureDevOps, None);
1037 assert_eq!(
1038 rule.check(&other_ctx).unwrap().len(),
1039 1,
1040 "an unlisted colon fence language must still be reported"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_tabs_in_front_matter_are_not_flagged() {
1046 let rule = MD010NoHardTabs::default();
1049 let content = "---\ntitle:\t\"Tabbed value\"\n---\n\n# Heading\n\nBody text.\n";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert!(
1053 result.is_empty(),
1054 "tabs inside front matter must not be flagged, got: {result:?}"
1055 );
1056 }
1057}