Skip to main content

rumdl_lib/rules/
md010_no_hard_tabs.rs

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