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