Skip to main content

rumdl_lib/rules/
md010_no_hard_tabs.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3/// Rule MD010: No tabs
4///
5/// See [docs/md010.md](../../docs/md010.md) for full documentation, configuration, and examples.
6use crate::utils::range_utils::calculate_match_range;
7
8pub mod md010_config;
9pub use md010_config::MD010Config;
10
11/// Rule MD010: Hard tabs
12#[derive(Clone, Default)]
13pub struct MD010NoHardTabs {
14    config: MD010Config,
15}
16
17/// What the structure around a code block's text line fixes about its tabs.
18struct CodeLine {
19    /// Blockquote markers that belong to the containers around the block.
20    quotes: usize,
21    /// For the first line of an indented block, the byte where its code begins.
22    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    /// Byte offset where a line's structural prefix ends, and how many blockquote
41    /// markers it holds.
42    ///
43    /// The prefix is the run of indentation, blockquote markers (`>`) and list
44    /// markers (`-`, `*`, `+`, or up to nine digits followed by `.` or `)`, when
45    /// followed by whitespace or the end of the line) that opens the line. Its
46    /// whitespace decides where list content starts, how deep a quote is, and
47    /// whether a line is indented code. A whitespace-only line has no structure
48    /// and returns 0.
49    ///
50    /// `code_quotes` is set for the text of a code block. Its markers belong to
51    /// the code unless they are the containers' own, so list markers are never
52    /// read and at most that many `>` markers are.
53    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    /// Length of a footnote definition label (`[^id]:`) at the start of `text`.
92    ///
93    /// The label may hold spaces and non-ASCII characters but no brackets or
94    /// tabs, and must be non-empty. Like a list marker it counts only when
95    /// whitespace or the end of the line follows, and that whitespace decides
96    /// whether the footnote's first block is a paragraph or indented code.
97    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    /// For the text lines of a code block, the number of blockquote markers that
104    /// belong to the containers around it, and for the first line of an indented
105    /// block, where its code text begins.
106    ///
107    /// A fence's opener is Markdown and is not listed. The first line of an
108    /// indented block is listed because it can open the containers with markers
109    /// that share the line with code: its code begins four columns past the
110    /// enclosing container's content column, and everything before that is
111    /// structure.
112    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    /// Byte offset of the first position on `line` at or past visual `column`,
148    /// with a tab advancing to the next multiple of four. The line length when
149    /// it ends first.
150    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    /// Columns the whitespace in `line[start..end]` spans when the line starts at
162    /// column 0 and a tab advances to the next multiple of four. Every character
163    /// is one column, which holds inside the structural prefix (a footnote label
164    /// may hold wide characters, but they are counted per character).
165    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    /// Start of the trailing run of spaces and tabs when it must be removed.
178    ///
179    /// A tab followed by at most one space is a soft line break, but the same run
180    /// written as four spaces is a hard break. A run that already ends in two
181    /// spaces is a hard break and is left to the ordinary conversion.
182    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    /// The language an info string declares, or `""` when it declares none.
189    ///
190    /// Normally that is the first whitespace-separated word, so
191    /// ```` ```makefile title="Makefile" ```` gives `makefile`. Two flavors write
192    /// a language inside braces instead, and each is read only where it is real
193    /// syntax: a Pandoc code-attribute block names it as the first `.class`
194    /// (`{#id .makefile}` gives `makefile`), and a Quarto executable chunk names
195    /// its engine (`{r, echo=FALSE}` gives `r`).
196    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    /// Lines (1-indexed) belonging to a code block whose language is listed in
216    /// `ignore-code-languages`.
217    ///
218    /// Covers CommonMark fences plus the Azure DevOps colon fences the parser
219    /// never sees. An indented code block has no info string and can never match.
220    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    /// Runs of consecutive tabs as `(start, end)` byte offsets into `line`.
253    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                    // We're in a group - check if this tab is consecutive
262                    if i == last_tab_pos + 1 {
263                        // Consecutive tab, continue the group
264                        last_tab_pos = i;
265                    } else {
266                        // Gap found, save current group and start new one
267                        groups.push((start, last_tab_pos + 1));
268                        current_group_start = Some(i);
269                        last_tab_pos = i;
270                    }
271                } else {
272                    // Start a new group
273                    current_group_start = Some(i);
274                    last_tab_pos = i;
275                }
276            }
277        }
278
279        // Add the last group if there is one
280        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        // Testing code-blocks here only avoids building a set that cannot matter:
319        // with it false the walk has already dropped every code block line.
320        // Only a code block that is checked has text whose prefix needs telling
321        // apart from its containers' own.
322        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            // Process tabs directly without intermediate collection
342            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            // Outside code, a trailing run holding a tab is removed whole, so it
357            // cannot turn into a hard break.
358            // Inside code, a tab is text and keeps the configured width.
359            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            // Generate warning for each group of consecutive tabs
371            for (start_pos, end_pos) in tab_groups {
372                let is_trailing_run = trailing_start == Some(start_pos);
373                // Tabs in executable/template code can be string data, rather
374                // than Markdown alignment. Keep these values and attributes
375                // intact while checking prose beside them on the same line.
376                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                // A tab between a code span's backticks is part of the span's text,
389                // and a renderer shows it. That includes the leading whitespace of a
390                // line that continues the span: a line that matches all its
391                // containers has it stripped, a lazy one keeps it, and nothing here
392                // tells them apart. Like a fenced block, a span is skipped unless
393                // `code-blocks` asks for every tab, and then its tab is reported
394                // without a fix. A group is judged by its first byte: a span's
395                // boundaries are backticks, so a run of tabs or of trailing
396                // whitespace cannot start before a span and end inside it.
397                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                // Whitespace inside the structural prefix is written as the columns
419                // the tabs span, because that is what the parser reads from it. Any
420                // other tab is alignment and uses the configured width.
421                //
422                // A backslash before a trailing run is a literal backslash while the
423                // run is there and a hard break once it is gone, and the one space
424                // that would keep it literal is itself trailing whitespace. That
425                // warning has no fix.
426                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                // Calculate precise character range for the tab group
435                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        // Skip if content is empty or has no tabs
506        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        // Both lines start with a tab at column 0: parsed as an indented code block.
614        // Default code_blocks=false skips tabs in indented code blocks.
615        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        // code_blocks=true: tabs inside indented code blocks are flagged.
631        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        // Line 1 starts with a tab at column 0 -> indented code block, skipped by default.
648        // Line 2 has a mid-line tab (alignment) -> flagged and fixed.
649        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        // code_blocks=true: line 1 is also flagged.
664        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        // Single tab at column 0 -> indented code block, skipped by default.
679        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        // code_blocks=true: tab is flagged and fixed.
694        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        // By default (code_blocks=false) tabs inside code blocks are skipped
710        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        // By default (code_blocks=false) tabs in fenced code blocks are skipped
725        // (e.g., Makefiles require tabs, Go uses tabs by convention)
726        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        // Should not flag tabs in HTML comments
736        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        // Should only flag the tab after the comment
748        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        // " \t..." (space then tab) and "\t ..." (tab then space): both parsed as
766        // indented code blocks by the shared spec-compliant flag.
767        // Default code_blocks=false skips them.
768        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        // code_blocks=true: both lines flagged.
784        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        // Both indentation tabs reach column four, which is what the parser reads.
792        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        // Should group consecutive tabs
802        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        // Test finding and grouping tabs in one pass
809        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        // Test with consecutive and non-consecutive tabs
819        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        // In code text only the containers' own quote markers are structure.
858        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        // "\tTab" at column 0 -> indented code block, skipped by default (code_blocks=false).
877        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); // spaces_per_tab=8, code_blocks=false
880        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        // code_blocks=true: the leading tab is flagged and replaced with the four
891        // columns it spans, whatever spaces_per_tab is.
892        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        // Fenced code block: tab skipped by default.
901        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        // code_blocks=true: tab inside fence is flagged.
910        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        // Tab at end of line
942        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        // Only tabs
948        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        // By default (code_blocks=false) tabs in fenced code blocks are preserved
964        // (e.g., Makefiles require tabs, Go uses tabs by convention)
965        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        // 5-tilde fenced code block should be recognized and tabs inside should be skipped
973        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        // Only tabs on line 4 (outside the code block) should be flagged
977        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        // 5-backtick fenced code block
992        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        // "    code\twith\ttab" is indented with 4 spaces -> indented code block.
1009        // Default code_blocks=false skips it; only the tab on the normal line is flagged.
1010        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        // code_blocks=true: all 3 tabs flagged (2 on line 1, 1 on line 3).
1026        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        // Tabs inside consecutive HTML comments should not be flagged
1048        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        // Tabs inside code block preserved, tabs outside replaced
1067        assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext    with    tab");
1068    }
1069
1070    #[test]
1071    fn test_fix_indented_code_block_tabs_replaced() {
1072        // Default code_blocks=false: indented code block tabs preserved, normal-text tab fixed.
1073        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        // code_blocks=true: all tabs replaced including those in the indented code block.
1084        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        // Default code_blocks = false: tabs skipped in BOTH block types.
1099        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        // code_blocks = true: tabs flagged in BOTH block types.
1109        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        // Line 4 "    \tfoo()": one alignment tab group inside the indented block.
1118        // Line 11 "\tfoo()": one leading tab group inside the fenced block.
1119        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        // Default false: only the two tab groups outside the fence.
1129        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        // true: also the two groups on the fenced content line.
1141        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        // Default preserves the Makefile recipe tab; only prose tabs fixed.
1164        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        // code-blocks = true opts into checking tabs inside fences, but a tab in a
1173        // Makefile recipe is required syntax rather than a formatting mistake.
1174        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        // ```Makefile and ```makefile name the same language.
1199        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        // The label is the first word, so attributes after it do not defeat the match.
1217        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        // A different language on the same shape is still reported, so the match is
1232        // not simply accepting every labelled fence.
1233        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        // An indented code block has no info string, so it has no language to list.
1245        // Documented in docs/md010.md as a real gap.
1246        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        // With code-blocks at its default the whole block is already skipped, so the
1265        // list changes nothing in either direction.
1266        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        // Pandoc declares a fence's language as the first `.class` of its attribute
1283        // block, so ```{.makefile} names the same language as ```makefile.
1284        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        // An unlisted class on the same shape is still reported.
1299        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        // Under `standard` braces are not attribute syntax, so the label stays raw
1311        // and cannot match a bare language name.
1312        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        // A Quarto executable chunk names its engine inside the braces.
1331        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        // A chunk for a different engine is still reported.
1346        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        // Azure DevOps colon fences carry their language on the opener.
1358        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        // An unlisted colon fence language is still reported.
1377        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        // Hard tabs inside YAML front matter are metadata, not Markdown body,
1389        // and must not be reported.
1390        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    /// Leading whitespace: tabs expand to the columns CommonMark gives them, which
1415    /// depend on where they start, so the document keeps its structure.
1416    const LEADING_ROWS: &[(&str, &str)] = &[
1417        // After a list marker the tab reaches the next multiple of four.
1418        ("-\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        // Space-tab mixes all reach column four.
1429        ("- 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        // After a blockquote marker.
1436        (">\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        // The tab after the marker is leading, the one inside the text is not.
1476        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                // A tab line that is not a closing fence stays inside the fence.
1505                ("```\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        // A tab inside a code line is code text and takes the configured width.
1518        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            // A backslash before the run would become a hard break once the run is gone,
1545            // so that tab is reported and left alone.
1546            ("foo\\\t\nbar\n", "foo\\\t\nbar\n"),
1547            // An empty `+` or `1.` cannot interrupt a paragraph, so it is text.
1548            ("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            // Two or more trailing spaces were already a hard break; it is kept.
1552            ("foo\t  \nbar\n", "foo      \nbar\n"),
1553            // Tabs before the trailing run are still alignment.
1554            ("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            // Diff lines and shell prompts in a fence are text.
1564            (
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            // The first line of an indented block is code text as well, unless a
1570            // marker on it opens the container the code sits in.
1571            ("    -\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            // The containers' own markers still decide the columns.
1576            ("> ```\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    /// A tab between a code span's backticks is part of its text, so no setting
1592    /// of the rule may rewrite it. By default it is not reported, as in a fence.
1593    /// With `code-blocks = true` it is reported, without a fix.
1594    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            // Inside one line.
1629            "`a\tb`\n",
1630            "`a\t\tb`\n",
1631            "text `a\tb` more\n",
1632            "`a\tb` and `c\td`\n",
1633            // Ending a line inside a multi-line span: a tab, tabs, mixed with spaces.
1634            "`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            // Containers keep their prefix on the continuation line.
1644            "- `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            // Spans inside other inline markup.
1652            "*`a\t\nb`*\n",
1653            "[`a\t\nb`](u)\n",
1654            "<span>`a\t\nb`</span>\n",
1655            // Math: a backtick inside `$...$` opens no span for rumdl but does for
1656            // a reader without math, and the text must hold under both.
1657            "$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        // A continuation line that matches all its containers has its leading
1667        // whitespace stripped, but a lazy one keeps it as part of the span, and
1668        // the rule cannot tell the two apart, so every such tab is left alone.
1669        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            // Lazy lines: the whitespace is part of the code text.
1677            "> `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 marker-looking token on a continuation line is text, and so is the
1682            // tab after it.
1683            "- `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        // pulldown-cmark reads the indented admonition body as indented code, so
1694        // the span is only known through the container supplement.
1695        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 tab on the line that closes the span, after the closing backtick.
1723            ("`a\nb`\tx\n", "`a\nb`    x\n"),
1724            ("`a`\t\nnext\n", "`a`\nnext\n"),
1725            // Not a code span: an unclosed backtick, a span across a blank line,
1726            // and raw HTML.
1727            ("`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        // Backticks inside a fence are code text, not a span: the opt-in still
1751        // rewrites them. Beside a real span on a prose line only the span is kept.
1752        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}