rumdl_lib/rules/
md020_no_missing_space_closed_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6use regex::Regex;
7use std::sync::LazyLock;
8
9static CLOSED_ATX_NO_SPACE_PATTERN: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r"^(\s*)(#+)([^#\s].*?)([^#\s\\])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
13static CLOSED_ATX_NO_SPACE_START_PATTERN: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"^(\s*)(#+)([^#\s].*?)\s(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
15static CLOSED_ATX_NO_SPACE_END_PATTERN: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^(\s*)(#+)\s(.*?)([^#\s\\])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
17
18#[derive(Clone)]
19pub struct MD020NoMissingSpaceClosedAtx;
20
21impl Default for MD020NoMissingSpaceClosedAtx {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl MD020NoMissingSpaceClosedAtx {
28 pub fn new() -> Self {
29 Self
30 }
31
32 fn opens_with_atx_marker(line_info: &crate::lint_context::LineInfo) -> bool {
35 line_info.atx_missing_space.is_some()
36 || line_info
37 .heading
38 .as_deref()
39 .is_some_and(|heading| matches!(heading.style, crate::lint_context::HeadingStyle::ATX))
40 }
41
42 fn quoted_atx_candidate(line: &str, edge: usize) -> bool {
48 let mut column: usize = 0;
49 let mut content_start: usize = 0;
50 for (pos, c) in line.char_indices() {
51 match c {
52 ' ' => column += 1,
53 '\t' => column += 4 - column % 4,
54 '>' => {
55 column += 1;
56 content_start = column + 1;
57 }
58 '#' => {
59 let hashes = line[pos..].len() - line[pos..].trim_start_matches('#').len();
60 return hashes <= 6 && column.saturating_sub(content_start.max(edge)) < 4;
61 }
62 _ => return false,
63 }
64 }
65 false
66 }
67
68 fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
69 CLOSED_ATX_NO_SPACE_PATTERN.is_match(line)
70 || CLOSED_ATX_NO_SPACE_START_PATTERN.is_match(line)
71 || CLOSED_ATX_NO_SPACE_END_PATTERN.is_match(line)
72 }
73
74 fn fix_closed_atx_heading(&self, line: &str) -> String {
75 if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
76 let indentation = &captures[1];
77 let opening_hashes = &captures[2];
78 let content = &captures[3];
79 let last_char = &captures[4];
80 let closing_hashes = &captures[5];
81 let custom_id = &captures[6];
82 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
83 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
84 let indentation = &captures[1];
85 let opening_hashes = &captures[2];
86 let content = &captures[3];
87 let closing_hashes = &captures[4];
88 let custom_id = &captures[5];
89 format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
90 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
91 let indentation = &captures[1];
92 let opening_hashes = &captures[2];
93 let content = &captures[3];
94 let last_char = &captures[4];
95 let closing_hashes = &captures[5];
96 let custom_id = &captures[6];
97 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
98 } else {
99 line.to_string()
100 }
101 }
102}
103
104impl Rule for MD020NoMissingSpaceClosedAtx {
105 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
106 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(20))
107 }
108
109 fn name(&self) -> &'static str {
110 "MD020"
111 }
112
113 fn description(&self) -> &'static str {
114 "No space inside hashes on closed heading"
115 }
116
117 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
118 let mut warnings = Vec::new();
119
120 let quoted_setext_lines: std::collections::HashSet<usize> = ctx
122 .headings()
123 .filter(|h| h.is_blockquote() && h.is_setext())
124 .flat_map(|h| (h.first_line_num() - 1)..h.line_num)
125 .collect();
126
127 let edges = crate::utils::blockquote::container_edges(ctx);
128
129 let source = ctx
132 .candidate_line_indices(crate::lint_context::LineCandidateKind::Hash)
133 .iter()
134 .map(|&index| (index, &ctx.lines[index]));
135
136 for (line_num, line_info) in source {
137 let full_line = line_info.content(ctx.content);
138 let (prefix_len, marker_offset) = if line_info.blockquote.is_some() {
141 if quoted_setext_lines.contains(&line_num)
142 || line_info.in_code_block
143 || line_info.in_html_block
144 || line_info.in_html_comment
145 || line_info.in_mdx_comment
146 || line_info.in_esm_block
147 || line_info.in_pymdown_block
148 || line_info.in_front_matter
149 {
150 continue;
151 }
152 match crate::utils::blockquote::parse_blockquote_prefix(full_line) {
153 Some(parsed)
154 if crate::utils::blockquote::quote_markers_are_real(full_line)
155 && !crate::utils::blockquote::quote_markers_are_paragraph_text(
156 ctx, &edges, line_num, full_line,
157 )
158 && Self::quoted_atx_candidate(full_line, edges[line_num]) =>
159 {
160 let prefix_len = full_line.len() - parsed.content.len();
161 (
162 prefix_len,
163 prefix_len + parsed.content.len() - parsed.content.trim_start().len(),
164 )
165 }
166 _ => continue,
167 }
168 } else {
169 (0, line_info.indent)
170 };
171 let marker_byte = line_info.byte_offset + marker_offset;
172 if ctx.overlaps_template_code(marker_byte, marker_byte + 1)
173 || ctx.is_in_html_tag(marker_byte)
174 || ctx.is_in_code_span_byte(marker_byte)
175 || ctx.is_in_parsed_link(marker_byte)
176 || ctx.overlaps_mdx_inline_code(marker_byte, marker_byte + 1)
177 || ctx.is_in_jsx_component_tag(marker_byte)
178 {
179 continue;
180 }
181 if prefix_len == 0
184 && (!Self::opens_with_atx_marker(line_info)
185 || crate::utils::blockquote::is_indented_past_edge(&edges, line_num, full_line))
186 {
187 continue;
188 }
189
190 let line = &full_line[prefix_len..];
191 let start_offset = full_line[..prefix_len].chars().count();
192
193 if self.is_closed_atx_heading_without_space(line) {
197 let line_range = ctx.line_content_byte_range(line_num + 1);
198
199 let mut start_col = 1;
200 let mut length = 1;
201 let mut message = String::new();
202
203 if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
204 let opening_hashes = captures.get(2).unwrap();
206 message = format!(
207 "Missing space inside hashes on closed heading (with {} at start and end)",
208 "#".repeat(opening_hashes.as_str().len())
209 );
210 start_col = start_offset + line[..opening_hashes.end()].chars().count() + 1;
213 length = 1;
214 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
215 let opening_hashes = captures.get(2).unwrap();
217 message = format!(
218 "Missing space after {} at start of closed heading",
219 "#".repeat(opening_hashes.as_str().len())
220 );
221 start_col = start_offset + line[..opening_hashes.end()].chars().count() + 1;
224 length = 1;
225 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
226 let content = captures.get(3).unwrap();
228 let closing_hashes = captures.get(5).unwrap();
229 message = format!(
230 "Missing space before {} at end of closed heading",
231 "#".repeat(closing_hashes.as_str().len())
232 );
233 start_col = start_offset + line[..content.end()].chars().count() + 1;
236 length = 1;
237 }
238
239 let (start_line, start_col_calc, end_line, end_col) =
240 calculate_single_line_range(line_num + 1, start_col, length);
241
242 warnings.push(LintWarning {
243 rule_name: Some(self.name().to_string()),
244 message,
245 line: start_line,
246 column: start_col_calc,
247 end_line,
248 end_column: end_col,
249 severity: Severity::Warning,
250 fix: Some(Fix::new(
251 line_range,
252 format!("{}{}", &full_line[..prefix_len], self.fix_closed_atx_heading(line)),
253 )),
254 });
255 }
256 }
257
258 Ok(warnings)
259 }
260
261 fn fixes_from_diagnostics(&self) -> bool {
262 true
263 }
264
265 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
266 let warnings = self.check(ctx)?;
267 let warnings =
268 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
269 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
270 .map_err(crate::rule::LintError::InvalidInput)
271 }
272
273 fn category(&self) -> RuleCategory {
275 RuleCategory::Heading
276 }
277
278 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
280 ctx.content.is_empty() || !ctx.likely_has_headings()
281 }
282
283 fn as_any(&self) -> &dyn std::any::Any {
284 self
285 }
286
287 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
288 where
289 Self: Sized,
290 {
291 Box::new(MD020NoMissingSpaceClosedAtx::new())
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::lint_context::LintContext;
299
300 #[test]
301 fn test_basic_functionality() {
302 let rule = MD020NoMissingSpaceClosedAtx;
303
304 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307 let result = rule.check(&ctx).unwrap();
308 assert!(result.is_empty());
309
310 let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
313 let result = rule.check(&ctx).unwrap();
314 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
316 assert_eq!(result[1].line, 3);
317 }
318
319 #[test]
320 fn test_multibyte_char_column_position() {
321 let rule = MD020NoMissingSpaceClosedAtx;
322
323 let content = "##Ünited##";
329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
330 let result = rule.check(&ctx).unwrap();
331
332 assert_eq!(result.len(), 1);
333 let content = "## Ü test##";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
343 let result = rule.check(&ctx).unwrap();
344
345 assert_eq!(result.len(), 1);
346 assert_eq!(
350 result[0].column, 9,
351 "Column should use character position, not byte offset"
352 );
353 }
354}