Skip to main content

rumdl_lib/rules/
md010_no_hard_tabs.rs

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