1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_match_range;
6use regex::Regex;
7use std::collections::HashMap;
8use std::sync::LazyLock;
9use std::sync::RwLock;
10
11mod md026_config;
12use md026_config::{DEFAULT_PUNCTUATION, MD026Config};
13
14static ATX_HEADING_UNIFIED: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^( {0,3})(#{1,6})(\s+)(.+?)(\s+#{1,6})?$").unwrap());
17
18static QUICK_PUNCTUATION_CHECK: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(&format!(r"[{}]", regex::escape(DEFAULT_PUNCTUATION))).unwrap());
21
22static PUNCTUATION_REGEX_CACHE: LazyLock<RwLock<HashMap<String, Regex>>> =
24 LazyLock::new(|| RwLock::new(HashMap::new()));
25
26#[derive(Clone, Default)]
28pub struct MD026NoTrailingPunctuation {
29 config: MD026Config,
30}
31
32impl MD026NoTrailingPunctuation {
33 pub fn new(punctuation: Option<String>) -> Self {
34 Self {
35 config: MD026Config {
36 punctuation: punctuation.unwrap_or_else(|| DEFAULT_PUNCTUATION.to_string()),
37 },
38 }
39 }
40
41 pub fn from_config_struct(config: MD026Config) -> Self {
42 Self { config }
43 }
44
45 #[inline]
46 fn get_punctuation_regex(&self) -> Result<Regex, regex::Error> {
47 {
49 let cache = PUNCTUATION_REGEX_CACHE.read().unwrap();
50 if let Some(cached_regex) = cache.get(&self.config.punctuation) {
51 return Ok(cached_regex.clone());
52 }
53 }
54
55 let pattern = format!(r"([{}]+)$", regex::escape(&self.config.punctuation));
57 let regex = Regex::new(&pattern)?;
58
59 {
60 let mut cache = PUNCTUATION_REGEX_CACHE.write().unwrap();
61 cache.insert(self.config.punctuation.clone(), regex.clone());
62 }
63
64 Ok(regex)
65 }
66
67 #[inline]
68 fn has_trailing_punctuation(&self, text: &str, re: &Regex) -> bool {
69 let trimmed = text.trim();
70 re.is_match(trimmed)
71 }
72
73 #[inline]
83 fn remove_trailing_punctuation(&self, text: &str, re: &Regex) -> String {
84 let mut result = text.trim().to_string();
85 loop {
86 let stripped = re.replace(&result, "").into_owned();
87 if stripped.len() == result.len() {
88 return stripped;
90 }
91 let trimmed = stripped.trim_end();
94 if trimmed.len() != stripped.len() && re.is_match(trimmed) {
95 result = trimmed.to_string();
96 } else {
97 return stripped;
98 }
99 }
100 }
101
102 #[inline]
104 fn fix_atx_heading(&self, line: &str, re: &Regex) -> String {
105 if let Some(captures) = ATX_HEADING_UNIFIED.captures(line) {
106 let indentation = captures.get(1).unwrap().as_str();
107 let hashes = captures.get(2).unwrap().as_str();
108 let space = captures.get(3).unwrap().as_str();
109 let content = captures.get(4).unwrap().as_str();
110
111 let fixed_content = if let Some(id_pos) = content.rfind(" {#") {
114 let before_id = &content[..id_pos];
116 let id_part = &content[id_pos..];
117 let fixed_before = self.remove_trailing_punctuation(before_id, re);
118 format!("{fixed_before}{id_part}")
119 } else {
120 self.remove_trailing_punctuation(content, re)
122 };
123
124 if let Some(trailing) = captures.get(5) {
126 return format!(
127 "{}{}{}{}{}",
128 indentation,
129 hashes,
130 space,
131 fixed_content,
132 trailing.as_str()
133 );
134 }
135
136 return format!("{indentation}{hashes}{space}{fixed_content}");
137 }
138
139 line.to_string()
141 }
142
143 #[inline]
145 fn fix_setext_heading(&self, content_line: &str, re: &Regex) -> String {
146 let trimmed = content_line.trim_end();
147 let mut whitespace = "";
148
149 if content_line.len() > trimmed.len() {
151 whitespace = &content_line[trimmed.len()..];
152 }
153
154 format!("{}{}", self.remove_trailing_punctuation(trimmed, re), whitespace)
156 }
157}
158
159impl Rule for MD026NoTrailingPunctuation {
160 fn name(&self) -> &'static str {
161 "MD026"
162 }
163
164 fn description(&self) -> &'static str {
165 "Trailing punctuation in heading"
166 }
167
168 fn category(&self) -> RuleCategory {
169 RuleCategory::Heading
170 }
171
172 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
173 if !ctx.likely_has_headings() {
175 return true;
176 }
177 let punctuation = &self.config.punctuation;
179 !punctuation.chars().any(|p| ctx.content.contains(p))
180 }
181
182 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
183 let content = ctx.content;
184
185 if content.is_empty() {
187 return Ok(Vec::new());
188 }
189
190 if self.config.punctuation == DEFAULT_PUNCTUATION {
193 if !QUICK_PUNCTUATION_CHECK.is_match(content) {
194 return Ok(Vec::new());
195 }
196 } else {
197 let has_custom_punctuation = self.config.punctuation.chars().any(|c| content.contains(c));
199 if !has_custom_punctuation {
200 return Ok(Vec::new());
201 }
202 }
203
204 let has_headings = ctx.lines.iter().any(|line| line.heading.is_some());
206 if !has_headings {
207 return Ok(Vec::new());
208 }
209
210 let mut warnings = Vec::new();
211 let Ok(re) = self.get_punctuation_regex() else {
212 return Ok(warnings);
213 };
214
215 for (line_num, line_info) in ctx.lines.iter().enumerate() {
217 if let Some(heading) = &line_info.heading {
218 if !heading.is_valid {
220 continue;
221 }
222
223 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
225 continue;
226 }
227
228 let text_to_check = heading.text.clone();
232
233 if self.has_trailing_punctuation(&text_to_check, &re) {
234 if let Some(punctuation_match) = re.find(&text_to_check) {
236 let line = line_info.content(ctx.content);
237
238 let punctuation_pos_in_text = punctuation_match.start();
240 let text_pos_in_line = line.find(&heading.text).unwrap_or(heading.content_column);
241 let punctuation_start_in_line = text_pos_in_line + punctuation_pos_in_text;
242 let punctuation_len = punctuation_match.len();
243
244 let (start_line, start_col, end_line, end_col) = calculate_match_range(
245 line_num + 1, line,
247 punctuation_start_in_line,
248 punctuation_len,
249 );
250
251 let last_char = text_to_check.chars().last().unwrap_or(' ');
252 warnings.push(LintWarning {
253 rule_name: Some(self.name().to_string()),
254 line: start_line,
255 column: start_col,
256 end_line,
257 end_column: end_col,
258 message: format!("Heading '{text_to_check}' ends with punctuation '{last_char}'"),
259 severity: Severity::Warning,
260 fix: Some(Fix::new(
261 ctx.line_content_byte_range(line_num + 1),
262 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
263 self.fix_atx_heading(line, &re)
264 } else {
265 self.fix_setext_heading(line, &re)
266 },
267 )),
268 });
269 }
270 }
271 }
272 }
273
274 Ok(warnings)
275 }
276
277 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
278 if self.should_skip(ctx) {
279 return Ok(ctx.content.to_string());
280 }
281 let warnings = self.check(ctx)?;
282 if warnings.is_empty() {
283 return Ok(ctx.content.to_string());
284 }
285 let warnings =
286 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
287 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
288 .map_err(crate::rule::LintError::InvalidInput)
289 }
290
291 fn as_any(&self) -> &dyn std::any::Any {
292 self
293 }
294
295 crate::impl_rule_config_methods!(MD026Config);
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::lint_context::LintContext;
302
303 #[test]
304 fn test_no_trailing_punctuation() {
305 let rule = MD026NoTrailingPunctuation::new(None);
306 let content = "# This is a heading\n\n## Another heading";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308 let result = rule.check(&ctx).unwrap();
309 assert!(result.is_empty(), "Headings without punctuation should not be flagged");
310 }
311
312 #[test]
313 fn test_trailing_period() {
314 let rule = MD026NoTrailingPunctuation::new(None);
315 let content = "# This is a heading.\n\n## Another one.";
316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
317 let result = rule.check(&ctx).unwrap();
318 assert_eq!(result.len(), 2);
319 assert_eq!(result[0].line, 1);
320 assert_eq!(result[0].column, 20);
321 assert!(result[0].message.contains("ends with punctuation '.'"));
322 assert_eq!(result[1].line, 3);
323 assert_eq!(result[1].column, 15);
324 }
325
326 #[test]
327 fn test_trailing_comma() {
328 let rule = MD026NoTrailingPunctuation::new(None);
329 let content = "# Heading,\n## Sub-heading,";
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
331 let result = rule.check(&ctx).unwrap();
332 assert_eq!(result.len(), 2);
333 assert!(result[0].message.contains("ends with punctuation ','"));
334 }
335
336 #[test]
337 fn test_trailing_semicolon() {
338 let rule = MD026NoTrailingPunctuation::new(None);
339 let content = "# Title;\n## Subtitle;";
340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
341 let result = rule.check(&ctx).unwrap();
342 assert_eq!(result.len(), 2);
343 assert!(result[0].message.contains("ends with punctuation ';'"));
344 }
345
346 #[test]
347 fn test_custom_punctuation() {
348 let rule = MD026NoTrailingPunctuation::new(Some("!".to_string()));
349 let content = "# Important!\n## Regular heading.";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352 assert_eq!(result.len(), 1, "Only exclamation should be flagged with custom config");
353 assert_eq!(result[0].line, 1);
354 assert!(result[0].message.contains("ends with punctuation '!'"));
355 }
356
357 #[test]
358 fn test_legitimate_question_mark() {
359 let rule = MD026NoTrailingPunctuation::new(Some(".,;?".to_string()));
360 let content = "# What is this?\n# This is bad.";
361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362 let result = rule.check(&ctx).unwrap();
363 assert_eq!(result.len(), 2, "Both should be flagged with custom punctuation");
365 }
366
367 #[test]
368 fn test_question_marks_not_in_default() {
369 let rule = MD026NoTrailingPunctuation::new(None);
370 let content = "# What is Rust?\n# How does it work?\n# Is it fast?";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372 let result = rule.check(&ctx).unwrap();
373 assert!(result.is_empty(), "Question marks are not in default punctuation list");
374 }
375
376 #[test]
377 fn test_colons_in_default() {
378 let rule = MD026NoTrailingPunctuation::new(None);
379 let content = "# FAQ:\n# API Reference:\n# Step 1:\n# Version 2.0:";
380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
381 let result = rule.check(&ctx).unwrap();
382 assert_eq!(
383 result.len(),
384 4,
385 "Colons are in default punctuation list and should be flagged"
386 );
387 }
388
389 #[test]
390 fn test_fix_atx_headings() {
391 let rule = MD026NoTrailingPunctuation::new(None);
392 let content = "# Title.\n## Subtitle,\n### Sub-subtitle;";
393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394 let fixed = rule.fix(&ctx).unwrap();
395 assert_eq!(fixed, "# Title\n## Subtitle\n### Sub-subtitle");
396 }
397
398 #[test]
399 fn test_fix_setext_headings() {
400 let rule = MD026NoTrailingPunctuation::new(None);
401 let content = "Title.\n======\n\nSubtitle,\n---------";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let fixed = rule.fix(&ctx).unwrap();
404 assert_eq!(fixed, "Title\n======\n\nSubtitle\n---------");
405 }
406
407 #[test]
408 fn test_fix_preserves_trailing_hashes() {
409 let rule = MD026NoTrailingPunctuation::new(None);
410 let content = "# Title. #\n## Subtitle, ##";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let fixed = rule.fix(&ctx).unwrap();
413 assert_eq!(fixed, "# Title #\n## Subtitle ##");
414 }
415
416 #[test]
417 fn test_indented_headings() {
418 let rule = MD026NoTrailingPunctuation::new(None);
419 let content = " # Title.\n ## Subtitle.";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422 assert_eq!(result.len(), 2, "Indented headings (< 4 spaces) should be checked");
423 }
424
425 #[test]
426 fn test_deeply_indented_ignored() {
427 let rule = MD026NoTrailingPunctuation::new(None);
428 let content = " # This is code.";
429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430 let result = rule.check(&ctx).unwrap();
431 assert!(result.is_empty(), "Deeply indented lines (4+ spaces) should be ignored");
432 }
433
434 #[test]
435 fn test_multiple_punctuation() {
436 let rule = MD026NoTrailingPunctuation::new(None);
437 let content = "# Title...";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 1);
441 assert_eq!(result[0].column, 8); }
443
444 #[test]
445 fn test_empty_content() {
446 let rule = MD026NoTrailingPunctuation::new(None);
447 let content = "";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert!(result.is_empty());
451 }
452
453 #[test]
454 fn test_no_headings() {
455 let rule = MD026NoTrailingPunctuation::new(None);
456 let content = "This is just text.\nMore text with punctuation.";
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let result = rule.check(&ctx).unwrap();
459 assert!(result.is_empty(), "Non-heading lines should not be checked");
460 }
461
462 #[test]
463 fn test_get_punctuation_regex() {
464 let rule = MD026NoTrailingPunctuation::new(Some("!?".to_string()));
465 let regex = rule.get_punctuation_regex().unwrap();
466 assert!(regex.is_match("text!"));
467 assert!(regex.is_match("text?"));
468 assert!(!regex.is_match("text."));
469 }
470
471 #[test]
472 fn test_regex_caching() {
473 let rule1 = MD026NoTrailingPunctuation::new(Some("!".to_string()));
474 let rule2 = MD026NoTrailingPunctuation::new(Some("!".to_string()));
475
476 let _regex1 = rule1.get_punctuation_regex().unwrap();
478 let _regex2 = rule2.get_punctuation_regex().unwrap();
479
480 let cache = PUNCTUATION_REGEX_CACHE.read().unwrap();
482 assert!(cache.contains_key("!"));
483 }
484
485 #[test]
486 fn test_config_from_toml() {
487 let mut config = crate::config::Config::default();
488 let mut rule_config = crate::config::RuleConfig::default();
489 rule_config
490 .values
491 .insert("punctuation".to_string(), toml::Value::String("!?".to_string()));
492 config.rules.insert("MD026".to_string(), rule_config);
493
494 let rule = MD026NoTrailingPunctuation::from_config(&config);
495 let content = "# Title!\n# Another?";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert_eq!(result.len(), 2, "Custom punctuation from config should be used");
499 }
500
501 #[test]
502 fn test_fix_removes_punctuation() {
503 let rule = MD026NoTrailingPunctuation::new(None);
504 let content = "# Title. \n## Subtitle, ";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let fixed = rule.fix(&ctx).unwrap();
507 assert_eq!(fixed, "# Title\n## Subtitle");
509 }
510
511 #[test]
512 fn test_final_newline_preservation() {
513 let rule = MD026NoTrailingPunctuation::new(None);
514 let content = "# Title.\n";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let fixed = rule.fix(&ctx).unwrap();
517 assert_eq!(fixed, "# Title\n");
518
519 let content_no_newline = "# Title.";
520 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
521 let fixed2 = rule.fix(&ctx2).unwrap();
522 assert_eq!(fixed2, "# Title");
523 }
524}