1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use 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#[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 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 let has_comment_start = HTML_COMMENT_START.is_match(line);
41 let has_comment_end = HTML_COMMENT_END.is_match(line);
43
44 if has_comment_start && !has_comment_end && !in_html_comment {
45 in_html_comment = true;
47 html_comment_lines[i] = true;
48 } else if has_comment_end && in_html_comment {
49 html_comment_lines[i] = true;
51 in_html_comment = false;
52 } else if has_comment_start && has_comment_end {
53 html_comment_lines[i] = true;
55 } else if in_html_comment {
56 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 if i == last_tab_pos + 1 {
86 last_tab_pos = i;
88 } else {
89 groups.push((start, last_tab_pos + 1));
91 current_group_start = Some(i);
92 last_tab_pos = i;
93 }
94 } else {
95 current_group_start = Some(i);
97 last_tab_pos = i;
98 }
99 }
100 }
101
102 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 let html_comment_lines = Self::find_html_comment_lines(&lines);
129
130 for (line_num, &line) in lines.iter().enumerate() {
131 if html_comment_lines[line_num] {
133 continue;
134 }
135
136 if let Some(line_info) = ctx.line_info(line_num + 1)
138 && line_info.in_code_block
139 {
140 continue;
141 }
142
143 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 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 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 let html_comment_lines = Self::find_html_comment_lines(&lines);
213
214 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 result.push_str(line);
224 } else if ctx.is_in_code_block_or_span(line_positions[i]) {
225 result.push_str(line);
227 } else {
228 result.push_str(&line.replace('\t', &" ".repeat(self.config.spaces_per_tab.get())));
230 }
231
232 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}