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::{LineIndex, calculate_match_range};
7use crate::utils::regex_cache::{HTML_COMMENT_END, HTML_COMMENT_START};
8
9mod md010_config;
10use md010_config::MD010Config;
11
12// HTML comment patterns are now imported from regex_cache
13
14/// Rule MD010: Hard tabs
15#[derive(Clone, Default)]
16pub struct MD010NoHardTabs {
17    config: MD010Config,
18}
19
20impl MD010NoHardTabs {
21    pub fn new(spaces_per_tab: usize) -> Self {
22        Self {
23            config: MD010Config { spaces_per_tab },
24        }
25    }
26
27    pub fn from_config_struct(config: MD010Config) -> Self {
28        Self { config }
29    }
30
31    // Identify lines that are part of HTML comments
32    fn find_html_comment_lines(lines: &[&str]) -> Vec<bool> {
33        let mut in_html_comment = false;
34        let mut html_comment_lines = vec![false; lines.len()];
35
36        for (i, line) in lines.iter().enumerate() {
37            // Check if this line has a comment start
38            let has_comment_start = HTML_COMMENT_START.is_match(line);
39            // Check if this line has a comment end
40            let has_comment_end = HTML_COMMENT_END.is_match(line);
41
42            if has_comment_start && !has_comment_end && !in_html_comment {
43                // Comment starts on this line and doesn't end
44                in_html_comment = true;
45                html_comment_lines[i] = true;
46            } else if has_comment_end && in_html_comment {
47                // Comment ends on this line
48                html_comment_lines[i] = true;
49                in_html_comment = false;
50            } else if has_comment_start && has_comment_end {
51                // Both start and end on the same line
52                html_comment_lines[i] = true;
53            } else if in_html_comment {
54                // We're inside a multi-line comment
55                html_comment_lines[i] = true;
56            }
57        }
58
59        html_comment_lines
60    }
61
62    fn count_leading_tabs(line: &str) -> usize {
63        let mut count = 0;
64        for c in line.chars() {
65            if c == '\t' {
66                count += 1;
67            } else {
68                break;
69            }
70        }
71        count
72    }
73
74    fn find_tab_positions(line: &str) -> Vec<usize> {
75        line.chars()
76            .enumerate()
77            .filter(|(_, c)| *c == '\t')
78            .map(|(i, _)| i)
79            .collect()
80    }
81
82    fn group_consecutive_tabs(tab_positions: &[usize]) -> Vec<(usize, usize)> {
83        if tab_positions.is_empty() {
84            return Vec::new();
85        }
86
87        let mut groups = Vec::new();
88        let mut start = tab_positions[0];
89        let mut end = tab_positions[0];
90
91        for &pos in tab_positions.iter().skip(1) {
92            if pos == end + 1 {
93                // Consecutive tab
94                end = pos;
95            } else {
96                // Gap found, save current group and start new one
97                groups.push((start, end + 1)); // end + 1 for exclusive end
98                start = pos;
99                end = pos;
100            }
101        }
102
103        // Add the last group
104        groups.push((start, end + 1));
105        groups
106    }
107}
108
109impl Rule for MD010NoHardTabs {
110    fn name(&self) -> &'static str {
111        "MD010"
112    }
113
114    fn description(&self) -> &'static str {
115        "No tabs"
116    }
117
118    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
119        let content = ctx.content;
120        let _line_index = LineIndex::new(content.to_string());
121
122        let mut warnings = Vec::new();
123        let lines: Vec<&str> = content.lines().collect();
124
125        // Pre-compute which lines are part of HTML comments
126        let html_comment_lines = Self::find_html_comment_lines(&lines);
127
128        for (line_num, &line) in lines.iter().enumerate() {
129            // Skip if in HTML comment
130            if html_comment_lines[line_num] {
131                continue;
132            }
133
134            // Always skip code blocks
135            if let Some(line_info) = ctx.line_info(line_num + 1)
136                && line_info.in_code_block
137            {
138                continue;
139            }
140
141            let tab_positions = Self::find_tab_positions(line);
142            if tab_positions.is_empty() {
143                continue;
144            }
145
146            let leading_tabs = Self::count_leading_tabs(line);
147            let tab_groups = Self::group_consecutive_tabs(&tab_positions);
148
149            // Generate warning for each group of consecutive tabs
150            for (start_pos, end_pos) in tab_groups {
151                let tab_count = end_pos - start_pos;
152                let is_leading = start_pos < leading_tabs;
153
154                // Calculate precise character range for the tab group
155                let (start_line, start_col, end_line, end_col) =
156                    calculate_match_range(line_num + 1, line, start_pos, tab_count);
157
158                let message = if line.trim().is_empty() {
159                    if tab_count == 1 {
160                        "Empty line contains tab".to_string()
161                    } else {
162                        format!("Empty line contains {tab_count} tabs")
163                    }
164                } else if is_leading {
165                    if tab_count == 1 {
166                        format!("Found leading tab, use {} spaces instead", self.config.spaces_per_tab)
167                    } else {
168                        format!(
169                            "Found {} leading tabs, use {} spaces instead",
170                            tab_count,
171                            tab_count * self.config.spaces_per_tab
172                        )
173                    }
174                } else if tab_count == 1 {
175                    "Found tab for alignment, use spaces instead".to_string()
176                } else {
177                    format!("Found {tab_count} tabs for alignment, use spaces instead")
178                };
179
180                warnings.push(LintWarning {
181                    rule_name: Some(self.name()),
182                    line: start_line,
183                    column: start_col,
184                    end_line,
185                    end_column: end_col,
186                    message,
187                    severity: Severity::Warning,
188                    fix: Some(Fix {
189                        range: _line_index.line_col_to_byte_range_with_length(line_num + 1, start_pos + 1, tab_count),
190                        replacement: " ".repeat(tab_count * self.config.spaces_per_tab),
191                    }),
192                });
193            }
194        }
195
196        Ok(warnings)
197    }
198
199    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
200        let content = ctx.content;
201        let _line_index = LineIndex::new(content.to_string());
202
203        let mut result = String::new();
204        let lines: Vec<&str> = content.lines().collect();
205
206        // Pre-compute which lines are part of HTML comments
207        let html_comment_lines = Self::find_html_comment_lines(&lines);
208
209        // Pre-compute line positions for code block detection
210        let mut line_positions = Vec::with_capacity(lines.len());
211        let mut pos = 0;
212        for line in &lines {
213            line_positions.push(pos);
214            pos += line.len() + 1; // +1 for newline
215        }
216
217        for (i, line) in lines.iter().enumerate() {
218            if html_comment_lines[i] {
219                // Preserve HTML comments as they are
220                result.push_str(line);
221            } else if ctx.is_in_code_block_or_span(line_positions[i]) {
222                // Always preserve code blocks as-is
223                result.push_str(line);
224            } else {
225                // Replace tabs with spaces
226                result.push_str(&line.replace('\t', &" ".repeat(self.config.spaces_per_tab)));
227            }
228
229            // Add newline if not the last line without a newline
230            if i < lines.len() - 1 || content.ends_with('\n') {
231                result.push('\n');
232            }
233        }
234
235        Ok(result)
236    }
237
238    fn as_any(&self) -> &dyn std::any::Any {
239        self
240    }
241
242    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
243        // Skip if content is empty or has no tabs
244        ctx.content.is_empty() || !ctx.has_char('\t')
245    }
246
247    fn category(&self) -> RuleCategory {
248        RuleCategory::Whitespace
249    }
250
251    fn default_config_section(&self) -> Option<(String, toml::Value)> {
252        let default_config = MD010Config::default();
253        let json_value = serde_json::to_value(&default_config).ok()?;
254        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
255
256        if let toml::Value::Table(table) = toml_value {
257            if !table.is_empty() {
258                Some((MD010Config::RULE_NAME.to_string(), toml::Value::Table(table)))
259            } else {
260                None
261            }
262        } else {
263            None
264        }
265    }
266
267    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
268    where
269        Self: Sized,
270    {
271        let rule_config = crate::rule_config_serde::load_rule_config::<MD010Config>(config);
272        Box::new(Self::from_config_struct(rule_config))
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::lint_context::LintContext;
280    use crate::rule::Rule;
281
282    #[test]
283    fn test_no_tabs() {
284        let rule = MD010NoHardTabs::default();
285        let content = "This is a line\nAnother line\nNo tabs here";
286        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
287        let result = rule.check(&ctx).unwrap();
288        assert!(result.is_empty());
289    }
290
291    #[test]
292    fn test_single_tab() {
293        let rule = MD010NoHardTabs::default();
294        let content = "Line with\ttab";
295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
296        let result = rule.check(&ctx).unwrap();
297        assert_eq!(result.len(), 1);
298        assert_eq!(result[0].line, 1);
299        assert_eq!(result[0].column, 10);
300        assert_eq!(result[0].message, "Found tab for alignment, use spaces instead");
301    }
302
303    #[test]
304    fn test_leading_tabs() {
305        let rule = MD010NoHardTabs::default();
306        let content = "\tIndented line\n\t\tDouble indented";
307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
308        let result = rule.check(&ctx).unwrap();
309        assert_eq!(result.len(), 2);
310        assert_eq!(result[0].line, 1);
311        assert_eq!(result[0].message, "Found leading tab, use 4 spaces instead");
312        assert_eq!(result[1].line, 2);
313        assert_eq!(result[1].message, "Found 2 leading tabs, use 8 spaces instead");
314    }
315
316    #[test]
317    fn test_fix_tabs() {
318        let rule = MD010NoHardTabs::default();
319        let content = "\tIndented\nNormal\tline\nNo tabs";
320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
321        let fixed = rule.fix(&ctx).unwrap();
322        assert_eq!(fixed, "    Indented\nNormal    line\nNo tabs");
323    }
324
325    #[test]
326    fn test_custom_spaces_per_tab() {
327        let rule = MD010NoHardTabs::new(4);
328        let content = "\tIndented";
329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330        let fixed = rule.fix(&ctx).unwrap();
331        assert_eq!(fixed, "    Indented");
332    }
333
334    #[test]
335    fn test_code_blocks_always_ignored() {
336        let rule = MD010NoHardTabs::default();
337        let content = "Normal\tline\n```\nCode\twith\ttab\n```\nAnother\tline";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339        let result = rule.check(&ctx).unwrap();
340        // Should only flag tabs outside code blocks
341        assert_eq!(result.len(), 2);
342        assert_eq!(result[0].line, 1);
343        assert_eq!(result[1].line, 5);
344
345        let fixed = rule.fix(&ctx).unwrap();
346        assert_eq!(fixed, "Normal    line\n```\nCode\twith\ttab\n```\nAnother    line");
347    }
348
349    #[test]
350    fn test_code_blocks_never_checked() {
351        let rule = MD010NoHardTabs::default();
352        let content = "```\nCode\twith\ttab\n```";
353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
354        let result = rule.check(&ctx).unwrap();
355        // Should never flag tabs in code blocks
356        assert_eq!(result.len(), 0);
357    }
358
359    #[test]
360    fn test_html_comments_ignored() {
361        let rule = MD010NoHardTabs::default();
362        let content = "Normal\tline\n<!-- HTML\twith\ttab -->\nAnother\tline";
363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
364        let result = rule.check(&ctx).unwrap();
365        // Should not flag tabs in HTML comments
366        assert_eq!(result.len(), 2);
367        assert_eq!(result[0].line, 1);
368        assert_eq!(result[1].line, 3);
369    }
370
371    #[test]
372    fn test_multiline_html_comments() {
373        let rule = MD010NoHardTabs::default();
374        let content = "Before\n<!--\nMultiline\twith\ttabs\ncomment\t-->\nAfter\ttab";
375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376        let result = rule.check(&ctx).unwrap();
377        // Should only flag the tab after the comment
378        assert_eq!(result.len(), 1);
379        assert_eq!(result[0].line, 5);
380    }
381
382    #[test]
383    fn test_empty_lines_with_tabs() {
384        let rule = MD010NoHardTabs::default();
385        let content = "Normal line\n\t\t\n\t\nAnother line";
386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
387        let result = rule.check(&ctx).unwrap();
388        assert_eq!(result.len(), 2);
389        assert_eq!(result[0].message, "Empty line contains 2 tabs");
390        assert_eq!(result[1].message, "Empty line contains tab");
391    }
392
393    #[test]
394    fn test_mixed_tabs_and_spaces() {
395        let rule = MD010NoHardTabs::default();
396        let content = " \tMixed indentation\n\t Mixed again";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
398        let result = rule.check(&ctx).unwrap();
399        assert_eq!(result.len(), 2);
400    }
401
402    #[test]
403    fn test_consecutive_tabs() {
404        let rule = MD010NoHardTabs::default();
405        let content = "Text\t\t\tthree tabs\tand\tanother";
406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
407        let result = rule.check(&ctx).unwrap();
408        // Should group consecutive tabs
409        assert_eq!(result.len(), 3);
410        assert_eq!(result[0].message, "Found 3 tabs for alignment, use spaces instead");
411    }
412
413    #[test]
414    fn test_tab_positions() {
415        let tabs = MD010NoHardTabs::find_tab_positions("a\tb\tc");
416        assert_eq!(tabs, vec![1, 3]);
417
418        let tabs = MD010NoHardTabs::find_tab_positions("\t\tabc");
419        assert_eq!(tabs, vec![0, 1]);
420
421        let tabs = MD010NoHardTabs::find_tab_positions("no tabs");
422        assert!(tabs.is_empty());
423    }
424
425    #[test]
426    fn test_group_consecutive_tabs() {
427        let groups = MD010NoHardTabs::group_consecutive_tabs(&[0, 1, 2, 5, 6]);
428        assert_eq!(groups, vec![(0, 3), (5, 7)]);
429
430        let groups = MD010NoHardTabs::group_consecutive_tabs(&[1, 3, 5]);
431        assert_eq!(groups, vec![(1, 2), (3, 4), (5, 6)]);
432
433        let groups = MD010NoHardTabs::group_consecutive_tabs(&[]);
434        assert!(groups.is_empty());
435    }
436
437    #[test]
438    fn test_count_leading_tabs() {
439        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t\tcode"), 2);
440        assert_eq!(MD010NoHardTabs::count_leading_tabs(" \tcode"), 0);
441        assert_eq!(MD010NoHardTabs::count_leading_tabs("no tabs"), 0);
442        assert_eq!(MD010NoHardTabs::count_leading_tabs("\t"), 1);
443    }
444
445    #[test]
446    fn test_default_config() {
447        let rule = MD010NoHardTabs::default();
448        let config = rule.default_config_section();
449        assert!(config.is_some());
450        let (name, _value) = config.unwrap();
451        assert_eq!(name, "MD010");
452    }
453
454    #[test]
455    fn test_from_config() {
456        // Test that custom config values are properly loaded
457        let custom_spaces = 8;
458        let rule = MD010NoHardTabs::new(custom_spaces);
459        let content = "\tTab";
460        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
461        let fixed = rule.fix(&ctx).unwrap();
462        assert_eq!(fixed, "        Tab");
463
464        // Code blocks are always ignored
465        let content_with_code = "```\n\tTab in code\n```";
466        let ctx = LintContext::new(content_with_code, crate::config::MarkdownFlavor::Standard);
467        let result = rule.check(&ctx).unwrap();
468        // Tabs in code blocks are never flagged
469        assert!(result.is_empty());
470    }
471
472    #[test]
473    fn test_performance_large_document() {
474        let rule = MD010NoHardTabs::default();
475        let mut content = String::new();
476        for i in 0..1000 {
477            content.push_str(&format!("Line {i}\twith\ttabs\n"));
478        }
479        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
480        let result = rule.check(&ctx).unwrap();
481        assert_eq!(result.len(), 2000);
482    }
483
484    #[test]
485    fn test_preserve_content() {
486        let rule = MD010NoHardTabs::default();
487        let content = "**Bold**\ttext\n*Italic*\ttext\n[Link](url)\ttab";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
489        let fixed = rule.fix(&ctx).unwrap();
490        assert_eq!(fixed, "**Bold**    text\n*Italic*    text\n[Link](url)    tab");
491    }
492
493    #[test]
494    fn test_edge_cases() {
495        let rule = MD010NoHardTabs::default();
496
497        // Tab at end of line
498        let content = "Text\t";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500        let result = rule.check(&ctx).unwrap();
501        assert_eq!(result.len(), 1);
502
503        // Only tabs
504        let content = "\t\t\t";
505        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
506        let result = rule.check(&ctx).unwrap();
507        assert_eq!(result.len(), 1);
508        assert_eq!(result[0].message, "Empty line contains 3 tabs");
509    }
510
511    #[test]
512    fn test_code_blocks_always_preserved_in_fix() {
513        let rule = MD010NoHardTabs::default();
514
515        let content = "Text\twith\ttab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore\ttabs";
516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
517        let fixed = rule.fix(&ctx).unwrap();
518
519        // Should always preserve tabs in all code blocks
520        let expected = "Text    with    tab\n```makefile\ntarget:\n\tcommand\n\tanother\n```\nMore    tabs";
521        assert_eq!(fixed, expected);
522    }
523
524    #[test]
525    fn test_find_html_comment_lines() {
526        let lines = vec!["Normal", "<!-- Start", "Middle", "End -->", "After"];
527        let result = MD010NoHardTabs::find_html_comment_lines(&lines);
528        assert_eq!(result, vec![false, true, true, true, false]);
529
530        let lines = vec!["<!-- Single line comment -->", "Normal"];
531        let result = MD010NoHardTabs::find_html_comment_lines(&lines);
532        assert_eq!(result, vec![true, false]);
533    }
534}