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
17struct CodeLine {
19 quotes: usize,
21 prefix_end: Option<usize>,
23}
24
25impl MD010NoHardTabs {
26 pub fn new(spaces_per_tab: usize) -> Self {
27 Self {
28 config: MD010Config {
29 spaces_per_tab: crate::types::PositiveUsize::from_const(spaces_per_tab),
30 code_blocks: false,
31 ..Default::default()
32 },
33 }
34 }
35
36 pub const fn from_config_struct(config: MD010Config) -> Self {
37 Self { config }
38 }
39
40 fn structural_prefix(line: &str, code_quotes: Option<usize>) -> (usize, usize) {
54 let bytes = line.as_bytes();
55 if bytes.iter().all(|b| matches!(b, b' ' | b'\t')) {
56 return (0, 0);
57 }
58 let mut pos = 0;
59 let mut quotes = 0;
60 loop {
61 while pos < bytes.len() && matches!(bytes[pos], b' ' | b'\t') {
62 pos += 1;
63 }
64 let Some(&first) = bytes.get(pos) else {
65 return (pos, quotes);
66 };
67 let marker_len = match first {
68 b'>' if code_quotes.is_none_or(|limit| quotes < limit) => 1,
69 b'-' | b'*' | b'+' if code_quotes.is_none() => 1,
70 b'0'..=b'9' if code_quotes.is_none() => {
71 let digits = bytes[pos..].iter().take_while(|b| b.is_ascii_digit()).count();
72 match bytes.get(pos + digits) {
73 Some(b'.' | b')') if digits <= 9 => digits + 1,
74 _ => return (pos, quotes),
75 }
76 }
77 b'[' if code_quotes.is_none() => match Self::footnote_label_len(&line[pos..]) {
78 Some(len) => len,
79 None => return (pos, quotes),
80 },
81 _ => return (pos, quotes),
82 };
83 if first != b'>' && !matches!(bytes.get(pos + marker_len), None | Some(b' ' | b'\t')) {
84 return (pos, quotes);
85 }
86 quotes += usize::from(first == b'>');
87 pos += marker_len;
88 }
89 }
90
91 fn footnote_label_len(text: &str) -> Option<usize> {
98 let label = text.strip_prefix("[^")?;
99 let end = label.find([']', '[', '\t'])?;
100 (end > 0 && label[end..].starts_with("]:")).then_some(2 + end + 2)
101 }
102
103 fn code_quote_markers(ctx: &crate::lint_context::LintContext) -> std::collections::HashMap<usize, CodeLine> {
113 let mut lines = std::collections::HashMap::new();
114 for detail in &ctx.code_block_details {
115 let line_of = |byte: usize| ctx.line_offsets.partition_point(|&off| off <= byte).saturating_sub(1);
116 let first = line_of(detail.start);
117 let last = line_of(detail.end.saturating_sub(1)).max(first);
118 let opener = &ctx.content[ctx.line_content_byte_range(first + 1)];
119 let quotes = match detail.content_column {
120 Some(content_column) => {
121 let code_start = Self::offset_at_column(opener, content_column + 4);
122 let quotes = opener[..code_start].bytes().filter(|&b| b == b'>').count();
123 lines.insert(
124 first + 1,
125 CodeLine {
126 quotes,
127 prefix_end: Some(code_start),
128 },
129 );
130 quotes
131 }
132 None => Self::structural_prefix(opener, None).1,
133 };
134 for line in first + 2..=last + 1 {
135 lines.insert(
136 line,
137 CodeLine {
138 quotes,
139 prefix_end: None,
140 },
141 );
142 }
143 }
144 lines
145 }
146
147 fn offset_at_column(line: &str, column: usize) -> usize {
151 let mut col = 0;
152 for (i, b) in line.bytes().enumerate() {
153 if col >= column {
154 return i;
155 }
156 col += if b == b'\t' { 4 - col % 4 } else { 1 };
157 }
158 line.len()
159 }
160
161 fn columns_spanned(line: &str, start: usize, end: usize) -> usize {
166 let mut col = 0;
167 let mut start_col = 0;
168 for (i, c) in line[..end].char_indices() {
169 if i == start {
170 start_col = col;
171 }
172 col += if c == '\t' { 4 - col % 4 } else { 1 };
173 }
174 col - start_col
175 }
176
177 fn trailing_tab_run(line: &str) -> Option<usize> {
183 let start = line.trim_end_matches([' ', '\t']).len();
184 let run = &line[start..];
185 (run.contains('\t') && !run.ends_with(" ")).then_some(start)
186 }
187
188 fn fence_language(info_string: &str, flavor: crate::config::MarkdownFlavor) -> std::borrow::Cow<'_, str> {
197 let info = info_string.trim();
198
199 if flavor.is_pandoc_compatible()
200 && let Some(class) = crate::utils::pandoc::pandoc_code_class_lang(info)
201 {
202 return std::borrow::Cow::Borrowed(class);
203 }
204
205 if flavor == crate::config::MarkdownFlavor::Quarto
206 && crate::utils::quarto_chunks::is_executable_chunk(info)
207 && let Some(header) = crate::utils::quarto_chunks::parse_inline_chunk_header(info)
208 {
209 return std::borrow::Cow::Owned(header.engine);
210 }
211
212 std::borrow::Cow::Borrowed(info.split_whitespace().next().unwrap_or(""))
213 }
214
215 fn ignored_language_lines(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
221 let mut ignored = std::collections::HashSet::new();
222
223 for detail in ctx.code_block_details.iter().chain(ctx.colon_fence_details()) {
224 let label = Self::fence_language(&detail.info_string, ctx.flavor);
225 if label.is_empty()
226 || !self
227 .config
228 .ignore_code_languages
229 .iter()
230 .any(|listed| listed.eq_ignore_ascii_case(label.as_ref()))
231 {
232 continue;
233 }
234
235 let start_line = ctx
236 .line_offsets
237 .partition_point(|&off| off <= detail.start)
238 .saturating_sub(1);
239 let end_byte = detail.end.saturating_sub(1);
240 let end_line = ctx
241 .line_offsets
242 .partition_point(|&off| off <= end_byte)
243 .saturating_sub(1);
244 for line in start_line..=end_line {
245 ignored.insert(line + 1);
246 }
247 }
248
249 ignored
250 }
251
252 fn find_and_group_tabs(line: &str) -> Vec<(usize, usize)> {
254 let mut groups = Vec::new();
255 let mut current_group_start: Option<usize> = None;
256 let mut last_tab_pos = 0;
257
258 for (i, byte) in line.bytes().enumerate() {
259 if byte == b'\t' {
260 if let Some(start) = current_group_start {
261 if i == last_tab_pos + 1 {
263 last_tab_pos = i;
265 } else {
266 groups.push((start, last_tab_pos + 1));
268 current_group_start = Some(i);
269 last_tab_pos = i;
270 }
271 } else {
272 current_group_start = Some(i);
274 last_tab_pos = i;
275 }
276 }
277 }
278
279 if let Some(start) = current_group_start {
281 groups.push((start, last_tab_pos + 1));
282 }
283
284 groups
285 }
286}
287
288impl Rule for MD010NoHardTabs {
289 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
290 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(10))
291 }
292
293 fn name(&self) -> &'static str {
294 "MD010"
295 }
296
297 fn description(&self) -> &'static str {
298 "No tabs"
299 }
300
301 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
302 let mut warnings = Vec::new();
303
304 let mut filtered = ctx
305 .filtered_lines()
306 .skip_front_matter()
307 .skip_html_comments()
308 .skip_mdx_comments()
309 .skip_html_blocks()
310 .skip_pymdown_blocks()
311 .skip_mkdocstrings()
312 .skip_esm_blocks();
313
314 if !self.config.code_blocks {
315 filtered = filtered.skip_code_blocks();
316 }
317
318 let code_quotes = if self.config.code_blocks {
323 Self::code_quote_markers(ctx)
324 } else {
325 std::collections::HashMap::new()
326 };
327
328 let ignored_lines = if self.config.code_blocks && !self.config.ignore_code_languages.is_empty() {
329 self.ignored_language_lines(ctx)
330 } else {
331 std::collections::HashSet::new()
332 };
333
334 for filtered_line in filtered {
335 if ignored_lines.contains(&filtered_line.line_num) {
336 continue;
337 }
338 let line_num = filtered_line.line_num - 1;
339 let line = filtered_line.content;
340
341 let tab_groups = Self::find_and_group_tabs(line);
343 if tab_groups.is_empty() {
344 continue;
345 }
346
347 let code_line = code_quotes.get(&filtered_line.line_num);
348 let prefix_end = match code_line {
349 Some(CodeLine {
350 prefix_end: Some(end), ..
351 }) => *end,
352 _ => Self::structural_prefix(line, code_line.map(|c| c.quotes)).0,
353 };
354 let line_start = ctx.line_content_byte_range(line_num + 1).start;
355
356 let trailing_start = if filtered_line.line_info.in_code_block || line.trim().is_empty() {
360 None
361 } else {
362 Self::trailing_tab_run(line)
363 };
364 let mut tab_groups = tab_groups;
365 if let Some(trailing) = trailing_start {
366 tab_groups.retain(|&(start, _)| start < trailing);
367 tab_groups.push((trailing, line.len()));
368 }
369
370 for (start_pos, end_pos) in tab_groups {
372 let is_trailing_run = trailing_start == Some(start_pos);
373 if ctx.overlaps_mdx_inline_code(line_start + start_pos, line_start + end_pos)
377 || (ctx.flavor.supports_jsx()
378 && (ctx.is_in_html_tag(line_start + start_pos)
379 || ctx.is_in_jsx_component_tag(line_start + start_pos)))
380 || ctx.is_in_jinja_string(line_start + start_pos)
381 || ctx.is_in_shortcode(line_start + start_pos)
382 {
383 continue;
384 }
385
386 let tab_count = line[start_pos..end_pos].matches('\t').count();
387
388 if ctx.is_code_span_text_byte(line_start + start_pos) {
398 if self.config.code_blocks {
399 let (start_line, start_col, end_line, end_col) =
400 calculate_match_range(line_num + 1, line, start_pos, end_pos - start_pos);
401 warnings.push(LintWarning {
402 rule_name: Some(self.name().to_string()),
403 line: start_line,
404 column: start_col,
405 end_line,
406 end_column: end_col,
407 message: "Found tab inside inline code, left as is because it is part of the code text"
408 .to_string(),
409 severity: Severity::Warning,
410 fix: None,
411 });
412 }
413 continue;
414 }
415
416 let is_structural = !is_trailing_run && start_pos < prefix_end;
417
418 let replacement = if is_trailing_run {
427 (!line[..start_pos].ends_with('\\')).then(String::new)
428 } else if is_structural {
429 Some(" ".repeat(Self::columns_spanned(line, start_pos, end_pos)))
430 } else {
431 Some(" ".repeat(tab_count * self.config.spaces_per_tab.get()))
432 };
433
434 let (start_line, start_col, end_line, end_col) =
436 calculate_match_range(line_num + 1, line, start_pos, end_pos - start_pos);
437
438 let message = if is_trailing_run {
439 "Found trailing tab, remove it".to_string()
440 } else if line.trim().is_empty() {
441 if tab_count == 1 {
442 "Empty line contains tab".to_string()
443 } else {
444 format!("Empty line contains {tab_count} tabs")
445 }
446 } else if is_structural {
447 if tab_count == 1 {
448 format!(
449 "Found leading tab, use {} spaces instead",
450 replacement.as_deref().map_or(0, str::len)
451 )
452 } else {
453 format!(
454 "Found {} leading tabs, use {} spaces instead",
455 tab_count,
456 replacement.as_deref().map_or(0, str::len)
457 )
458 }
459 } else if tab_count == 1 {
460 "Found tab for alignment, use spaces instead".to_string()
461 } else {
462 format!("Found {tab_count} tabs for alignment, use spaces instead")
463 };
464
465 warnings.push(LintWarning {
466 rule_name: Some(self.name().to_string()),
467 line: start_line,
468 column: start_col,
469 end_line,
470 end_column: end_col,
471 message,
472 severity: Severity::Warning,
473 fix: replacement
474 .map(|replacement| Fix::new(line_start + start_pos..line_start + end_pos, replacement)),
475 });
476 }
477 }
478
479 Ok(warnings)
480 }
481
482 fn fixes_from_diagnostics(&self) -> bool {
483 true
484 }
485
486 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
487 if self.should_skip(ctx) {
488 return Ok(ctx.content.to_string());
489 }
490 let warnings = self.check(ctx)?;
491 if warnings.is_empty() {
492 return Ok(ctx.content.to_string());
493 }
494 let warnings =
495 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
496 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
497 .map_err(crate::rule::LintError::InvalidInput)
498 }
499
500 fn as_any(&self) -> &dyn std::any::Any {
501 self
502 }
503
504 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
505 ctx.content.is_empty() || !ctx.has_char('\t')
507 }
508
509 fn category(&self) -> RuleCategory {
510 RuleCategory::Whitespace
511 }
512
513 crate::impl_rule_config_methods!(MD010Config);
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::lint_context::LintContext;
520 use crate::rule::Rule;
521
522 #[test]
523 fn test_mdx_expression_tabs_preserve_string_values() {
524 let rule = MD010NoHardTabs::default();
525 for source in [
526 "export const unused = 1\n\n{ \"left\tright\" }\n",
527 "Before { \"left\t\tright\" } after\n",
528 "# heading { \"内容\tvalue\" }\n",
529 "export const unused = 1\n\n{`left\n\tright`}\n",
530 ] {
531 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
532 assert!(rule.check(&ctx).unwrap().is_empty(), "MDX code is literal: {source}");
533 assert_eq!(rule.fix(&ctx).unwrap(), source);
534 }
535 }
536
537 #[test]
538 fn test_mdx_attribute_tabs_preserve_values_and_lint_visible_children() {
539 let rule = MD010NoHardTabs::default();
540 for attribute in ["title=\"left\tright\"", "title={\"left\tright\"}"] {
541 let source = format!("Before <span {attribute}>left\tright</span> after\n");
542 let expected = format!("Before <span {attribute}>left right</span> after\n");
543 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
544 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
545 assert_eq!(rule.fix(&ctx).unwrap(), expected);
546 }
547 }
548
549 #[test]
550 fn test_tabs_beside_mdx_expressions_are_still_fixed() {
551 let rule = MD010NoHardTabs::default();
552 let source = "Before\t{ \"left\tright\" }\tafter\n";
553 let expected = "Before { \"left\tright\" } after\n";
554 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
555 assert_eq!(rule.check(&ctx).unwrap().len(), 2);
556 assert_eq!(rule.fix(&ctx).unwrap(), expected);
557 }
558
559 #[test]
560 fn test_quoted_jinja_tabs_are_literal_but_directive_spacing_is_lintable() {
561 let rule = MD010NoHardTabs::default();
562 for template in [
563 "{{ 'left\tright' }}",
564 "{% set value = 'left\tright' %}{{ value }}",
565 "{{ {\"text\": 'left\tright'}[\"text\"] }}",
566 ] {
567 let source = format!("Before {template}\tafter\n");
568 let expected = format!("Before {template} after\n");
569 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
570 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
571 assert_eq!(rule.fix(&ctx).unwrap(), expected);
572 }
573 let source = "Before {{ value\t}} after\n";
574 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
575 assert_eq!(rule.fix(&ctx).unwrap(), "Before {{ value }} after\n");
576 }
577
578 #[test]
579 fn test_hugo_shortcode_tabs_preserve_argument_values() {
580 let rule = MD010NoHardTabs::default();
581 for template in ["{{< note title=\"left\tright\" >}}", "{{% note title=`left\tright` %}}"] {
582 let source = format!("Before {template}\tafter\n");
583 let expected = format!("Before {template} after\n");
584 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Hugo, None);
585 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
586 assert_eq!(rule.fix(&ctx).unwrap(), expected);
587 }
588 }
589
590 #[test]
591 fn test_no_tabs() {
592 let rule = MD010NoHardTabs::default();
593 let content = "This is a line\nAnother line\nNo tabs here";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 assert!(result.is_empty());
597 }
598
599 #[test]
600 fn test_single_tab() {
601 let rule = MD010NoHardTabs::default();
602 let content = "Line with\ttab";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert_eq!(result.len(), 1);
606 assert_eq!(result[0].line, 1);
607 assert_eq!(result[0].column, 10);
608 assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
609 }
610
611 #[test]
612 fn test_leading_tabs_skipped_in_indented_code_by_default() {
613 let content = "\tIndented line\n\t\tDouble indented";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617
618 let rule_off = MD010NoHardTabs::default();
619 let result_off = rule_off.check(&ctx).unwrap();
620 assert!(
621 result_off.is_empty(),
622 "indented code block skipped by default, got {result_off:?}"
623 );
624 assert_eq!(
625 rule_off.fix(&ctx).unwrap(),
626 "\tIndented line\n\t\tDouble indented",
627 "fix must preserve indented code block content"
628 );
629
630 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
632 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
633 code_blocks: true,
634 ..Default::default()
635 });
636 let result_on = rule_on.check(&ctx).unwrap();
637 assert_eq!(result_on.len(), 2, "got {result_on:?}");
638 assert_eq!(result_on[0].line, 1);
639 assert_eq!(result_on[0].message, "Found leading tab, use 4 spaces instead");
640 assert_eq!(result_on[1].line, 2);
641 assert_eq!(result_on[1].message, "Found 2 leading tabs, use 8 spaces instead");
642 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented line\n Double indented");
643 }
644
645 #[test]
646 fn test_fix_tabs() {
647 let content = "\tIndented\nNormal\tline\nNo tabs";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651
652 let rule_off = MD010NoHardTabs::default();
653 let warnings_off = rule_off.check(&ctx).unwrap();
654 assert_eq!(warnings_off.len(), 1, "got {warnings_off:?}");
655 assert_eq!(warnings_off[0].line, 2);
656 assert_eq!(warnings_off[0].message, "Found tab for alignment, use spaces instead");
657 assert_eq!(
658 rule_off.fix(&ctx).unwrap(),
659 "\tIndented\nNormal line\nNo tabs",
660 "indented code block line preserved; alignment tab fixed"
661 );
662
663 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
665 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
666 code_blocks: true,
667 ..Default::default()
668 });
669 let warnings_on = rule_on.check(&ctx).unwrap();
670 assert_eq!(warnings_on.len(), 2, "got {warnings_on:?}");
671 assert_eq!(warnings_on[0].line, 1);
672 assert_eq!(warnings_on[1].line, 2);
673 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented\nNormal line\nNo tabs");
674 }
675
676 #[test]
677 fn test_custom_spaces_per_tab() {
678 let content = "\tIndented";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681
682 let rule_off = MD010NoHardTabs::new(4);
683 assert!(
684 rule_off.check(&ctx).unwrap().is_empty(),
685 "indented code block skipped by default"
686 );
687 assert_eq!(
688 rule_off.fix(&ctx).unwrap(),
689 "\tIndented",
690 "indented code block preserved by default"
691 );
692
693 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
695 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
696 code_blocks: true,
697 ..Default::default()
698 });
699 assert_eq!(rule_on.check(&ctx).unwrap().len(), 1);
700 assert_eq!(rule_on.fix(&ctx).unwrap(), " Indented");
701 }
702
703 #[test]
704 fn test_fenced_code_block_tabs_skipped_by_default() {
705 let rule = MD010NoHardTabs::default();
706 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let result = rule.check(&ctx).unwrap();
709 assert_eq!(result.len(), 2);
711 assert_eq!(result[0].line, 1);
712 assert_eq!(result[1].line, 5);
713
714 let fixed = rule.fix(&ctx).unwrap();
715 assert_eq!(fixed, "Normal line\n```\nCode\twith\ttab\n```\nAnother line");
716 }
717
718 #[test]
719 fn test_fenced_only_content_skipped_by_default() {
720 let rule = MD010NoHardTabs::default();
721 let content = "```\nCode\twith\ttab\n```";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724 assert_eq!(result.len(), 0);
727 }
728
729 #[test]
730 fn test_html_comments_ignored() {
731 let rule = MD010NoHardTabs::default();
732 let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx).unwrap();
735 assert_eq!(result.len(), 2);
737 assert_eq!(result[0].line, 1);
738 assert_eq!(result[1].line, 3);
739 }
740
741 #[test]
742 fn test_multiline_html_comments() {
743 let rule = MD010NoHardTabs::default();
744 let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(result.len(), 1);
749 assert_eq!(result[0].line, 5);
750 }
751
752 #[test]
753 fn test_empty_lines_with_tabs() {
754 let rule = MD010NoHardTabs::default();
755 let content = "Normal line\n\t\t\n\t\nAnother line";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert_eq!(result.len(), 2);
759 assert_eq!(result[0].message, "Empty line contains 2 tabs");
760 assert_eq!(result[1].message, "Empty line contains tab");
761 }
762
763 #[test]
764 fn test_mixed_tabs_and_spaces() {
765 let content = " \tMixed indentation\n\t Mixed again";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770
771 let rule_off = MD010NoHardTabs::default();
772 let result_off = rule_off.check(&ctx).unwrap();
773 assert!(
774 result_off.is_empty(),
775 "indented code block lines skipped, got {result_off:?}"
776 );
777 assert_eq!(
778 rule_off.fix(&ctx).unwrap(),
779 " \tMixed indentation\n\t Mixed again",
780 "content preserved unchanged"
781 );
782
783 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
785 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
786 code_blocks: true,
787 ..Default::default()
788 });
789 let result_on = rule_on.check(&ctx).unwrap();
790 assert_eq!(result_on.len(), 2, "got {result_on:?}");
791 assert_eq!(rule_on.fix(&ctx).unwrap(), " Mixed indentation\n Mixed again");
793 }
794
795 #[test]
796 fn test_consecutive_tabs() {
797 let rule = MD010NoHardTabs::default();
798 let content = "Text\t\t\tthree tabs\tand\tanother";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert_eq!(result.len(), 3);
803 assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
804 }
805
806 #[test]
807 fn test_find_and_group_tabs() {
808 let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
810 assert_eq!(groups, vec![(1, 2), (3, 4)]);
811
812 let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
813 assert_eq!(groups, vec![(0, 2)]);
814
815 let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
816 assert!(groups.is_empty());
817
818 let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
820 assert_eq!(groups, vec![(0, 3), (4, 6)]);
821
822 let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
823 assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
824 }
825
826 #[test]
827 fn tab_after_multibyte_characters_reports_its_character_column() {
828 let rule = MD010NoHardTabs::default();
829 let content = "a\u{e9}\u{e9}b\tdef\n\u{1f600}\t\tx\n";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let warnings = rule.check(&ctx).unwrap();
832 let columns: Vec<_> = warnings.iter().map(|w| (w.line, w.column, w.end_column)).collect();
833 assert_eq!(columns, vec![(1, 5, 6), (2, 2, 4)]);
834
835 for warning in &warnings {
836 let range = warning.fix.as_ref().unwrap().range.clone();
837 assert!(content[range].chars().all(|c| c == '\t'), "fix must replace only tabs");
838 }
839 assert_eq!(rule.fix(&ctx).unwrap(), "a\u{e9}\u{e9}b def\n\u{1f600} x\n");
840 }
841
842 fn structural_prefix_end(line: &str) -> usize {
843 MD010NoHardTabs::structural_prefix(line, None).0
844 }
845
846 #[test]
847 fn test_structural_prefix_end() {
848 assert_eq!(structural_prefix_end("\t\tcode"), 2);
849 assert_eq!(structural_prefix_end(" \tcode"), 2);
850 assert_eq!(structural_prefix_end("no tabs"), 0);
851 assert_eq!(structural_prefix_end("\t"), 0);
852 assert_eq!(structural_prefix_end("10.\tx"), 4);
853 assert_eq!(structural_prefix_end("-\t> x"), 4);
854 assert_eq!(structural_prefix_end("-x\ty"), 0);
855 assert_eq!(structural_prefix_end("1234567890.\tx"), 0);
856 assert_eq!(structural_prefix_end("12\tx"), 0);
857 assert_eq!(MD010NoHardTabs::structural_prefix("-\tx", Some(0)), (0, 0));
859 assert_eq!(MD010NoHardTabs::structural_prefix("1.\tx", Some(0)), (0, 0));
860 assert_eq!(MD010NoHardTabs::structural_prefix(">\tx", Some(0)), (0, 0));
861 assert_eq!(MD010NoHardTabs::structural_prefix(">\t>\tx", Some(1)), (2, 1));
862 assert_eq!(MD010NoHardTabs::structural_prefix(" \t-\tx", Some(0)), (3, 0));
863 }
864
865 #[test]
866 fn test_default_config() {
867 let rule = MD010NoHardTabs::default();
868 let config = rule.default_config_section();
869 assert!(config.is_some());
870 let (name, _value) = config.unwrap();
871 assert_eq!(name, "MD010");
872 }
873
874 #[test]
875 fn test_from_config() {
876 let content_plain = "\tTab";
878 let ctx_plain = LintContext::new(content_plain, crate::config::MarkdownFlavor::Standard, None);
879 let rule_8_off = MD010NoHardTabs::new(8); assert!(
881 rule_8_off.check(&ctx_plain).unwrap().is_empty(),
882 "indented code block skipped"
883 );
884 assert_eq!(
885 rule_8_off.fix(&ctx_plain).unwrap(),
886 "\tTab",
887 "content preserved unchanged"
888 );
889
890 let rule_8_on = MD010NoHardTabs::from_config_struct(MD010Config {
893 spaces_per_tab: crate::types::PositiveUsize::from_const(8),
894 code_blocks: true,
895 ..Default::default()
896 });
897 assert_eq!(rule_8_on.check(&ctx_plain).unwrap().len(), 1);
898 assert_eq!(rule_8_on.fix(&ctx_plain).unwrap(), " Tab");
899
900 let content_fenced = "```\n\tTab in code\n```";
902 let ctx_fenced = LintContext::new(content_fenced, crate::config::MarkdownFlavor::Standard, None);
903 assert!(
904 rule_8_off.check(&ctx_fenced).unwrap().is_empty(),
905 "fenced code block skipped"
906 );
907 assert_eq!(rule_8_off.fix(&ctx_fenced).unwrap(), "```\n\tTab in code\n```");
908
909 let result_on = rule_8_on.check(&ctx_fenced).unwrap();
911 assert_eq!(result_on.len(), 1, "got {result_on:?}");
912 assert_eq!(result_on[0].line, 2);
913 assert_eq!(rule_8_on.fix(&ctx_fenced).unwrap(), "```\n Tab in code\n```");
914 }
915
916 #[test]
917 fn test_performance_large_document() {
918 let rule = MD010NoHardTabs::default();
919 let mut content = String::new();
920 for i in 0..1000 {
921 content.push_str(&format!("Line {i}\twith\ttabs\n"));
922 }
923 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925 assert_eq!(result.len(), 2000);
926 }
927
928 #[test]
929 fn test_preserve_content() {
930 let rule = MD010NoHardTabs::default();
931 let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let fixed = rule.fix(&ctx).unwrap();
934 assert_eq!(fixed, "**Bold** text\n*Italic* text\n[Link](url) tab");
935 }
936
937 #[test]
938 fn test_edge_cases() {
939 let rule = MD010NoHardTabs::default();
940
941 let content = "Text\t";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let result = rule.check(&ctx).unwrap();
945 assert_eq!(result.len(), 1);
946
947 let content = "\t\t\t";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951 assert_eq!(result.len(), 1);
952 assert_eq!(result[0].message, "Empty line contains 3 tabs");
953 }
954
955 #[test]
956 fn test_fenced_code_block_tabs_preserved_in_fix_by_default() {
957 let rule = MD010NoHardTabs::default();
958
959 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let fixed = rule.fix(&ctx).unwrap();
962
963 let expected = "Text with tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore tabs";
966 assert_eq!(fixed, expected);
967 }
968
969 #[test]
970 fn test_tilde_fence_longer_than_3() {
971 let rule = MD010NoHardTabs::default();
972 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976 assert_eq!(
978 result.len(),
979 2,
980 "Expected 2 warnings but got {}: {:?}",
981 result.len(),
982 result
983 );
984 assert_eq!(result[0].line, 4);
985 assert_eq!(result[1].line, 4);
986 }
987
988 #[test]
989 fn test_backtick_fence_longer_than_3() {
990 let rule = MD010NoHardTabs::default();
991 let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995 assert_eq!(
996 result.len(),
997 2,
998 "Expected 2 warnings but got {}: {:?}",
999 result.len(),
1000 result
1001 );
1002 assert_eq!(result[0].line, 4);
1003 assert_eq!(result[1].line, 4);
1004 }
1005
1006 #[test]
1007 fn test_indented_code_block_tabs_skipped_by_default() {
1008 let content = " code\twith\ttab\n\nNormal\ttext";
1011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1012
1013 let rule_off = MD010NoHardTabs::default();
1014 let result_off = rule_off.check(&ctx).unwrap();
1015 assert_eq!(
1016 result_off.len(),
1017 1,
1018 "expected 1 warning (only normal-text tab), got {}: {:?}",
1019 result_off.len(),
1020 result_off
1021 );
1022 assert_eq!(result_off[0].line, 3);
1023 assert_eq!(result_off[0].message, "Found tab for alignment, use spaces instead");
1024
1025 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
1027 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
1028 code_blocks: true,
1029 ..Default::default()
1030 });
1031 let result_on = rule_on.check(&ctx).unwrap();
1032 assert_eq!(
1033 result_on.len(),
1034 3,
1035 "expected 3 warnings with code_blocks=true, got {}: {:?}",
1036 result_on.len(),
1037 result_on
1038 );
1039 assert_eq!(result_on[0].line, 1);
1040 assert_eq!(result_on[1].line, 1);
1041 assert_eq!(result_on[2].line, 3);
1042 }
1043
1044 #[test]
1045 fn test_html_comment_end_then_start_same_line() {
1046 let rule = MD010NoHardTabs::default();
1047 let content =
1049 "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\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 "Expected 0 warnings but got {}: {:?}",
1055 result.len(),
1056 result
1057 );
1058 }
1059
1060 #[test]
1061 fn test_fix_tilde_fence_longer_than_3() {
1062 let rule = MD010NoHardTabs::default();
1063 let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let fixed = rule.fix(&ctx).unwrap();
1066 assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext with tab");
1068 }
1069
1070 #[test]
1071 fn test_fix_indented_code_block_tabs_replaced() {
1072 let content = " code\twith\ttab\n\nNormal\ttext";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075
1076 let rule_off = MD010NoHardTabs::default();
1077 assert_eq!(
1078 rule_off.fix(&ctx).unwrap(),
1079 " code\twith\ttab\n\nNormal text",
1080 "indented code block preserved; only normal-text tab fixed"
1081 );
1082
1083 let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
1085 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
1086 code_blocks: true,
1087 ..Default::default()
1088 });
1089 assert_eq!(
1090 rule_on.fix(&ctx).unwrap(),
1091 " code with tab\n\nNormal text",
1092 "all tabs replaced with code_blocks=true"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_issue_630_default_skips_both_code_blocks() {
1098 let rule = MD010NoHardTabs::default();
1100 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";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let result = rule.check(&ctx).unwrap();
1103 assert!(result.is_empty(), "both code blocks skipped, got {result:?}");
1104 }
1105
1106 #[test]
1107 fn test_issue_630_code_blocks_true_flags_both() {
1108 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1110 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
1111 code_blocks: true,
1112 ..Default::default()
1113 });
1114 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";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert_eq!(result.len(), 2, "got {result:?}");
1120 assert_eq!(result[0].line, 4);
1121 assert_eq!(result[1].line, 11);
1122 }
1123
1124 #[test]
1125 fn test_code_blocks_toggle_fenced() {
1126 let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
1127
1128 let off = MD010NoHardTabs::default();
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let r_off = off.check(&ctx).unwrap();
1132 assert_eq!(r_off.len(), 2, "got {r_off:?}");
1133 assert_eq!(r_off[0].line, 1);
1134 assert_eq!(r_off[1].line, 5);
1135 assert_eq!(
1136 off.fix(&ctx).unwrap(),
1137 "Normal line\n```\nCode\twith\ttab\n```\nAnother line"
1138 );
1139
1140 let on = MD010NoHardTabs::from_config_struct(MD010Config {
1142 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
1143 code_blocks: true,
1144 ..Default::default()
1145 });
1146 let r_on = on.check(&ctx).unwrap();
1147 assert_eq!(r_on.len(), 4, "got {r_on:?}");
1148 assert_eq!(r_on[0].line, 1);
1149 assert_eq!(r_on[1].line, 3);
1150 assert_eq!(r_on[2].line, 3);
1151 assert_eq!(r_on[3].line, 5);
1152 assert_eq!(
1153 on.fix(&ctx).unwrap(),
1154 "Normal line\n```\nCode with tab\n```\nAnother line"
1155 );
1156 }
1157
1158 #[test]
1159 fn test_code_blocks_toggle_makefile_fence_preserved_by_default() {
1160 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\nMore\ttabs";
1161 let off = MD010NoHardTabs::default();
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 assert_eq!(
1165 off.fix(&ctx).unwrap(),
1166 "Text with tab\n```makefile\ntarget:\n\tcommand\n```\nMore tabs"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_ignore_code_languages_skips_a_listed_fence() {
1172 let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\n```sh\necho\thello\n```";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176
1177 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1178 spaces_per_tab: crate::types::PositiveUsize::from_const(4),
1179 code_blocks: true,
1180 ignore_code_languages: vec!["makefile".to_string()],
1181 });
1182
1183 let result = rule.check(&ctx).unwrap();
1184 let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1185 assert_eq!(
1186 lines,
1187 vec![1, 1, 7],
1188 "the makefile recipe tab on line 4 must be skipped, got {result:?}"
1189 );
1190 assert_eq!(
1191 rule.fix(&ctx).unwrap(),
1192 "Text with tab\n```makefile\ntarget:\n\tcommand\n```\n```sh\necho hello\n```"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_ignore_code_languages_matches_the_label_case_insensitively() {
1198 let content = "```Makefile\ntarget:\n\tcommand\n```";
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201
1202 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1203 code_blocks: true,
1204 ignore_code_languages: vec!["makefile".to_string()],
1205 ..Default::default()
1206 });
1207
1208 assert!(
1209 rule.check(&ctx).unwrap().is_empty(),
1210 "an uppercase fence label must match a lowercase configured language"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_ignore_code_languages_matches_only_the_first_info_string_word() {
1216 let labelled = "```makefile title=\"Makefile\"\ntarget:\n\tcommand\n```";
1218 let ctx = LintContext::new(labelled, crate::config::MarkdownFlavor::Standard, None);
1219
1220 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1221 code_blocks: true,
1222 ignore_code_languages: vec!["makefile".to_string()],
1223 ..Default::default()
1224 });
1225
1226 assert!(
1227 rule.check(&ctx).unwrap().is_empty(),
1228 "a fence carrying attributes after its language must still match"
1229 );
1230
1231 let other = "```shell title=\"Makefile\"\ntarget:\n\tcommand\n```";
1234 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::Standard, None);
1235 assert_eq!(
1236 rule.check(&other_ctx).unwrap().len(),
1237 1,
1238 "an unlisted language must still be reported"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_ignore_code_languages_cannot_match_an_indented_code_block() {
1244 let content = "Text.\n\n target:\n \tcommand\n";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248
1249 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1250 code_blocks: true,
1251 ignore_code_languages: vec!["makefile".to_string()],
1252 ..Default::default()
1253 });
1254
1255 assert_eq!(
1256 rule.check(&ctx).unwrap().len(),
1257 1,
1258 "an indented block carries no language, so the list cannot exempt it"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_ignore_code_languages_is_inert_without_code_blocks() {
1264 let content = "```makefile\ntarget:\n\tcommand\n```\n```sh\necho\thello\n```";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268
1269 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1270 ignore_code_languages: vec!["makefile".to_string()],
1271 ..Default::default()
1272 });
1273
1274 assert!(
1275 rule.check(&ctx).unwrap().is_empty(),
1276 "listing a language must not start checking blocks that code-blocks skips"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_ignore_code_languages_matches_a_pandoc_class_attribute() {
1282 let content = "```{.makefile}\ntarget:\n\tcommand\n```";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1286
1287 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1288 code_blocks: true,
1289 ignore_code_languages: vec!["makefile".to_string()],
1290 ..Default::default()
1291 });
1292
1293 assert!(
1294 rule.check(&ctx).unwrap().is_empty(),
1295 "the first .class of a Pandoc attribute block is the fence language"
1296 );
1297
1298 let other = "```{.shell}\ntarget:\n\tcommand\n```";
1300 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::Pandoc, None);
1301 assert_eq!(
1302 rule.check(&other_ctx).unwrap().len(),
1303 1,
1304 "an unlisted class must still be reported"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_ignore_code_languages_class_attribute_needs_a_pandoc_compatible_flavor() {
1310 let content = "```{.makefile}\ntarget:\n\tcommand\n```";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314
1315 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1316 code_blocks: true,
1317 ignore_code_languages: vec!["makefile".to_string()],
1318 ..Default::default()
1319 });
1320
1321 assert_eq!(
1322 rule.check(&ctx).unwrap().len(),
1323 1,
1324 "attribute syntax must only be read under a Pandoc-compatible flavor"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_ignore_code_languages_matches_a_quarto_exec_chunk() {
1330 let content = "```{r}\nx <- 1\n\tindented\n```";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1333
1334 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1335 code_blocks: true,
1336 ignore_code_languages: vec!["r".to_string()],
1337 ..Default::default()
1338 });
1339
1340 assert!(
1341 rule.check(&ctx).unwrap().is_empty(),
1342 "a `{{r}}` chunk declares language r"
1343 );
1344
1345 let python = "```{python}\nx = 1\n\tindented\n```";
1347 let python_ctx = LintContext::new(python, crate::config::MarkdownFlavor::Quarto, None);
1348 assert_eq!(
1349 rule.check(&python_ctx).unwrap().len(),
1350 1,
1351 "an unlisted engine must still be reported"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_ignore_code_languages_matches_an_azure_colon_fence() {
1357 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1359 code_blocks: true,
1360 ignore_code_languages: vec!["makefile".to_string()],
1361 ..Default::default()
1362 });
1363
1364 for content in [
1365 ":::makefile\ntarget:\n\tcommand\n:::",
1366 "::: makefile\ntarget:\n\tcommand\n:::",
1367 ] {
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::AzureDevOps, None);
1369 assert!(
1370 rule.check(&ctx).unwrap().is_empty(),
1371 "a colon fence's language must be honoured, got {:?} for {content:?}",
1372 rule.check(&ctx).unwrap()
1373 );
1374 }
1375
1376 let other = ":::mermaid\nflowchart LR\n\tA --> B\n:::";
1378 let other_ctx = LintContext::new(other, crate::config::MarkdownFlavor::AzureDevOps, None);
1379 assert_eq!(
1380 rule.check(&other_ctx).unwrap().len(),
1381 1,
1382 "an unlisted colon fence language must still be reported"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_tabs_in_front_matter_are_not_flagged() {
1388 let rule = MD010NoHardTabs::default();
1391 let content = "---\ntitle:\t\"Tabbed value\"\n---\n\n# Heading\n\nBody text.\n";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393 let result = rule.check(&ctx).unwrap();
1394 assert!(
1395 result.is_empty(),
1396 "tabs inside front matter must not be flagged, got: {result:?}"
1397 );
1398 }
1399
1400 fn fixed_with(spaces_per_tab: usize, code_blocks: bool, content: &str) -> String {
1401 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1402 spaces_per_tab: crate::types::PositiveUsize::from_const(spaces_per_tab),
1403 code_blocks,
1404 ..Default::default()
1405 });
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 rule.fix(&ctx).unwrap()
1408 }
1409
1410 fn fixed(content: &str) -> String {
1411 fixed_with(4, false, content)
1412 }
1413
1414 const LEADING_ROWS: &[(&str, &str)] = &[
1417 ("-\tx\n\n ## a\n", "- x\n\n ## a\n"),
1419 ("*\tx\n", "* x\n"),
1420 ("+\tx\n", "+ x\n"),
1421 ("- \tx\n", "- x\n"),
1422 ("1.\tx\n", "1. x\n"),
1423 ("1)\tx\n", "1) x\n"),
1424 ("10.\tx\n", "10. x\n"),
1425 ("100.\tx\n", "100. x\n"),
1426 ("-\t-\tx\n", "- - x\n"),
1427 ("1.\t-\tx\n", "1. - x\n"),
1428 ("- x\n\n \tcont\n", "- x\n\n cont\n"),
1430 ("- x\n\n \tcont\n", "- x\n\n cont\n"),
1431 ("- x\n\n \tcont\n", "- x\n\n cont\n"),
1432 ("- x\n\n\t cont\n", "- x\n\n cont\n"),
1433 ("- x\n\n\tcont\n", "- x\n\n cont\n"),
1434 ("- x\n\t- y\n\t\t- z\n", "- x\n - y\n - z\n"),
1435 (">\tfoo\n", "> foo\n"),
1437 ("> \tfoo\n", "> foo\n"),
1438 (">\t> foo\n", "> > foo\n"),
1439 ("-\t> foo\n", "- > foo\n"),
1440 ("> -\tx\n", "> - x\n"),
1441 ];
1442
1443 #[test]
1444 fn leading_tabs_expand_to_the_tab_stop() {
1445 for (content, expected) in LEADING_ROWS {
1446 assert_eq!(&fixed(content), expected, "{content:?}");
1447 }
1448 }
1449
1450 #[test]
1451 fn leading_tabs_ignore_spaces_per_tab() {
1452 for spaces_per_tab in [1, 2, 3, 8] {
1453 for (content, expected) in LEADING_ROWS {
1454 assert_eq!(
1455 &fixed_with(spaces_per_tab, false, content),
1456 expected,
1457 "{content:?} with spaces-per-tab = {spaces_per_tab}"
1458 );
1459 }
1460 }
1461 }
1462
1463 #[test]
1464 fn tabs_after_text_still_use_spaces_per_tab() {
1465 for (content, width) in [
1466 ("a\tb\n", 2),
1467 ("a\tb\n", 4),
1468 ("a\tb\n", 8),
1469 ("- a\tb\n", 2),
1470 ("|a\t|b|\n", 8),
1471 ] {
1472 let expected = content.replace('\t', &" ".repeat(width));
1473 assert_eq!(fixed_with(width, false, content), expected, "{content:?}");
1474 }
1475 assert_eq!(fixed_with(2, false, "-\tx\ty\n"), "- x y\n");
1477 }
1478
1479 #[test]
1480 fn a_leading_tab_message_names_the_width_that_is_written() {
1481 let rule = MD010NoHardTabs::from_config_struct(MD010Config {
1482 spaces_per_tab: crate::types::PositiveUsize::from_const(2),
1483 code_blocks: false,
1484 ..Default::default()
1485 });
1486 let ctx = LintContext::new("- x\n\n\tcont\n", crate::config::MarkdownFlavor::Standard, None);
1487 let warnings = rule.check(&ctx).unwrap();
1488 assert_eq!(warnings.len(), 1);
1489 assert_eq!(warnings[0].message, "Found leading tab, use 4 spaces instead");
1490 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, " ");
1491 }
1492
1493 #[test]
1494 fn a_tab_in_leading_code_whitespace_keeps_the_code_with_code_blocks_on() {
1495 for spaces_per_tab in [2, 8] {
1496 for (content, expected) in [
1497 ("-\t\tx\n", "- x\n"),
1498 (">\t\tfoo\n", "> foo\n"),
1499 ("para\n\n\tcode\n\t\tmore\n", "para\n\n code\n more\n"),
1500 ("para\n\n \tcode\n", "para\n\n code\n"),
1501 ("para\n\n \tcode\n", "para\n\n code\n"),
1502 ("para\n\n\t# not heading\n", "para\n\n # not heading\n"),
1503 ("```\n\tcode\n```\n", "```\n code\n```\n"),
1504 ("```\n\t```\nfoo\n", "```\n ```\nfoo\n"),
1506 ("- ```\n\tcode\n ```\n", "- ```\n code\n ```\n"),
1507 ("> ```\n>\tcode\n> ```\n", "> ```\n> code\n> ```\n"),
1508 ("1. x\n\n ```\n\tcode\n ```\n", "1. x\n\n ```\n code\n ```\n"),
1509 ] {
1510 assert_eq!(
1511 fixed_with(spaces_per_tab, true, content),
1512 expected,
1513 "{content:?} with spaces-per-tab = {spaces_per_tab}"
1514 );
1515 }
1516 }
1517 assert_eq!(fixed_with(2, true, "para\n\n a\tb\n"), "para\n\n a b\n");
1519 assert_eq!(
1520 fixed_with(8, true, "```\nfoo\tbar\n```\n"),
1521 "```\nfoo bar\n```\n"
1522 );
1523 }
1524
1525 #[test]
1526 fn code_blocks_are_left_alone_when_code_blocks_is_off() {
1527 for content in ["para\n\n\tcode\n", "```\n\tcode\n```\n", "```\nfoo\t\nbar\n```\n"] {
1528 assert_eq!(fixed(content), content);
1529 }
1530 }
1531
1532 #[test]
1533 fn a_trailing_tab_is_removed_rather_than_widened_into_a_hard_break() {
1534 for (content, expected) in [
1535 ("foo\t\nbar\n", "foo\nbar\n"),
1536 ("foo \t\nbar\n", "foo\nbar\n"),
1537 ("foo \t\nbar\n", "foo\nbar\n"),
1538 ("foo\t\t\nbar\n", "foo\nbar\n"),
1539 ("foo\t \nbar\n", "foo\nbar\n"),
1540 ("foo\t", "foo"),
1541 ("# h\t\n\ntext\n", "# h\n\ntext\n"),
1542 ("- foo\t\n bar\n", "- foo\n bar\n"),
1543 ("> foo\t\n> bar\n", "> foo\n> bar\n"),
1544 ("foo\\\t\nbar\n", "foo\\\t\nbar\n"),
1547 ("foo\n+\t\nbar\n", "foo\n+\nbar\n"),
1549 ("foo\n1.\t\nbar\n", "foo\n1.\nbar\n"),
1550 ("-\t\n x\n", "-\n x\n"),
1551 ("foo\t \nbar\n", "foo \nbar\n"),
1553 ("a\tb\t\nc\n", "a b\nc\n"),
1555 ] {
1556 assert_eq!(fixed(content), expected, "{content:?}");
1557 }
1558 }
1559
1560 #[test]
1561 fn code_text_that_looks_like_a_marker_keeps_the_configured_width() {
1562 for (content, expected) in [
1563 (
1565 "```\n-\told\n+\tnew\n>\tquoted\n1.\tx\n```\n",
1566 "```\n- old\n+ new\n> quoted\n1. x\n```\n",
1567 ),
1568 ("para\n\n a\n -\tx\n", "para\n\n a\n - x\n"),
1569 (" -\tx\n -\ty\n", " - x\n - y\n"),
1572 (" >\tx\n -\ty\n", " > x\n - y\n"),
1573 (" >\tx\n >\ty\n", " > x\n > y\n"),
1574 ("-\t\tx\n\t\ty\n", "- x\n y\n"),
1575 ("> ```\n>\t-\tx\n> ```\n", "> ```\n> - x\n> ```\n"),
1577 ("- ```\n -\tx\n ```\n", "- ```\n - x\n ```\n"),
1578 ("> ```\n> >\tx\n> ```\n", "> ```\n> > x\n> ```\n"),
1579 ] {
1580 assert_eq!(fixed_with(8, true, content), expected, "{content:?}");
1581 }
1582 }
1583
1584 fn span_rule(code_blocks: bool) -> MD010NoHardTabs {
1585 MD010NoHardTabs::from_config_struct(MD010Config {
1586 code_blocks,
1587 ..Default::default()
1588 })
1589 }
1590
1591 fn assert_span_tabs_kept(source: &str, flavor: crate::config::MarkdownFlavor) {
1595 for code_blocks in [false, true] {
1596 let rule = span_rule(code_blocks);
1597 let ctx = LintContext::new(source, flavor, None);
1598 let warnings = rule.check(&ctx).unwrap();
1599 if code_blocks {
1600 assert!(!warnings.is_empty(), "code-blocks reports the tab: {source:?}");
1601 for warning in &warnings {
1602 assert!(
1603 warning.fix.is_none(),
1604 "a code span tab has no fix: {source:?} {warning:?}"
1605 );
1606 assert_eq!(
1607 warning.message,
1608 "Found tab inside inline code, left as is because it is part of the code text"
1609 );
1610 }
1611 } else {
1612 assert!(
1613 warnings.is_empty(),
1614 "a code span tab is not reported: {source:?} {warnings:?}"
1615 );
1616 }
1617 assert_eq!(
1618 rule.fix(&ctx).unwrap(),
1619 source,
1620 "code-blocks={code_blocks} rewrote a code span: {source:?}"
1621 );
1622 }
1623 }
1624
1625 #[test]
1626 fn a_tab_inside_an_inline_code_span_is_kept_in_every_shape_a_span_can_take() {
1627 for source in [
1628 "`a\tb`\n",
1630 "`a\t\tb`\n",
1631 "text `a\tb` more\n",
1632 "`a\tb` and `c\td`\n",
1633 "`a\t\nb`\n",
1635 "`a\t\t\nb`\n",
1636 "`a \t\nb`\n",
1637 "`a\t \nb`\n",
1638 "``a\t\nb``\n",
1639 "`a\t\nmid\t\nb`\n",
1640 "`a\t\nb` and `c\t\nd`\n",
1641 "`a\t\r\nb`\r\n",
1642 "`a\tb`",
1643 "- `a\t\n b`\n",
1645 "1. `a\t\n b`\n",
1646 "- x\n - `a\t\n b`\n",
1647 "> `a\t\n> b`\n",
1648 "> > `a\t\n> > b`\n",
1649 "- > `a\t\n > b`\n",
1650 "> - `a\t\n> b`\n",
1651 "*`a\t\nb`*\n",
1653 "[`a\t\nb`](u)\n",
1654 "<span>`a\t\nb`</span>\n",
1655 "$x$ `a\t\nb`\n",
1658 "A $x `y$\t\nz` end\n",
1659 ] {
1660 assert_span_tabs_kept(source, crate::config::MarkdownFlavor::Standard);
1661 }
1662 }
1663
1664 #[test]
1665 fn leading_whitespace_of_a_span_continuation_line_is_span_text_unless_proven_otherwise() {
1666 for source in [
1670 "`a\n\tb`\n",
1671 "- `a\n\tb`\n",
1672 "- `a\n \tb`\n",
1673 "> `a\n>\tb`\n",
1674 "> `a\n> \tb`\n",
1675 "- > `a\n >\tb`\n",
1676 "> `a\n\tb`\n",
1678 "> para\n`a\n\tb`\n",
1679 "- > para\n `a\n \tb`\n",
1680 "> `a\n >\tb`\n",
1681 "- `a\n 2.\tb`\n",
1684 "`a\n2.\tb`\n",
1685 "> `a\n> 2.\tb`\n",
1686 ] {
1687 assert_span_tabs_kept(source, crate::config::MarkdownFlavor::Standard);
1688 }
1689 }
1690
1691 #[test]
1692 fn a_tab_in_a_code_span_inside_a_mkdocs_container_is_kept() {
1693 for source in ["!!! note\n\n `a\tb`\n", "!!! note\n\n `a\t\n b`\n"] {
1696 assert_span_tabs_kept(source, crate::config::MarkdownFlavor::MkDocs);
1697 }
1698 let rule = span_rule(false);
1699 let source = "!!! note\n\n `a\t\n b`\n\nouter\t\n";
1700 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MkDocs, None);
1701 assert_eq!(rule.fix(&ctx).unwrap(), "!!! note\n\n `a\t\n b`\n\nouter\n");
1702 }
1703
1704 #[test]
1705 fn a_code_span_tab_warning_covers_the_run_and_has_no_fix() {
1706 let rule = span_rule(true);
1707 let ctx = LintContext::new("`a\t\tb`\n", crate::config::MarkdownFlavor::Standard, None);
1708 let warnings = rule.check(&ctx).unwrap();
1709 assert_eq!(warnings.len(), 1, "{warnings:?}");
1710 assert_eq!(
1711 (warnings[0].line, warnings[0].column, warnings[0].end_column),
1712 (1, 3, 5)
1713 );
1714 assert!(warnings[0].fix.is_none());
1715 }
1716
1717 #[test]
1718 fn tabs_around_a_code_span_are_still_fixed() {
1719 for (source, expected) in [
1720 ("`a` x\ty\n", "`a` x y\n"),
1721 ("text\t`a` and\t`b`\n", "text `a` and `b`\n"),
1722 ("`a\nb`\tx\n", "`a\nb` x\n"),
1724 ("`a`\t\nnext\n", "`a`\nnext\n"),
1725 ("`a\t\nb\n", "`a\nb\n"),
1728 ("`a\t\n\nb`\n", "`a\n\nb`\n"),
1729 ("<code>a\t\nb</code>\n", "<code>a\nb</code>\n"),
1730 ] {
1731 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1732 assert_eq!(span_rule(false).fix(&ctx).unwrap(), expected, "{source:?}");
1733 }
1734 }
1735
1736 #[test]
1737 fn tabs_outside_a_code_span_are_fixed_while_the_tab_inside_it_is_kept() {
1738 for (source, expected) in [
1739 ("a\t`b\tc`\n", "a `b\tc`\n"),
1740 ("-\t`a\tb`\n", "- `a\tb`\n"),
1741 ("`a\t\nb` tail\t\nnext\n", "`a\t\nb` tail\nnext\n"),
1742 ] {
1743 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1744 assert_eq!(span_rule(false).fix(&ctx).unwrap(), expected, "{source:?}");
1745 }
1746 }
1747
1748 #[test]
1749 fn code_blocks_true_fixes_the_text_of_a_fence_but_not_of_a_span_on_the_same_line() {
1750 for (source, expected) in [
1753 ("```\n`a\tb`\n```\n", "```\n`a b`\n```\n"),
1754 ("```\n`a\t\nb`\n```\n", "```\n`a \nb`\n```\n"),
1755 ("a\t`b\tc`\n", "a `b\tc`\n"),
1756 ] {
1757 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1758 assert_eq!(span_rule(true).fix(&ctx).unwrap(), expected, "{source:?}");
1759 }
1760 }
1761
1762 #[test]
1763 fn fixing_around_code_spans_twice_changes_nothing_more() {
1764 for source in ["`a\t\nb` x\t\n", "a\t`b\tc`\t\nd\n", "`a\nb`\tx\n", "- `a\t\n b`\t\n"] {
1765 let once = fixed(source);
1766 assert_eq!(fixed(&once), once, "{source:?}");
1767 }
1768 }
1769
1770 #[test]
1771 fn a_trailing_tab_after_a_backslash_is_reported_without_a_fix() {
1772 let rule = MD010NoHardTabs::default();
1773 let ctx = LintContext::new("foo\\\t\nbar\n", crate::config::MarkdownFlavor::Standard, None);
1774 let warnings = rule.check(&ctx).unwrap();
1775 assert_eq!(warnings.len(), 1, "{warnings:?}");
1776 assert!(warnings[0].fix.is_none(), "{warnings:?}");
1777 }
1778
1779 #[test]
1780 fn a_trailing_tab_in_code_text_takes_the_configured_width() {
1781 assert_eq!(fixed_with(2, true, "```\nfoo\t\nbar\n```\n"), "```\nfoo \nbar\n```\n");
1782 }
1783
1784 #[test]
1785 fn a_trailing_tab_is_one_warning_covering_the_whole_run() {
1786 let rule = MD010NoHardTabs::default();
1787 let ctx = LintContext::new("foo \t\nbar\n", crate::config::MarkdownFlavor::Standard, None);
1788 let warnings = rule.check(&ctx).unwrap();
1789 assert_eq!(warnings.len(), 1, "{warnings:?}");
1790 let fix = warnings[0].fix.as_ref().unwrap();
1791 assert_eq!(fix.range, 3..6);
1792 assert_eq!(fix.replacement, "");
1793 assert_eq!(warnings[0].message, "Found trailing tab, remove it");
1794 }
1795
1796 #[test]
1797 fn a_whitespace_only_line_keeps_spaces_per_tab() {
1798 assert_eq!(fixed_with(2, false, "a\n\t\nb\n"), "a\n \nb\n");
1799 assert_eq!(fixed_with(8, false, "a\n\t\nb\n"), format!("a\n{}\nb\n", " ".repeat(8)));
1800 }
1801
1802 #[test]
1803 fn tabs_after_non_ascii_text_do_not_disturb_the_columns() {
1804 assert_eq!(fixed_with(2, false, "-\tcafé\tx\n"), "- café x\n");
1805 }
1806
1807 #[test]
1808 fn fixing_twice_changes_nothing_more() {
1809 for (content, _) in LEADING_ROWS {
1810 let once = fixed(content);
1811 assert_eq!(fixed(&once), once, "{content:?}");
1812 }
1813 for content in ["foo\t\nbar\n", "foo\\\t\nbar\n", "foo\n+\t\nbar\n", "a\tb\t\nc\n"] {
1814 let once = fixed(content);
1815 assert_eq!(fixed(&once), once, "{content:?}");
1816 }
1817 }
1818
1819 #[test]
1820 fn every_warning_replacement_matches_the_fixed_document() {
1821 let rule = MD010NoHardTabs::default();
1822 for (content, expected) in LEADING_ROWS {
1823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1824 let mut result = (*content).to_string();
1825 let mut warnings = rule.check(&ctx).unwrap();
1826 warnings.sort_by_key(|w| std::cmp::Reverse(w.fix.as_ref().unwrap().range.start));
1827 for warning in warnings {
1828 let fix = warning.fix.unwrap();
1829 result.replace_range(fix.range, &fix.replacement);
1830 }
1831 assert_eq!(&result, expected, "{content:?}");
1832 }
1833 }
1834
1835 #[test]
1836 fn footnote_label_whitespace_is_the_columns_the_tab_spans() {
1837 for (content, expected) in [
1838 ("[^1]:\tNote\n", "[^1]: Note\n"),
1839 ("[^ab]:\tNote\n", "[^ab]: Note\n"),
1840 ("[^abc]:\tNote\n", "[^abc]: Note\n"),
1841 ("[^abcd]:\tNote\n", "[^abcd]: Note\n"),
1842 ("[^abcde]:\tNote\n", "[^abcde]: Note\n"),
1843 ("[^1]:\t\tNote\n", "[^1]: Note\n"),
1844 ("[^a b]:\tNote\n", "[^a b]: Note\n"),
1845 ("[^\u{e9}\u{e9}]:\tNote\n", "[^\u{e9}\u{e9}]: Note\n"),
1846 ("> [^1]:\tNote\n", "> [^1]: Note\n"),
1847 ("[^1]:\t- item\n", "[^1]: - item\n"),
1848 ("[^1]:\n\n\tcont\n", "[^1]:\n\n cont\n"),
1849 ] {
1850 for spaces_per_tab in [2, 4, 8] {
1851 assert_eq!(
1852 fixed_with(spaces_per_tab, false, content),
1853 expected,
1854 "{content:?} at {spaces_per_tab}"
1855 );
1856 }
1857 }
1858 }
1859
1860 #[test]
1861 fn a_tab_that_is_not_after_a_footnote_label_keeps_the_configured_width() {
1862 for (content, expected) in [
1863 ("[x]:\tfoo\n", "[x]: foo\n"),
1864 ("a [^1]:\tb\n", "a [^1]: b\n"),
1865 ("[^1]\tb\n", "[^1] b\n"),
1866 ("[^]:\tb\n", "[^]: b\n"),
1867 ("[^a[b]:\tb\n", "[^a[b]: b\n"),
1868 ("[^1]:x\ty\n", "[^1]:x y\n"),
1869 ("[^a\tb]:\tc\n", "[^a b]: c\n"),
1870 ] {
1871 assert_eq!(fixed_with(2, false, content), expected, "{content:?}");
1872 }
1873 }
1874
1875 #[test]
1876 fn a_footnote_label_is_not_a_marker_in_code_text() {
1877 assert_eq!(MD010NoHardTabs::structural_prefix("[^1]:\tx", Some(0)), (0, 0));
1878 assert_eq!(MD010NoHardTabs::structural_prefix("[^1]:\tx", None), (6, 0));
1879 assert_eq!(MD010NoHardTabs::structural_prefix("[^1]:x", None), (0, 0));
1880 assert_eq!(MD010NoHardTabs::structural_prefix("[^1]:", None), (5, 0));
1881 }
1882}