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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
30 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(19))
31 }
32
33 fn name(&self) -> &'static str {
34 "MD019"
35 }
36
37 fn description(&self) -> &'static str {
38 "Multiple spaces after hash in heading"
39 }
40
41 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
42 let mut warnings = Vec::new();
43
44 for parsed in ctx.headings() {
46 let line_num = parsed.line_num - 1;
47 let line_info = parsed.line_info;
48 let heading = parsed.heading;
49 let marker_byte = line_info.byte_offset
51 + if parsed.is_blockquote() {
52 heading.marker_column
53 } else {
54 line_info.indent
55 };
56 if ctx.overlaps_template_code(marker_byte, marker_byte + 1) {
57 continue;
58 }
59 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
61 let line = line_info.content(ctx.content);
62 let marker_start = heading.marker_column;
65 let trimmed = &line[marker_start..];
66 let marker_pos = marker_start + heading.marker.len();
67
68 if trimmed.len() > heading.marker.len() {
70 let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
71
72 if space_count > 1 {
73 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
75 line_num + 1, marker_pos + 1, space_count, );
79
80 let line_start_byte = ctx.line_start_byte(line_num + 1).unwrap_or(0);
82
83 let original_line = line_info.content(ctx.content);
85 let marker_byte_pos = line_start_byte + marker_start + heading.marker.len();
86
87 let after_marker_start = marker_start + heading.marker.len();
89 let after_marker = &original_line[after_marker_start..];
90 let space_bytes = after_marker
91 .as_bytes()
92 .iter()
93 .take_while(|&&b| b == b' ' || b == b'\t')
94 .count();
95
96 let extra_spaces_start = marker_byte_pos;
97 let extra_spaces_end = marker_byte_pos + space_bytes;
98
99 warnings.push(LintWarning {
100 rule_name: Some(self.name().to_string()),
101 message: format!(
102 "Multiple spaces ({}) after {} in heading",
103 space_count,
104 "#".repeat(heading.level as usize)
105 ),
106 line: start_line,
107 column: start_col,
108 end_line,
109 end_column: end_col,
110 severity: Severity::Warning,
111 fix: Some(Fix::new(extra_spaces_start..extra_spaces_end, " ".to_string())),
112 });
113 }
114 }
115 }
116 }
117
118 Ok(warnings)
119 }
120
121 fn fixes_from_diagnostics(&self) -> bool {
122 true
123 }
124
125 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
126 if self.should_skip(ctx) {
127 return Ok(ctx.content.to_string());
128 }
129 let warnings = self.check(ctx)?;
130 if warnings.is_empty() {
131 return Ok(ctx.content.to_string());
132 }
133 let warnings =
134 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
135 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
136 .map_err(crate::rule::LintError::InvalidInput)
137 }
138
139 fn category(&self) -> RuleCategory {
141 RuleCategory::Heading
142 }
143
144 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
146 ctx.content.is_empty() || !ctx.likely_has_headings()
147 }
148
149 fn as_any(&self) -> &dyn std::any::Any {
150 self
151 }
152
153 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
154 where
155 Self: Sized,
156 {
157 Box::new(MD019NoMultipleSpaceAtx::new())
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn test_basic_functionality() {
167 let rule = MD019NoMultipleSpaceAtx::new();
168
169 let content = "# Multiple Spaces\n\nRegular content\n\n## More Spaces";
171 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
172 let result = rule.check(&ctx).unwrap();
173 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
175 assert_eq!(result[1].line, 5);
176
177 let content = "# Single Space\n\n## Also correct";
179 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
180 let result = rule.check(&ctx).unwrap();
181 assert!(
182 result.is_empty(),
183 "Properly formatted headings should not generate warnings"
184 );
185 }
186}