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            },
24        }
25    }
26
27    pub const fn from_config_struct(config: MD010Config) -> Self {
28        Self { config }
29    }
30
31    fn count_leading_tabs(line: &str) -> usize {
32        let mut count = 0;
33        for c in line.chars() {
34            if c == '\t' {
35                count += 1;
36            } else {
37                break;
38            }
39        }
40        count
41    }
42
43    fn find_and_group_tabs(line: &str) -> Vec<(usize, usize)> {
44        let mut groups = Vec::new();
45        let mut current_group_start: Option<usize> = None;
46        let mut last_tab_pos = 0;
47
48        for (i, c) in line.chars().enumerate() {
49            if c == '\t' {
50                if let Some(start) = current_group_start {
51                    // We're in a group - check if this tab is consecutive
52                    if i == last_tab_pos + 1 {
53                        // Consecutive tab, continue the group
54                        last_tab_pos = i;
55                    } else {
56                        // Gap found, save current group and start new one
57                        groups.push((start, last_tab_pos + 1));
58                        current_group_start = Some(i);
59                        last_tab_pos = i;
60                    }
61                } else {
62                    // Start a new group
63                    current_group_start = Some(i);
64                    last_tab_pos = i;
65                }
66            }
67        }
68
69        // Add the last group if there is one
70        if let Some(start) = current_group_start {
71            groups.push((start, last_tab_pos + 1));
72        }
73
74        groups
75    }
76}
77
78impl Rule for MD010NoHardTabs {
79    fn name(&self) -> &'static str {
80        "MD010"
81    }
82
83    fn description(&self) -> &'static str {
84        "No tabs"
85    }
86
87    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
88        let line_index = &ctx.line_index;
89
90        let mut warnings = Vec::new();
91
92        let mut filtered = ctx
93            .filtered_lines()
94            .skip_front_matter()
95            .skip_html_comments()
96            .skip_mdx_comments()
97            .skip_html_blocks()
98            .skip_pymdown_blocks()
99            .skip_mkdocstrings()
100            .skip_esm_blocks();
101
102        if !self.config.code_blocks {
103            filtered = filtered.skip_code_blocks();
104        }
105
106        for filtered_line in filtered {
107            let line_num = filtered_line.line_num - 1;
108            let line = filtered_line.content;
109
110            // Process tabs directly without intermediate collection
111            let tab_groups = Self::find_and_group_tabs(line);
112            if tab_groups.is_empty() {
113                continue;
114            }
115
116            let leading_tabs = Self::count_leading_tabs(line);
117
118            // Generate warning for each group of consecutive tabs
119            for (start_pos, end_pos) in tab_groups {
120                let tab_count = end_pos - start_pos;
121                let is_leading = start_pos < leading_tabs;
122
123                // Calculate precise character range for the tab group
124                let (start_line, start_col, end_line, end_col) =
125                    calculate_match_range(line_num + 1, line, start_pos, tab_count);
126
127                let message = if line.trim().is_empty() {
128                    if tab_count == 1 {
129                        "Empty line contains tab".to_string()
130                    } else {
131                        format!("Empty line contains {tab_count} tabs")
132                    }
133                } else if is_leading {
134                    if tab_count == 1 {
135                        format!(
136                            "Found leading tab, use {} spaces instead",
137                            self.config.spaces_per_tab.get()
138                        )
139                    } else {
140                        format!(
141                            "Found {} leading tabs, use {} spaces instead",
142                            tab_count,
143                            tab_count * self.config.spaces_per_tab.get()
144                        )
145                    }
146                } else if tab_count == 1 {
147                    "Found tab for alignment, use spaces instead".to_string()
148                } else {
149                    format!("Found {tab_count} tabs for alignment, use spaces instead")
150                };
151
152                warnings.push(LintWarning {
153                    rule_name: Some(self.name().to_string()),
154                    line: start_line,
155                    column: start_col,
156                    end_line,
157                    end_column: end_col,
158                    message,
159                    severity: Severity::Warning,
160                    fix: Some(Fix::new(
161                        line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
162                        " ".repeat(tab_count * self.config.spaces_per_tab.get()),
163                    )),
164                });
165            }
166        }
167
168        Ok(warnings)
169    }
170
171    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
172        if self.should_skip(ctx) {
173            return Ok(ctx.content.to_string());
174        }
175        let warnings = self.check(ctx)?;
176        if warnings.is_empty() {
177            return Ok(ctx.content.to_string());
178        }
179        let warnings =
180            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
181        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
182            .map_err(crate::rule::LintError::InvalidInput)
183    }
184
185    fn as_any(&self) -> &dyn std::any::Any {
186        self
187    }
188
189    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
190        // Skip if content is empty or has no tabs
191        ctx.content.is_empty() || !ctx.has_char('\t')
192    }
193
194    fn category(&self) -> RuleCategory {
195        RuleCategory::Whitespace
196    }
197
198    crate::impl_rule_config_methods!(MD010Config);
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::lint_context::LintContext;
205    use crate::rule::Rule;
206
207    #[test]
208    fn test_no_tabs() {
209        let rule = MD010NoHardTabs::default();
210        let content = "This is a line\nAnother line\nNo tabs here";
211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
212        let result = rule.check(&ctx).unwrap();
213        assert!(result.is_empty());
214    }
215
216    #[test]
217    fn test_single_tab() {
218        let rule = MD010NoHardTabs::default();
219        let content = "Line with\ttab";
220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
221        let result = rule.check(&ctx).unwrap();
222        assert_eq!(result.len(), 1);
223        assert_eq!(result[0].line, 1);
224        assert_eq!(result[0].column, 10);
225        assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
226    }
227
228    #[test]
229    fn test_leading_tabs_skipped_in_indented_code_by_default() {
230        // Both lines start with a tab at column 0: parsed as an indented code block.
231        // Default code_blocks=false skips tabs in indented code blocks.
232        let content = "\tIndented line\n\t\tDouble indented";
233        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
234
235        let rule_off = MD010NoHardTabs::default();
236        let result_off = rule_off.check(&ctx).unwrap();
237        assert!(
238            result_off.is_empty(),
239            "indented code block skipped by default, got {result_off:?}"
240        );
241        assert_eq!(
242            rule_off.fix(&ctx).unwrap(),
243            "\tIndented line\n\t\tDouble indented",
244            "fix must preserve indented code block content"
245        );
246
247        // code_blocks=true: tabs inside indented code blocks are flagged.
248        let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
249            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
250            code_blocks: true,
251        });
252        let result_on = rule_on.check(&ctx).unwrap();
253        assert_eq!(result_on.len(), 2, "got {result_on:?}");
254        assert_eq!(result_on[0].line, 1);
255        assert_eq!(result_on[0].message, "Found leading tab, use 4 spaces instead");
256        assert_eq!(result_on[1].line, 2);
257        assert_eq!(result_on[1].message, "Found 2 leading tabs, use 8 spaces instead");
258        assert_eq!(rule_on.fix(&ctx).unwrap(), "    Indented line\n        Double indented");
259    }
260
261    #[test]
262    fn test_fix_tabs() {
263        // Line 1 starts with a tab at column 0 -> indented code block, skipped by default.
264        // Line 2 has a mid-line tab (alignment) -> flagged and fixed.
265        let content = "\tIndented\nNormal\tline\nNo tabs";
266        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
267
268        let rule_off = MD010NoHardTabs::default();
269        let warnings_off = rule_off.check(&ctx).unwrap();
270        assert_eq!(warnings_off.len(), 1, "got {warnings_off:?}");
271        assert_eq!(warnings_off[0].line, 2);
272        assert_eq!(warnings_off[0].message, "Found tab for alignment, use spaces instead");
273        assert_eq!(
274            rule_off.fix(&ctx).unwrap(),
275            "\tIndented\nNormal    line\nNo tabs",
276            "indented code block line preserved; alignment tab fixed"
277        );
278
279        // code_blocks=true: line 1 is also flagged.
280        let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
281            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
282            code_blocks: true,
283        });
284        let warnings_on = rule_on.check(&ctx).unwrap();
285        assert_eq!(warnings_on.len(), 2, "got {warnings_on:?}");
286        assert_eq!(warnings_on[0].line, 1);
287        assert_eq!(warnings_on[1].line, 2);
288        assert_eq!(rule_on.fix(&ctx).unwrap(), "    Indented\nNormal    line\nNo tabs");
289    }
290
291    #[test]
292    fn test_custom_spaces_per_tab() {
293        // Single tab at column 0 -> indented code block, skipped by default.
294        let content = "\tIndented";
295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
296
297        let rule_off = MD010NoHardTabs::new(4);
298        assert!(
299            rule_off.check(&ctx).unwrap().is_empty(),
300            "indented code block skipped by default"
301        );
302        assert_eq!(
303            rule_off.fix(&ctx).unwrap(),
304            "\tIndented",
305            "indented code block preserved by default"
306        );
307
308        // code_blocks=true: tab is flagged and fixed.
309        let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
310            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
311            code_blocks: true,
312        });
313        assert_eq!(rule_on.check(&ctx).unwrap().len(), 1);
314        assert_eq!(rule_on.fix(&ctx).unwrap(), "    Indented");
315    }
316
317    #[test]
318    fn test_fenced_code_block_tabs_skipped_by_default() {
319        let rule = MD010NoHardTabs::default();
320        let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
321        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
322        let result = rule.check(&ctx).unwrap();
323        // By default (code_blocks=false) tabs inside code blocks are skipped
324        assert_eq!(result.len(), 2);
325        assert_eq!(result[0].line, 1);
326        assert_eq!(result[1].line, 5);
327
328        let fixed = rule.fix(&ctx).unwrap();
329        assert_eq!(fixed, "Normal    line\n```\nCode\twith\ttab\n```\nAnother    line");
330    }
331
332    #[test]
333    fn test_fenced_only_content_skipped_by_default() {
334        let rule = MD010NoHardTabs::default();
335        let content = "```\nCode\twith\ttab\n```";
336        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337        let result = rule.check(&ctx).unwrap();
338        // By default (code_blocks=false) tabs in fenced code blocks are skipped
339        // (e.g., Makefiles require tabs, Go uses tabs by convention)
340        assert_eq!(result.len(), 0);
341    }
342
343    #[test]
344    fn test_html_comments_ignored() {
345        let rule = MD010NoHardTabs::default();
346        let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
348        let result = rule.check(&ctx).unwrap();
349        // Should not flag tabs in HTML comments
350        assert_eq!(result.len(), 2);
351        assert_eq!(result[0].line, 1);
352        assert_eq!(result[1].line, 3);
353    }
354
355    #[test]
356    fn test_multiline_html_comments() {
357        let rule = MD010NoHardTabs::default();
358        let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360        let result = rule.check(&ctx).unwrap();
361        // Should only flag the tab after the comment
362        assert_eq!(result.len(), 1);
363        assert_eq!(result[0].line, 5);
364    }
365
366    #[test]
367    fn test_empty_lines_with_tabs() {
368        let rule = MD010NoHardTabs::default();
369        let content = "Normal line\n\t\t\n\t\nAnother line";
370        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371        let result = rule.check(&ctx).unwrap();
372        assert_eq!(result.len(), 2);
373        assert_eq!(result[0].message, "Empty line contains 2 tabs");
374        assert_eq!(result[1].message, "Empty line contains tab");
375    }
376
377    #[test]
378    fn test_mixed_tabs_and_spaces() {
379        // " \t..." (space then tab) and "\t ..." (tab then space): both parsed as
380        // indented code blocks by the shared spec-compliant flag.
381        // Default code_blocks=false skips them.
382        let content = " \tMixed indentation\n\t Mixed again";
383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384
385        let rule_off = MD010NoHardTabs::default();
386        let result_off = rule_off.check(&ctx).unwrap();
387        assert!(
388            result_off.is_empty(),
389            "indented code block lines skipped, got {result_off:?}"
390        );
391        assert_eq!(
392            rule_off.fix(&ctx).unwrap(),
393            " \tMixed indentation\n\t Mixed again",
394            "content preserved unchanged"
395        );
396
397        // code_blocks=true: both lines flagged.
398        let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
399            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
400            code_blocks: true,
401        });
402        let result_on = rule_on.check(&ctx).unwrap();
403        assert_eq!(result_on.len(), 2, "got {result_on:?}");
404        assert_eq!(rule_on.fix(&ctx).unwrap(), "     Mixed indentation\n     Mixed again");
405    }
406
407    #[test]
408    fn test_consecutive_tabs() {
409        let rule = MD010NoHardTabs::default();
410        let content = "Text\t\t\tthree tabs\tand\tanother";
411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412        let result = rule.check(&ctx).unwrap();
413        // Should group consecutive tabs
414        assert_eq!(result.len(), 3);
415        assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
416    }
417
418    #[test]
419    fn test_find_and_group_tabs() {
420        // Test finding and grouping tabs in one pass
421        let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
422        assert_eq!(groups, vec![(1, 2), (3, 4)]);
423
424        let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
425        assert_eq!(groups, vec![(0, 2)]);
426
427        let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
428        assert!(groups.is_empty());
429
430        // Test with consecutive and non-consecutive tabs
431        let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
432        assert_eq!(groups, vec![(0, 3), (4, 6)]);
433
434        let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
435        assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
436    }
437
438    #[test]
439    fn test_count_leading_tabs() {
440        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
441        assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
442        assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
443        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
444    }
445
446    #[test]
447    fn test_default_config() {
448        let rule = MD010NoHardTabs::default();
449        let config = rule.default_config_section();
450        assert!(config.is_some());
451        let (name, _value) = config.unwrap();
452        assert_eq!(name, "MD010");
453    }
454
455    #[test]
456    fn test_from_config() {
457        // "\tTab" at column 0 -> indented code block, skipped by default (code_blocks=false).
458        let content_plain = "\tTab";
459        let ctx_plain = LintContext::new(content_plain, crate::config::MarkdownFlavor::Standard, None);
460        let rule_8_off = MD010NoHardTabs::new(8); // spaces_per_tab=8, code_blocks=false
461        assert!(
462            rule_8_off.check(&ctx_plain).unwrap().is_empty(),
463            "indented code block skipped"
464        );
465        assert_eq!(
466            rule_8_off.fix(&ctx_plain).unwrap(),
467            "\tTab",
468            "content preserved unchanged"
469        );
470
471        // code_blocks=true: the tab is flagged and replaced with 8 spaces.
472        let rule_8_on = MD010NoHardTabs::from_config_struct(MD010Config {
473            spaces_per_tab: crate::types::PositiveUsize::from_const(8),
474            code_blocks: true,
475        });
476        assert_eq!(rule_8_on.check(&ctx_plain).unwrap().len(), 1);
477        assert_eq!(rule_8_on.fix(&ctx_plain).unwrap(), "        Tab");
478
479        // Fenced code block: tab skipped by default.
480        let content_fenced = "```\n\tTab in code\n```";
481        let ctx_fenced = LintContext::new(content_fenced, crate::config::MarkdownFlavor::Standard, None);
482        assert!(
483            rule_8_off.check(&ctx_fenced).unwrap().is_empty(),
484            "fenced code block skipped"
485        );
486        assert_eq!(rule_8_off.fix(&ctx_fenced).unwrap(), "```\n\tTab in code\n```");
487
488        // code_blocks=true: tab inside fence is flagged.
489        let result_on = rule_8_on.check(&ctx_fenced).unwrap();
490        assert_eq!(result_on.len(), 1, "got {result_on:?}");
491        assert_eq!(result_on[0].line, 2);
492        assert_eq!(rule_8_on.fix(&ctx_fenced).unwrap(), "```\n        Tab in code\n```");
493    }
494
495    #[test]
496    fn test_performance_large_document() {
497        let rule = MD010NoHardTabs::default();
498        let mut content = String::new();
499        for i in 0..1000 {
500            content.push_str(&format!("Line {i}\twith\ttabs\n"));
501        }
502        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
503        let result = rule.check(&ctx).unwrap();
504        assert_eq!(result.len(), 2000);
505    }
506
507    #[test]
508    fn test_preserve_content() {
509        let rule = MD010NoHardTabs::default();
510        let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512        let fixed = rule.fix(&ctx).unwrap();
513        assert_eq!(fixed, "**Bold**    text\n*Italic*    text\n[Link](url)    tab");
514    }
515
516    #[test]
517    fn test_edge_cases() {
518        let rule = MD010NoHardTabs::default();
519
520        // Tab at end of line
521        let content = "Text\t";
522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523        let result = rule.check(&ctx).unwrap();
524        assert_eq!(result.len(), 1);
525
526        // Only tabs
527        let content = "\t\t\t";
528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529        let result = rule.check(&ctx).unwrap();
530        assert_eq!(result.len(), 1);
531        assert_eq!(result[0].message, "Empty line contains 3 tabs");
532    }
533
534    #[test]
535    fn test_fenced_code_block_tabs_preserved_in_fix_by_default() {
536        let rule = MD010NoHardTabs::default();
537
538        let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540        let fixed = rule.fix(&ctx).unwrap();
541
542        // By default (code_blocks=false) tabs in fenced code blocks are preserved
543        // (e.g., Makefiles require tabs, Go uses tabs by convention)
544        let expected = "Text    with    tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore    tabs";
545        assert_eq!(fixed, expected);
546    }
547
548    #[test]
549    fn test_tilde_fence_longer_than_3() {
550        let rule = MD010NoHardTabs::default();
551        // 5-tilde fenced code block should be recognized and tabs inside should be skipped
552        let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let result = rule.check(&ctx).unwrap();
555        // Only tabs on line 4 (outside the code block) should be flagged
556        assert_eq!(
557            result.len(),
558            2,
559            "Expected 2 warnings but got {}: {:?}",
560            result.len(),
561            result
562        );
563        assert_eq!(result[0].line, 4);
564        assert_eq!(result[1].line, 4);
565    }
566
567    #[test]
568    fn test_backtick_fence_longer_than_3() {
569        let rule = MD010NoHardTabs::default();
570        // 5-backtick fenced code block
571        let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573        let result = rule.check(&ctx).unwrap();
574        assert_eq!(
575            result.len(),
576            2,
577            "Expected 2 warnings but got {}: {:?}",
578            result.len(),
579            result
580        );
581        assert_eq!(result[0].line, 4);
582        assert_eq!(result[1].line, 4);
583    }
584
585    #[test]
586    fn test_indented_code_block_tabs_skipped_by_default() {
587        // "    code\twith\ttab" is indented with 4 spaces -> indented code block.
588        // Default code_blocks=false skips it; only the tab on the normal line is flagged.
589        let content = "    code\twith\ttab\n\nNormal\ttext";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591
592        let rule_off = MD010NoHardTabs::default();
593        let result_off = rule_off.check(&ctx).unwrap();
594        assert_eq!(
595            result_off.len(),
596            1,
597            "expected 1 warning (only normal-text tab), got {}: {:?}",
598            result_off.len(),
599            result_off
600        );
601        assert_eq!(result_off[0].line, 3);
602        assert_eq!(result_off[0].message, "Found tab for alignment, use spaces instead");
603
604        // code_blocks=true: all 3 tabs flagged (2 on line 1, 1 on line 3).
605        let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
606            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
607            code_blocks: true,
608        });
609        let result_on = rule_on.check(&ctx).unwrap();
610        assert_eq!(
611            result_on.len(),
612            3,
613            "expected 3 warnings with code_blocks=true, got {}: {:?}",
614            result_on.len(),
615            result_on
616        );
617        assert_eq!(result_on[0].line, 1);
618        assert_eq!(result_on[1].line, 1);
619        assert_eq!(result_on[2].line, 3);
620    }
621
622    #[test]
623    fn test_html_comment_end_then_start_same_line() {
624        let rule = MD010NoHardTabs::default();
625        // Tabs inside consecutive HTML comments should not be flagged
626        let content =
627            "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629        let result = rule.check(&ctx).unwrap();
630        assert!(
631            result.is_empty(),
632            "Expected 0 warnings but got {}: {:?}",
633            result.len(),
634            result
635        );
636    }
637
638    #[test]
639    fn test_fix_tilde_fence_longer_than_3() {
640        let rule = MD010NoHardTabs::default();
641        let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let fixed = rule.fix(&ctx).unwrap();
644        // Tabs inside code block preserved, tabs outside replaced
645        assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext    with    tab");
646    }
647
648    #[test]
649    fn test_fix_indented_code_block_tabs_replaced() {
650        // Default code_blocks=false: indented code block tabs preserved, normal-text tab fixed.
651        let content = "    code\twith\ttab\n\nNormal\ttext";
652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653
654        let rule_off = MD010NoHardTabs::default();
655        assert_eq!(
656            rule_off.fix(&ctx).unwrap(),
657            "    code\twith\ttab\n\nNormal    text",
658            "indented code block preserved; only normal-text tab fixed"
659        );
660
661        // code_blocks=true: all tabs replaced including those in the indented code block.
662        let rule_on = MD010NoHardTabs::from_config_struct(MD010Config {
663            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
664            code_blocks: true,
665        });
666        assert_eq!(
667            rule_on.fix(&ctx).unwrap(),
668            "    code    with    tab\n\nNormal    text",
669            "all tabs replaced with code_blocks=true"
670        );
671    }
672
673    #[test]
674    fn test_issue_630_default_skips_both_code_blocks() {
675        // Default code_blocks = false: tabs skipped in BOTH block types.
676        let rule = MD010NoHardTabs::default();
677        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";
678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679        let result = rule.check(&ctx).unwrap();
680        assert!(result.is_empty(), "both code blocks skipped, got {result:?}");
681    }
682
683    #[test]
684    fn test_issue_630_code_blocks_true_flags_both() {
685        // code_blocks = true: tabs flagged in BOTH block types.
686        let rule = MD010NoHardTabs::from_config_struct(MD010Config {
687            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
688            code_blocks: true,
689        });
690        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";
691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692        let result = rule.check(&ctx).unwrap();
693        // Line 4 "    \tfoo()": one alignment tab group inside the indented block.
694        // Line 11 "\tfoo()": one leading tab group inside the fenced block.
695        assert_eq!(result.len(), 2, "got {result:?}");
696        assert_eq!(result[0].line, 4);
697        assert_eq!(result[1].line, 11);
698    }
699
700    #[test]
701    fn test_code_blocks_toggle_fenced() {
702        let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
703
704        // Default false: only the two tab groups outside the fence.
705        let off = MD010NoHardTabs::default();
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let r_off = off.check(&ctx).unwrap();
708        assert_eq!(r_off.len(), 2, "got {r_off:?}");
709        assert_eq!(r_off[0].line, 1);
710        assert_eq!(r_off[1].line, 5);
711        assert_eq!(
712            off.fix(&ctx).unwrap(),
713            "Normal    line\n```\nCode\twith\ttab\n```\nAnother    line"
714        );
715
716        // true: also the two groups on the fenced content line.
717        let on = MD010NoHardTabs::from_config_struct(MD010Config {
718            spaces_per_tab: crate::types::PositiveUsize::from_const(4),
719            code_blocks: true,
720        });
721        let r_on = on.check(&ctx).unwrap();
722        assert_eq!(r_on.len(), 4, "got {r_on:?}");
723        assert_eq!(r_on[0].line, 1);
724        assert_eq!(r_on[1].line, 3);
725        assert_eq!(r_on[2].line, 3);
726        assert_eq!(r_on[3].line, 5);
727        assert_eq!(
728            on.fix(&ctx).unwrap(),
729            "Normal    line\n```\nCode    with    tab\n```\nAnother    line"
730        );
731    }
732
733    #[test]
734    fn test_code_blocks_toggle_makefile_fence_preserved_by_default() {
735        let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n```\nMore\ttabs";
736        let off = MD010NoHardTabs::default();
737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738        // Default preserves the Makefile recipe tab; only prose tabs fixed.
739        assert_eq!(
740            off.fix(&ctx).unwrap(),
741            "Text    with    tab\n```makefile\ntarget:\n\tcommand\n```\nMore    tabs"
742        );
743    }
744
745    #[test]
746    fn test_tabs_in_front_matter_are_not_flagged() {
747        // Hard tabs inside YAML front matter are metadata, not Markdown body,
748        // and must not be reported.
749        let rule = MD010NoHardTabs::default();
750        let content = "---\ntitle:\t\"Tabbed value\"\n---\n\n# Heading\n\nBody text.\n";
751        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752        let result = rule.check(&ctx).unwrap();
753        assert!(
754            result.is_empty(),
755            "tabs inside front matter must not be flagged, got: {result:?}"
756        );
757    }
758}