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_html_block
152                    || info.in_pymdown_block
153                    || info.in_mkdocstrings
154                    || info.in_esm_block
155            }) {
156                continue;
157            }
158
159            // Process tabs directly without intermediate collection
160            let tab_groups = Self::find_and_group_tabs(line);
161            if tab_groups.is_empty() {
162                continue;
163            }
164
165            let leading_tabs = Self::count_leading_tabs(line);
166
167            // Generate warning for each group of consecutive tabs
168            for (start_pos, end_pos) in tab_groups {
169                let tab_count = end_pos - start_pos;
170                let is_leading = start_pos < leading_tabs;
171
172                // Calculate precise character range for the tab group
173                let (start_line, start_col, end_line, end_col) =
174                    calculate_match_range(line_num + 1, line, start_pos, tab_count);
175
176                let message = if line.trim().is_empty() {
177                    if tab_count == 1 {
178                        "Empty line contains tab".to_string()
179                    } else {
180                        format!("Empty line contains {tab_count} tabs")
181                    }
182                } else if is_leading {
183                    if tab_count == 1 {
184                        format!(
185                            "Found leading tab, use {} spaces instead",
186                            self.config.spaces_per_tab.get()
187                        )
188                    } else {
189                        format!(
190                            "Found {} leading tabs, use {} spaces instead",
191                            tab_count,
192                            tab_count * self.config.spaces_per_tab.get()
193                        )
194                    }
195                } else if tab_count == 1 {
196                    "Found tab for alignment, use spaces instead".to_string()
197                } else {
198                    format!("Found {tab_count} tabs for alignment, use spaces instead")
199                };
200
201                warnings.push(LintWarning {
202                    rule_name: Some(self.name().to_string()),
203                    line: start_line,
204                    column: start_col,
205                    end_line,
206                    end_column: end_col,
207                    message,
208                    severity: Severity::Warning,
209                    fix: Some(Fix {
210                        range: _line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
211                        replacement: " ".repeat(tab_count * self.config.spaces_per_tab.get()),
212                    }),
213                });
214            }
215        }
216
217        Ok(warnings)
218    }
219
220    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
221        let content = ctx.content;
222
223        let mut result = String::new();
224        let lines = ctx.raw_lines();
225
226        // Track fenced code blocks separately — preserve tabs in FENCED blocks
227        let fenced_lines = Self::find_fenced_code_block_lines(lines);
228
229        for (i, line) in lines.iter().enumerate() {
230            // Preserve fenced code blocks and other non-markdown contexts
231            let should_skip = fenced_lines[i]
232                || ctx.line_info(i + 1).is_some_and(|info| {
233                    info.in_html_comment
234                        || info.in_html_block
235                        || info.in_pymdown_block
236                        || info.in_mkdocstrings
237                        || info.in_esm_block
238                });
239
240            if should_skip {
241                result.push_str(line);
242            } else {
243                // Replace tabs with spaces in regular markdown content
244                result.push_str(&line.replace('\t', &" ".repeat(self.config.spaces_per_tab.get())));
245            }
246
247            // Add newline if not the last line without a newline
248            if i < lines.len() - 1 || content.ends_with('\n') {
249                result.push('\n');
250            }
251        }
252
253        Ok(result)
254    }
255
256    fn as_any(&self) -> &dyn std::any::Any {
257        self
258    }
259
260    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
261        // Skip if content is empty or has no tabs
262        ctx.content.is_empty() || !ctx.has_char('\t')
263    }
264
265    fn category(&self) -> RuleCategory {
266        RuleCategory::Whitespace
267    }
268
269    fn default_config_section(&self) -> Option<(String, toml::Value)> {
270        let default_config = MD010Config::default();
271        let json_value = serde_json::to_value(&default_config).ok()?;
272        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
273
274        if let toml::Value::Table(table) = toml_value {
275            if !table.is_empty() {
276                Some((MD010Config::RULE_NAME.to_string(), toml::Value::Table(table)))
277            } else {
278                None
279            }
280        } else {
281            None
282        }
283    }
284
285    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
286    where
287        Self: Sized,
288    {
289        let rule_config = crate::rule_config_serde::load_rule_config::<MD010Config>(config);
290        Box::new(Self::from_config_struct(rule_config))
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::lint_context::LintContext;
298    use crate::rule::Rule;
299
300    #[test]
301    fn test_no_tabs() {
302        let rule = MD010NoHardTabs::default();
303        let content = "This is a line\nAnother line\nNo tabs here";
304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305        let result = rule.check(&ctx).unwrap();
306        assert!(result.is_empty());
307    }
308
309    #[test]
310    fn test_single_tab() {
311        let rule = MD010NoHardTabs::default();
312        let content = "Line with\ttab";
313        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
314        let result = rule.check(&ctx).unwrap();
315        assert_eq!(result.len(), 1);
316        assert_eq!(result[0].line, 1);
317        assert_eq!(result[0].column, 10);
318        assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
319    }
320
321    #[test]
322    fn test_leading_tabs() {
323        let rule = MD010NoHardTabs::default();
324        let content = "\tIndented line\n\t\tDouble indented";
325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
326        let result = rule.check(&ctx).unwrap();
327        assert_eq!(result.len(), 2);
328        assert_eq!(result[0].line, 1);
329        assert_eq!(result[0].message, "Found leading tab, use 4 spaces instead");
330        assert_eq!(result[1].line, 2);
331        assert_eq!(result[1].message, "Found 2 leading tabs, use 8 spaces instead");
332    }
333
334    #[test]
335    fn test_fix_tabs() {
336        let rule = MD010NoHardTabs::default();
337        let content = "\tIndented\nNormal\tline\nNo tabs";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339        let fixed = rule.fix(&ctx).unwrap();
340        assert_eq!(fixed, "    Indented\nNormal    line\nNo tabs");
341    }
342
343    #[test]
344    fn test_custom_spaces_per_tab() {
345        let rule = MD010NoHardTabs::new(4);
346        let content = "\tIndented";
347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
348        let fixed = rule.fix(&ctx).unwrap();
349        assert_eq!(fixed, "    Indented");
350    }
351
352    #[test]
353    fn test_code_blocks_always_ignored() {
354        let rule = MD010NoHardTabs::default();
355        let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
356        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357        let result = rule.check(&ctx).unwrap();
358        // Should only flag tabs outside code blocks - code has its own formatting rules
359        assert_eq!(result.len(), 2);
360        assert_eq!(result[0].line, 1);
361        assert_eq!(result[1].line, 5);
362
363        let fixed = rule.fix(&ctx).unwrap();
364        assert_eq!(fixed, "Normal    line\n```\nCode\twith\ttab\n```\nAnother    line");
365    }
366
367    #[test]
368    fn test_code_blocks_never_checked() {
369        let rule = MD010NoHardTabs::default();
370        let content = "```\nCode\twith\ttab\n```";
371        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372        let result = rule.check(&ctx).unwrap();
373        // Should never flag tabs in code blocks - code has its own formatting rules
374        // (e.g., Makefiles require tabs, Go uses tabs by convention)
375        assert_eq!(result.len(), 0);
376    }
377
378    #[test]
379    fn test_html_comments_ignored() {
380        let rule = MD010NoHardTabs::default();
381        let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
382        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383        let result = rule.check(&ctx).unwrap();
384        // Should not flag tabs in HTML comments
385        assert_eq!(result.len(), 2);
386        assert_eq!(result[0].line, 1);
387        assert_eq!(result[1].line, 3);
388    }
389
390    #[test]
391    fn test_multiline_html_comments() {
392        let rule = MD010NoHardTabs::default();
393        let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
394        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
395        let result = rule.check(&ctx).unwrap();
396        // Should only flag the tab after the comment
397        assert_eq!(result.len(), 1);
398        assert_eq!(result[0].line, 5);
399    }
400
401    #[test]
402    fn test_empty_lines_with_tabs() {
403        let rule = MD010NoHardTabs::default();
404        let content = "Normal line\n\t\t\n\t\nAnother line";
405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406        let result = rule.check(&ctx).unwrap();
407        assert_eq!(result.len(), 2);
408        assert_eq!(result[0].message, "Empty line contains 2 tabs");
409        assert_eq!(result[1].message, "Empty line contains tab");
410    }
411
412    #[test]
413    fn test_mixed_tabs_and_spaces() {
414        let rule = MD010NoHardTabs::default();
415        let content = " \tMixed indentation\n\t Mixed again";
416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417        let result = rule.check(&ctx).unwrap();
418        assert_eq!(result.len(), 2);
419    }
420
421    #[test]
422    fn test_consecutive_tabs() {
423        let rule = MD010NoHardTabs::default();
424        let content = "Text\t\t\tthree tabs\tand\tanother";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let result = rule.check(&ctx).unwrap();
427        // Should group consecutive tabs
428        assert_eq!(result.len(), 3);
429        assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
430    }
431
432    #[test]
433    fn test_find_and_group_tabs() {
434        // Test finding and grouping tabs in one pass
435        let groups = MD010NoHardTabs::find_and_group_tabs("a\tb\tc");
436        assert_eq!(groups, vec![(1, 2), (3, 4)]);
437
438        let groups = MD010NoHardTabs::find_and_group_tabs("\t\tabc");
439        assert_eq!(groups, vec![(0, 2)]);
440
441        let groups = MD010NoHardTabs::find_and_group_tabs("no tabs");
442        assert!(groups.is_empty());
443
444        // Test with consecutive and non-consecutive tabs
445        let groups = MD010NoHardTabs::find_and_group_tabs("\t\t\ta\t\tb");
446        assert_eq!(groups, vec![(0, 3), (4, 6)]);
447
448        let groups = MD010NoHardTabs::find_and_group_tabs("\ta\tb\tc");
449        assert_eq!(groups, vec![(0, 1), (2, 3), (4, 5)]);
450    }
451
452    #[test]
453    fn test_count_leading_tabs() {
454        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
455        assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
456        assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
457        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
458    }
459
460    #[test]
461    fn test_default_config() {
462        let rule = MD010NoHardTabs::default();
463        let config = rule.default_config_section();
464        assert!(config.is_some());
465        let (name, _value) = config.unwrap();
466        assert_eq!(name, "MD010");
467    }
468
469    #[test]
470    fn test_from_config() {
471        // Test that custom config values are properly loaded
472        let custom_spaces = 8;
473        let rule = MD010NoHardTabs::new(custom_spaces);
474        let content = "\tTab";
475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476        let fixed = rule.fix(&ctx).unwrap();
477        assert_eq!(fixed, "        Tab");
478
479        // Code blocks are always ignored
480        let content_with_code = "```\n\tTab in code\n```";
481        let ctx = LintContext::new(content_with_code, crate::config::MarkdownFlavor::Standard, None);
482        let result = rule.check(&ctx).unwrap();
483        // Tabs in code blocks are never flagged
484        assert!(result.is_empty());
485    }
486
487    #[test]
488    fn test_performance_large_document() {
489        let rule = MD010NoHardTabs::default();
490        let mut content = String::new();
491        for i in 0..1000 {
492            content.push_str(&format!("Line {i}\twith\ttabs\n"));
493        }
494        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
495        let result = rule.check(&ctx).unwrap();
496        assert_eq!(result.len(), 2000);
497    }
498
499    #[test]
500    fn test_preserve_content() {
501        let rule = MD010NoHardTabs::default();
502        let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504        let fixed = rule.fix(&ctx).unwrap();
505        assert_eq!(fixed, "**Bold**    text\n*Italic*    text\n[Link](url)    tab");
506    }
507
508    #[test]
509    fn test_edge_cases() {
510        let rule = MD010NoHardTabs::default();
511
512        // Tab at end of line
513        let content = "Text\t";
514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515        let result = rule.check(&ctx).unwrap();
516        assert_eq!(result.len(), 1);
517
518        // Only tabs
519        let content = "\t\t\t";
520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521        let result = rule.check(&ctx).unwrap();
522        assert_eq!(result.len(), 1);
523        assert_eq!(result[0].message, "Empty line contains 3 tabs");
524    }
525
526    #[test]
527    fn test_code_blocks_always_preserved_in_fix() {
528        let rule = MD010NoHardTabs::default();
529
530        let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let fixed = rule.fix(&ctx).unwrap();
533
534        // Tabs in code blocks are preserved - code has its own formatting rules
535        // (e.g., Makefiles require tabs, Go uses tabs by convention)
536        let expected = "Text    with    tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore    tabs";
537        assert_eq!(fixed, expected);
538    }
539
540    #[test]
541    fn test_tilde_fence_longer_than_3() {
542        let rule = MD010NoHardTabs::default();
543        // 5-tilde fenced code block should be recognized and tabs inside should be skipped
544        let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546        let result = rule.check(&ctx).unwrap();
547        // Only tabs on line 4 (outside the code block) should be flagged
548        assert_eq!(
549            result.len(),
550            2,
551            "Expected 2 warnings but got {}: {:?}",
552            result.len(),
553            result
554        );
555        assert_eq!(result[0].line, 4);
556        assert_eq!(result[1].line, 4);
557    }
558
559    #[test]
560    fn test_backtick_fence_longer_than_3() {
561        let rule = MD010NoHardTabs::default();
562        // 5-backtick fenced code block
563        let content = "`````\ncode\twith\ttab\n`````\ntext\twith\ttab";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let result = rule.check(&ctx).unwrap();
566        assert_eq!(
567            result.len(),
568            2,
569            "Expected 2 warnings but got {}: {:?}",
570            result.len(),
571            result
572        );
573        assert_eq!(result[0].line, 4);
574        assert_eq!(result[1].line, 4);
575    }
576
577    #[test]
578    fn test_indented_code_block_tabs_flagged() {
579        let rule = MD010NoHardTabs::default();
580        // Tabs in indented code blocks are flagged because the tab IS the problem
581        // (unlike fenced code blocks where tabs are part of the code formatting)
582        let content = "    code\twith\ttab\n\nNormal\ttext";
583        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584        let result = rule.check(&ctx).unwrap();
585        assert_eq!(
586            result.len(),
587            3,
588            "Expected 3 warnings but got {}: {:?}",
589            result.len(),
590            result
591        );
592        assert_eq!(result[0].line, 1);
593        assert_eq!(result[1].line, 1);
594        assert_eq!(result[2].line, 3);
595    }
596
597    #[test]
598    fn test_html_comment_end_then_start_same_line() {
599        let rule = MD010NoHardTabs::default();
600        // Tabs inside consecutive HTML comments should not be flagged
601        let content =
602            "<!-- first comment\nend --> text <!-- second comment\n\ttabbed content inside second comment\n-->";
603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604        let result = rule.check(&ctx).unwrap();
605        assert!(
606            result.is_empty(),
607            "Expected 0 warnings but got {}: {:?}",
608            result.len(),
609            result
610        );
611    }
612
613    #[test]
614    fn test_fix_tilde_fence_longer_than_3() {
615        let rule = MD010NoHardTabs::default();
616        let content = "~~~~~\ncode\twith\ttab\n~~~~~\ntext\twith\ttab";
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618        let fixed = rule.fix(&ctx).unwrap();
619        // Tabs inside code block preserved, tabs outside replaced
620        assert_eq!(fixed, "~~~~~\ncode\twith\ttab\n~~~~~\ntext    with    tab");
621    }
622
623    #[test]
624    fn test_fix_indented_code_block_tabs_replaced() {
625        let rule = MD010NoHardTabs::default();
626        let content = "    code\twith\ttab\n\nNormal\ttext";
627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628        let fixed = rule.fix(&ctx).unwrap();
629        // All tabs replaced, including those in indented code blocks
630        assert_eq!(fixed, "    code    with    tab\n\nNormal    text");
631    }
632}