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
8mod md010_config;
9use 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            },
23        }
24    }
25
26    pub const fn from_config_struct(config: MD010Config) -> Self {
27        Self { config }
28    }
29
30    /// Detect which lines are inside fenced code blocks (``` or ~~~).
31    /// Only fenced code blocks are skipped — indented code blocks (4+ spaces / tab)
32    /// are NOT skipped because the tabs themselves are what MD010 should flag.
33    fn find_fenced_code_block_lines(lines: &[&str]) -> Vec<bool> {
34        let mut in_fenced_block = false;
35        let mut fence_char: Option<char> = None;
36        let mut fence_len: usize = 0;
37        let mut result = vec![false; lines.len()];
38
39        for (i, line) in lines.iter().enumerate() {
40            let trimmed = line.trim_start();
41
42            if !in_fenced_block {
43                // Check for opening fence (3+ backticks or tildes)
44                let first_char = trimmed.chars().next();
45                if matches!(first_char, Some('`') | Some('~')) {
46                    let fc = first_char.unwrap();
47                    let count = trimmed.chars().take_while(|&c| c == fc).count();
48                    if count >= 3 {
49                        in_fenced_block = true;
50                        fence_char = Some(fc);
51                        fence_len = count;
52                        result[i] = true;
53                    }
54                }
55            } else {
56                result[i] = true;
57                // Check for closing fence (must match opening fence char and be >= opening length)
58                if let Some(fc) = fence_char {
59                    let first = trimmed.chars().next();
60                    if first == Some(fc) {
61                        let count = trimmed.chars().take_while(|&c| c == fc).count();
62                        // Closing fence must be at least as long as opening, with nothing else on the line
63                        if count >= fence_len && trimmed[count..].trim().is_empty() {
64                            in_fenced_block = false;
65                            fence_char = None;
66                            fence_len = 0;
67                        }
68                    }
69                }
70            }
71        }
72
73        result
74    }
75
76    fn count_leading_tabs(line: &str) -> usize {
77        let mut count = 0;
78        for c in line.chars() {
79            if c == '\t' {
80                count += 1;
81            } else {
82                break;
83            }
84        }
85        count
86    }
87
88    fn find_and_group_tabs(line: &str) -> Vec<(usize, usize)> {
89        let mut groups = Vec::new();
90        let mut current_group_start: Option<usize> = None;
91        let mut last_tab_pos = 0;
92
93        for (i, c) in line.chars().enumerate() {
94            if c == '\t' {
95                if let Some(start) = current_group_start {
96                    // We're in a group - check if this tab is consecutive
97                    if i == last_tab_pos + 1 {
98                        // Consecutive tab, continue the group
99                        last_tab_pos = i;
100                    } else {
101                        // Gap found, save current group and start new one
102                        groups.push((start, last_tab_pos + 1));
103                        current_group_start = Some(i);
104                        last_tab_pos = i;
105                    }
106                } else {
107                    // Start a new group
108                    current_group_start = Some(i);
109                    last_tab_pos = i;
110                }
111            }
112        }
113
114        // Add the last group if there is one
115        if let Some(start) = current_group_start {
116            groups.push((start, last_tab_pos + 1));
117        }
118
119        groups
120    }
121}
122
123impl Rule for MD010NoHardTabs {
124    fn name(&self) -> &'static str {
125        "MD010"
126    }
127
128    fn description(&self) -> &'static str {
129        "No tabs"
130    }
131
132    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
133        let line_index = &ctx.line_index;
134
135        let mut warnings = Vec::new();
136        let lines = ctx.raw_lines();
137
138        // Track fenced code blocks separately — we skip FENCED blocks but NOT
139        // indented code blocks (since tab indentation IS what MD010 should flag)
140        let fenced_lines = Self::find_fenced_code_block_lines(lines);
141
142        for (line_num, &line) in lines.iter().enumerate() {
143            // Skip fenced code blocks (code has its own formatting rules)
144            if fenced_lines[line_num] {
145                continue;
146            }
147
148            // Skip HTML comments, HTML blocks, PyMdown blocks, mkdocstrings, ESM blocks
149            if ctx.line_info(line_num + 1).is_some_and(|info| {
150                info.in_html_comment
151                    || info.in_mdx_comment
152                    || info.in_html_block
153                    || info.in_pymdown_block
154                    || info.in_mkdocstrings
155                    || info.in_esm_block
156            }) {
157                continue;
158            }
159
160            // Process tabs directly without intermediate collection
161            let tab_groups = Self::find_and_group_tabs(line);
162            if tab_groups.is_empty() {
163                continue;
164            }
165
166            let leading_tabs = Self::count_leading_tabs(line);
167
168            // Generate warning for each group of consecutive tabs
169            for (start_pos, end_pos) in tab_groups {
170                let tab_count = end_pos - start_pos;
171                let is_leading = start_pos < leading_tabs;
172
173                // Calculate precise character range for the tab group
174                let (start_line, start_col, end_line, end_col) =
175                    calculate_match_range(line_num + 1, line, start_pos, tab_count);
176
177                let message = if line.trim().is_empty() {
178                    if tab_count == 1 {
179                        "Empty line contains tab".to_string()
180                    } else {
181                        format!("Empty line contains {tab_count} tabs")
182                    }
183                } else if is_leading {
184                    if tab_count == 1 {
185                        format!(
186                            "Found leading tab, use {} spaces instead",
187                            self.config.spaces_per_tab.get()
188                        )
189                    } else {
190                        format!(
191                            "Found {} leading tabs, use {} spaces instead",
192                            tab_count,
193                            tab_count * self.config.spaces_per_tab.get()
194                        )
195                    }
196                } else if tab_count == 1 {
197                    "Found tab for alignment, use spaces instead".to_string()
198                } else {
199                    format!("Found {tab_count} tabs for alignment, use spaces instead")
200                };
201
202                warnings.push(LintWarning {
203                    rule_name: Some(self.name().to_string()),
204                    line: start_line,
205                    column: start_col,
206                    end_line,
207                    end_column: end_col,
208                    message,
209                    severity: Severity::Warning,
210                    fix: Some(Fix {
211                        range: line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
212                        replacement: " ".repeat(tab_count * self.config.spaces_per_tab.get()),
213                    }),
214                });
215            }
216        }
217
218        Ok(warnings)
219    }
220
221    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
222        if self.should_skip(ctx) {
223            return Ok(ctx.content.to_string());
224        }
225        let warnings = self.check(ctx)?;
226        if warnings.is_empty() {
227            return Ok(ctx.content.to_string());
228        }
229        let warnings =
230            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
231        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
232            .map_err(crate::rule::LintError::InvalidInput)
233    }
234
235    fn as_any(&self) -> &dyn std::any::Any {
236        self
237    }
238
239    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
240        // Skip if content is empty or has no tabs
241        ctx.content.is_empty() || !ctx.has_char('\t')
242    }
243
244    fn category(&self) -> RuleCategory {
245        RuleCategory::Whitespace
246    }
247
248    fn default_config_section(&self) -> Option<(String, toml::Value)> {
249        let default_config = MD010Config::default();
250        let json_value = serde_json::to_value(&default_config).ok()?;
251        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
252
253        if let toml::Value::Table(table) = toml_value {
254            if !table.is_empty() {
255                Some((MD010Config::RULE_NAME.to_string(), toml::Value::Table(table)))
256            } else {
257                None
258            }
259        } else {
260            None
261        }
262    }
263
264    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
265    where
266        Self: Sized,
267    {
268        let rule_config = crate::rule_config_serde::load_rule_config::<MD010Config>(config);
269        Box::new(Self::from_config_struct(rule_config))
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::lint_context::LintContext;
277    use crate::rule::Rule;
278
279    #[test]
280    fn test_no_tabs() {
281        let rule = MD010NoHardTabs::default();
282        let content = "This is a line\nAnother line\nNo tabs here";
283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
284        let result = rule.check(&ctx).unwrap();
285        assert!(result.is_empty());
286    }
287
288    #[test]
289    fn test_single_tab() {
290        let rule = MD010NoHardTabs::default();
291        let content = "Line with\ttab";
292        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
293        let result = rule.check(&ctx).unwrap();
294        assert_eq!(result.len(), 1);
295        assert_eq!(result[0].line, 1);
296        assert_eq!(result[0].column, 10);
297        assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
298    }
299
300    #[test]
301    fn test_leading_tabs() {
302        let rule = MD010NoHardTabs::default();
303        let content = "\tIndented line\n\t\tDouble indented";
304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305        let result = rule.check(&ctx).unwrap();
306        assert_eq!(result.len(), 2);
307        assert_eq!(result[0].line, 1);
308        assert_eq!(result[0].message, "Found leading tab, use 4 spaces instead");
309        assert_eq!(result[1].line, 2);
310        assert_eq!(result[1].message, "Found 2 leading tabs, use 8 spaces instead");
311    }
312
313    #[test]
314    fn test_fix_tabs() {
315        let rule = MD010NoHardTabs::default();
316        let content = "\tIndented\nNormal\tline\nNo tabs";
317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
318        let fixed = rule.fix(&ctx).unwrap();
319        assert_eq!(fixed, "    Indented\nNormal    line\nNo tabs");
320    }
321
322    #[test]
323    fn test_custom_spaces_per_tab() {
324        let rule = MD010NoHardTabs::new(4);
325        let content = "\tIndented";
326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327        let fixed = rule.fix(&ctx).unwrap();
328        assert_eq!(fixed, "    Indented");
329    }
330
331    #[test]
332    fn test_code_blocks_always_ignored() {
333        let rule = MD010NoHardTabs::default();
334        let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336        let result = rule.check(&ctx).unwrap();
337        // Should only flag tabs outside code blocks - code has its own formatting rules
338        assert_eq!(result.len(), 2);
339        assert_eq!(result[0].line, 1);
340        assert_eq!(result[1].line, 5);
341
342        let fixed = rule.fix(&ctx).unwrap();
343        assert_eq!(fixed, "Normal    line\n```\nCode\twith\ttab\n```\nAnother    line");
344    }
345
346    #[test]
347    fn test_code_blocks_never_checked() {
348        let rule = MD010NoHardTabs::default();
349        let content = "```\nCode\twith\ttab\n```";
350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351        let result = rule.check(&ctx).unwrap();
352        // Should never flag tabs in code blocks - code has its own formatting rules
353        // (e.g., Makefiles require tabs, Go uses tabs by convention)
354        assert_eq!(result.len(), 0);
355    }
356
357    #[test]
358    fn test_html_comments_ignored() {
359        let rule = MD010NoHardTabs::default();
360        let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362        let result = rule.check(&ctx).unwrap();
363        // Should not flag tabs in HTML comments
364        assert_eq!(result.len(), 2);
365        assert_eq!(result[0].line, 1);
366        assert_eq!(result[1].line, 3);
367    }
368
369    #[test]
370    fn test_multiline_html_comments() {
371        let rule = MD010NoHardTabs::default();
372        let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374        let result = rule.check(&ctx).unwrap();
375        // Should only flag the tab after the comment
376        assert_eq!(result.len(), 1);
377        assert_eq!(result[0].line, 5);
378    }
379
380    #[test]
381    fn test_empty_lines_with_tabs() {
382        let rule = MD010NoHardTabs::default();
383        let content = "Normal line\n\t\t\n\t\nAnother line";
384        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385        let result = rule.check(&ctx).unwrap();
386        assert_eq!(result.len(), 2);
387        assert_eq!(result[0].message, "Empty line contains 2 tabs");
388        assert_eq!(result[1].message, "Empty line contains tab");
389    }
390
391    #[test]
392    fn test_mixed_tabs_and_spaces() {
393        let rule = MD010NoHardTabs::default();
394        let content = " \tMixed indentation\n\t Mixed again";
395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396        let result = rule.check(&ctx).unwrap();
397        assert_eq!(result.len(), 2);
398    }
399
400    #[test]
401    fn test_consecutive_tabs() {
402        let rule = MD010NoHardTabs::default();
403        let content = "Text\t\t\tthree tabs\tand\tanother";
404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405        let result = rule.check(&ctx).unwrap();
406        // Should group consecutive tabs
407        assert_eq!(result.len(), 3);
408        assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
409    }
410
411    #[test]
412    fn test_find_and_group_tabs() {
413        // Test finding and grouping tabs in one pass
414        let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
415        assert_eq!(groups, vec![(1, 2), (3, 4)]);
416
417        let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
418        assert_eq!(groups, vec![(0, 2)]);
419
420        let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
421        assert!(groups.is_empty());
422
423        // Test with consecutive and non-consecutive tabs
424        let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
425        assert_eq!(groups, vec![(0, 3), (4, 6)]);
426
427        let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
428        assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
429    }
430
431    #[test]
432    fn test_count_leading_tabs() {
433        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
434        assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
435        assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
436        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
437    }
438
439    #[test]
440    fn test_default_config() {
441        let rule = MD010NoHardTabs::default();
442        let config = rule.default_config_section();
443        assert!(config.is_some());
444        let (name, _value) = config.unwrap();
445        assert_eq!(name, "MD010");
446    }
447
448    #[test]
449    fn test_from_config() {
450        // Test that custom config values are properly loaded
451        let custom_spaces = 8;
452        let rule = MD010NoHardTabs::new(custom_spaces);
453        let content = "\tTab";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455        let fixed = rule.fix(&ctx).unwrap();
456        assert_eq!(fixed, "        Tab");
457
458        // Code blocks are always ignored
459        let content_with_code = "```\n\tTab in code\n```";
460        let ctx = LintContext::new(content_with_code, crate::config::MarkdownFlavor::Standard, None);
461        let result = rule.check(&ctx).unwrap();
462        // Tabs in code blocks are never flagged
463        assert!(result.is_empty());
464    }
465
466    #[test]
467    fn test_performance_large_document() {
468        let rule = MD010NoHardTabs::default();
469        let mut content = String::new();
470        for i in 0..1000 {
471            content.push_str(&format!("Line {i}\twith\ttabs\n"));
472        }
473        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
474        let result = rule.check(&ctx).unwrap();
475        assert_eq!(result.len(), 2000);
476    }
477
478    #[test]
479    fn test_preserve_content() {
480        let rule = MD010NoHardTabs::default();
481        let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
482        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483        let fixed = rule.fix(&ctx).unwrap();
484        assert_eq!(fixed, "**Bold**    text\n*Italic*    text\n[Link](url)    tab");
485    }
486
487    #[test]
488    fn test_edge_cases() {
489        let rule = MD010NoHardTabs::default();
490
491        // Tab at end of line
492        let content = "Text\t";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494        let result = rule.check(&ctx).unwrap();
495        assert_eq!(result.len(), 1);
496
497        // Only tabs
498        let content = "\t\t\t";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500        let result = rule.check(&ctx).unwrap();
501        assert_eq!(result.len(), 1);
502        assert_eq!(result[0].message, "Empty line contains 3 tabs");
503    }
504
505    #[test]
506    fn test_code_blocks_always_preserved_in_fix() {
507        let rule = MD010NoHardTabs::default();
508
509        let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511        let fixed = rule.fix(&ctx).unwrap();
512
513        // Tabs in code blocks are preserved - code has its own formatting rules
514        // (e.g., Makefiles require tabs, Go uses tabs by convention)
515        let expected = "Text    with    tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore    tabs";
516        assert_eq!(fixed, expected);
517    }
518
519    #[test]
520    fn test_tilde_fence_longer_than_3() {
521        let rule = MD010NoHardTabs::default();
522        // 5-tilde fenced code block should be recognized and tabs inside should be skipped
523        let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
524        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525        let result = rule.check(&ctx).unwrap();
526        // Only tabs on line 4 (outside the code block) should be flagged
527        assert_eq!(
528            result.len(),
529            2,
530            "Expected 2 warnings but got {}: {:?}",
531            result.len(),
532            result
533        );
534        assert_eq!(result[0].line, 4);
535        assert_eq!(result[1].line, 4);
536    }
537
538    #[test]
539    fn test_backtick_fence_longer_than_3() {
540        let rule = MD010NoHardTabs::default();
541        // 5-backtick fenced code block
542        let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544        let result = rule.check(&ctx).unwrap();
545        assert_eq!(
546            result.len(),
547            2,
548            "Expected 2 warnings but got {}: {:?}",
549            result.len(),
550            result
551        );
552        assert_eq!(result[0].line, 4);
553        assert_eq!(result[1].line, 4);
554    }
555
556    #[test]
557    fn test_indented_code_block_tabs_flagged() {
558        let rule = MD010NoHardTabs::default();
559        // Tabs in indented code blocks are flagged because the tab IS the problem
560        // (unlike fenced code blocks where tabs are part of the code formatting)
561        let content = "    code\twith\ttab\n\nNormal\ttext";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563        let result = rule.check(&ctx).unwrap();
564        assert_eq!(
565            result.len(),
566            3,
567            "Expected 3 warnings but got {}: {:?}",
568            result.len(),
569            result
570        );
571        assert_eq!(result[0].line, 1);
572        assert_eq!(result[1].line, 1);
573        assert_eq!(result[2].line, 3);
574    }
575
576    #[test]
577    fn test_html_comment_end_then_start_same_line() {
578        let rule = MD010NoHardTabs::default();
579        // Tabs inside consecutive HTML comments should not be flagged
580        let content =
581            "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583        let result = rule.check(&ctx).unwrap();
584        assert!(
585            result.is_empty(),
586            "Expected 0 warnings but got {}: {:?}",
587            result.len(),
588            result
589        );
590    }
591
592    #[test]
593    fn test_fix_tilde_fence_longer_than_3() {
594        let rule = MD010NoHardTabs::default();
595        let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597        let fixed = rule.fix(&ctx).unwrap();
598        // Tabs inside code block preserved, tabs outside replaced
599        assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext    with    tab");
600    }
601
602    #[test]
603    fn test_fix_indented_code_block_tabs_replaced() {
604        let rule = MD010NoHardTabs::default();
605        let content = "    code\twith\ttab\n\nNormal\ttext";
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607        let fixed = rule.fix(&ctx).unwrap();
608        // All tabs replaced, including those in indented code blocks
609        assert_eq!(fixed, "    code    with    tab\n\nNormal    text");
610    }
611}