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