rumdl_lib/rules/
md019_no_multiple_space_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6
7#[derive(Clone)]
8pub struct MD019NoMultipleSpaceAtx;
9
10impl Default for MD019NoMultipleSpaceAtx {
11 fn default() -> Self {
12 Self::new()
13 }
14}
15
16impl MD019NoMultipleSpaceAtx {
17 pub fn new() -> Self {
18 Self
19 }
20
21 fn count_spaces_after_marker(&self, line: &str, marker_len: usize) -> usize {
23 let after_marker = &line[marker_len..];
24 after_marker.chars().take_while(|c| *c == ' ' || *c == '\t').count()
25 }
26}
27
28impl Rule for MD019NoMultipleSpaceAtx {
29 fn name(&self) -> &'static str {
30 "MD019"
31 }
32
33 fn description(&self) -> &'static str {
34 "Multiple spaces after hash in heading"
35 }
36
37 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
38 let mut warnings = Vec::new();
39
40 for (line_num, line_info) in ctx.lines.iter().enumerate() {
42 if let Some(heading) = &line_info.heading {
43 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
45 let line = line_info.content(ctx.content);
46 let trimmed = line.trim_start();
47 let marker_pos = line_info.indent + heading.marker.len();
48
49 if trimmed.len() > heading.marker.len() {
51 let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
52
53 if space_count > 1 {
54 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
56 line_num + 1, marker_pos + 1, space_count, );
60
61 let line_start_byte = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
63
64 let original_line = line_info.content(ctx.content);
66 let marker_byte_pos = line_start_byte + line_info.indent + heading.marker.len();
67
68 let after_marker_start = line_info.indent + heading.marker.len();
70 let after_marker = &original_line[after_marker_start..];
71 let space_bytes = after_marker
72 .as_bytes()
73 .iter()
74 .take_while(|&&b| b == b' ' || b == b'\t')
75 .count();
76
77 let extra_spaces_start = marker_byte_pos;
78 let extra_spaces_end = marker_byte_pos + space_bytes;
79
80 warnings.push(LintWarning {
81 rule_name: Some(self.name().to_string()),
82 message: format!(
83 "Multiple spaces ({}) after {} in heading",
84 space_count,
85 "#".repeat(heading.level as usize)
86 ),
87 line: start_line,
88 column: start_col,
89 end_line,
90 end_column: end_col,
91 severity: Severity::Warning,
92 fix: Some(Fix {
93 range: extra_spaces_start..extra_spaces_end,
94 replacement: " ".to_string(), }),
96 });
97 }
98 }
99 }
100 }
101 }
102
103 Ok(warnings)
104 }
105
106 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
107 if self.should_skip(ctx) {
108 return Ok(ctx.content.to_string());
109 }
110 let warnings = self.check(ctx)?;
111 if warnings.is_empty() {
112 return Ok(ctx.content.to_string());
113 }
114 let warnings =
115 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
116 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
117 .map_err(crate::rule::LintError::InvalidInput)
118 }
119
120 fn category(&self) -> RuleCategory {
122 RuleCategory::Heading
123 }
124
125 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
127 ctx.content.is_empty() || !ctx.likely_has_headings()
128 }
129
130 fn as_any(&self) -> &dyn std::any::Any {
131 self
132 }
133
134 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
135 where
136 Self: Sized,
137 {
138 Box::new(MD019NoMultipleSpaceAtx::new())
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn test_basic_functionality() {
148 let rule = MD019NoMultipleSpaceAtx::new();
149
150 let content = "# Multiple Spaces\n\nRegular content\n\n## More Spaces";
152 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
153 let result = rule.check(&ctx).unwrap();
154 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
156 assert_eq!(result[1].line, 5);
157
158 let content = "# Single Space\n\n## Also correct";
160 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
161 let result = rule.check(&ctx).unwrap();
162 assert!(
163 result.is_empty(),
164 "Properly formatted headings should not generate warnings"
165 );
166 }
167}