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