1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::FlavorOverrideNotice;
6use crate::utils::regex_cache::{EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX};
7use regex::Regex;
8use std::collections::HashMap;
9use std::ops::Range;
10use std::sync::LazyLock;
11use std::sync::RwLock;
12
13mod md026_config;
14use md026_config::{DEFAULT_PUNCTUATION, MD026Config};
15
16static QUICK_PUNCTUATION_CHECK: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(&format!(r"[{}]", regex::escape(DEFAULT_PUNCTUATION))).unwrap());
19
20static PUNCTUATION_REGEX_CACHE: LazyLock<RwLock<HashMap<String, Regex>>> =
22 LazyLock::new(|| RwLock::new(HashMap::new()));
23
24static MDG_PUNCTUATION_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
26
27#[derive(Clone)]
29pub struct MD026NoTrailingPunctuation {
30 config: MD026Config,
31 mdg_punctuation: String,
35 colon_configured_explicitly: bool,
38}
39
40impl Default for MD026NoTrailingPunctuation {
41 fn default() -> Self {
42 Self::new(None)
43 }
44}
45
46impl MD026NoTrailingPunctuation {
47 pub(crate) fn default_source_has_trailing_punctuation(text: &str) -> bool {
50 text.trim_end()
51 .chars()
52 .next_back()
53 .is_some_and(|character| DEFAULT_PUNCTUATION.contains(character))
54 }
55
56 pub fn new(punctuation: Option<String>) -> Self {
57 let explicit = punctuation.is_some();
58 Self::build(
59 MD026Config {
60 punctuation: punctuation.unwrap_or_else(|| DEFAULT_PUNCTUATION.to_string()),
61 },
62 explicit,
63 )
64 }
65
66 pub fn from_config_struct(config: MD026Config) -> Self {
67 Self::build(config, false)
68 }
69
70 fn build(config: MD026Config, punctuation_explicit: bool) -> Self {
71 let colon_configured_explicitly = punctuation_explicit && config.punctuation.contains(':');
72 let mdg_punctuation = config.punctuation.replace(':', "");
73
74 Self {
75 config,
76 mdg_punctuation,
77 colon_configured_explicitly,
78 }
79 }
80
81 #[inline]
83 fn effective_punctuation(&self, flavor: crate::config::MarkdownFlavor) -> &str {
84 if flavor == crate::config::MarkdownFlavor::MDG {
85 &self.mdg_punctuation
86 } else {
87 &self.config.punctuation
88 }
89 }
90
91 fn mdg_colon_override_applies(&self, flavor: crate::config::MarkdownFlavor) -> bool {
93 flavor == crate::config::MarkdownFlavor::MDG && self.colon_configured_explicitly
94 }
95
96 fn warn_once_about_mdg_colon_override(&self, flavor: crate::config::MarkdownFlavor) {
101 if self.mdg_colon_override_applies(flavor) {
102 MDG_PUNCTUATION_OVERRIDE.report(
103 "MD026",
104 "punctuation",
105 &self.config.punctuation,
106 &self.mdg_punctuation,
107 "the ASCII colon after a Gherkin keyword is structural",
108 );
109 }
110 }
111
112 #[inline]
113 fn get_punctuation_regex(&self, punctuation: &str) -> Result<Regex, regex::Error> {
114 {
116 let cache = PUNCTUATION_REGEX_CACHE.read().unwrap();
117 if let Some(cached_regex) = cache.get(punctuation) {
118 return Ok(cached_regex.clone());
119 }
120 }
121
122 let pattern = format!(r"([{}]+)$", regex::escape(punctuation));
124 let regex = Regex::new(&pattern)?;
125
126 {
127 let mut cache = PUNCTUATION_REGEX_CACHE.write().unwrap();
128 cache.insert(punctuation.to_string(), regex.clone());
129 }
130
131 Ok(regex)
132 }
133
134 fn trailing_punctuation_run(&self, text: &str, re: &Regex) -> Option<Range<usize>> {
145 let mut start = re.find(text)?.start();
146 let constructs = HTML_ENTITY_REGEX
147 .find_iter(text)
148 .chain(EMOJI_SHORTCODE_REGEX.find_iter(text));
149 for construct in constructs {
150 if Self::is_backslash_escaped(text, construct.start()) {
151 continue;
152 }
153 start = start.max(construct.end());
154 }
155 (start < text.len()).then_some(start..text.len())
156 }
157
158 fn is_backslash_escaped(text: &str, pos: usize) -> bool {
162 text[..pos].bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
163 }
164
165 #[inline]
175 fn remove_trailing_punctuation(&self, text: &str, re: &Regex) -> String {
176 let mut result = text.trim().to_string();
177 loop {
178 let Some(run) = self.trailing_punctuation_run(&result, re) else {
179 return result;
181 };
182 result.truncate(run.start);
183 let trimmed_len = result.trim_end().len();
186 if trimmed_len != result.len() && self.trailing_punctuation_run(&result[..trimmed_len], re).is_some() {
187 result.truncate(trimmed_len);
188 } else {
189 return result;
190 }
191 }
192 }
193}
194
195fn removed_source_range(source: &str, end: usize, removed: &str) -> Option<Range<usize>> {
202 let mut source = source.get(..end)?;
203 let mut rest = removed;
204 while !rest.is_empty() {
205 let text = rest.trim_end();
206 if text.len() < rest.len() {
207 let trimmed = source.trim_end();
208 if trimmed.len() == source.len() {
209 return None;
210 }
211 source = trimmed;
212 rest = text;
213 } else {
214 let ch = rest.chars().next_back()?;
215 source = source.strip_suffix(ch)?;
216 rest = &rest[..rest.len() - ch.len_utf8()];
217 }
218 }
219 Some(source.len()..end)
220}
221
222fn exposes_closing_sequence(remaining: &str) -> bool {
228 let kept = remaining.trim_end_matches([' ', '\t']);
229 if !kept.ends_with('#') {
230 return false;
231 }
232 let before_hashes = kept.trim_end_matches('#');
233 before_hashes.is_empty() || before_hashes.ends_with([' ', '\t'])
234}
235
236fn ends_the_heading_text(rest: &str) -> bool {
240 let rest_end = rest.trim_end_matches([' ', '\t']);
241 let attributes = rest_end.trim_start_matches([' ', '\t']);
242 attributes.is_empty()
243 || (attributes.len() < rest_end.len() && attributes.starts_with('{') && attributes.ends_with('}'))
244}
245
246fn block_skeleton(content: &str) -> Vec<String> {
248 use pulldown_cmark::{Event, Parser, Tag};
249 Parser::new_ext(content, crate::utils::parser_options::rumdl_parser_options_for(content))
250 .filter_map(|event| match event {
251 Event::Start(Tag::Heading { level, .. }) => Some(format!("h{}", level as u8)),
252 Event::Start(
253 tag @ (Tag::List(_)
254 | Tag::Item
255 | Tag::BlockQuote(_)
256 | Tag::Paragraph
257 | Tag::CodeBlock(_)
258 | Tag::HtmlBlock
259 | Tag::Table(_)),
260 ) => Some(
261 format!("{tag:?}")
262 .split(['(', ' ', '{'])
263 .next()
264 .unwrap_or("")
265 .to_string(),
266 ),
267 Event::Rule => Some("rule".to_string()),
268 _ => None,
269 })
270 .collect()
271}
272
273fn line_cannot_open_block(line: &str) -> bool {
280 let line = line.trim_start_matches(|c: char| c.is_whitespace() || c == '>');
281 let mut chars = line.chars();
282 let Some(first) = chars.next() else {
283 return false;
284 };
285 let second = chars.next();
286 match first {
287 c if !c.is_ascii() || c.is_ascii_alphabetic() => true,
289 '0'..='9' => {
291 let after_digits = line.trim_start_matches(|c: char| c.is_ascii_digit());
292 match after_digits.strip_prefix(['.', ')']) {
293 Some(after_marker) => !(after_marker.is_empty() || after_marker.starts_with([' ', '\t'])),
294 None => true,
295 }
296 }
297 '"' | '\'' | '(' | ',' | '.' | '?' | '&' | '@' | '\\' => true,
298 '*' | '_' => {
301 second.is_some_and(|c| !matches!(c, ' ' | '\t'))
302 && line.chars().any(|c| !matches!(c, '*' | '_' | '-' | '=' | ' ' | '\t'))
303 }
304 '`' | '~' => line.chars().take_while(|&c| c == first).count() < 3,
306 '[' => !line.contains("]:"),
308 '!' => second == Some('['),
309 _ => false,
310 }
311}
312
313fn edited_line_cannot_open_block(before: &str, rest: &str) -> bool {
318 let mut before = before;
319 loop {
320 before = before.trim_start_matches(|c: char| c.is_whitespace() || c == '>');
321 let after_digits = before.trim_start_matches(|c: char| c.is_ascii_digit());
322 let marker_len = if before.starts_with(['-', '+', '*']) {
323 1
324 } else if after_digits.len() < before.len() && after_digits.starts_with(['.', ')']) {
325 before.len() - after_digits.len() + 1
326 } else {
327 0
328 };
329 match before.get(marker_len..) {
330 Some(after_marker) if marker_len > 0 && after_marker.starts_with([' ', '\t']) => before = after_marker,
331 _ => break,
332 }
333 }
334 line_cannot_open_block(&format!("{before}{rest}"))
335}
336
337struct BlockWindow {
341 range: Range<usize>,
342 prefix_width: usize,
343}
344
345fn block_windows(content: &str) -> Vec<BlockWindow> {
348 use pulldown_cmark::{Event, Parser, Tag};
349 let parser = Parser::new_ext(content, crate::utils::parser_options::rumdl_parser_options_for(content));
350 let mut windows = Vec::new();
351 let mut depth = 0usize;
353 let mut leaf_depth: Option<usize> = None;
354 let push = |range: &Range<usize>, windows: &mut Vec<BlockWindow>| {
355 let start = content[..range.start].rfind('\n').map_or(0, |i| i + 1);
356 windows.push(BlockWindow {
357 range: start..range.end,
358 prefix_width: range.start - start,
359 });
360 };
361 for (event, range) in parser.into_offset_iter() {
362 match event {
363 Event::Start(tag) => {
364 if leaf_depth.is_none() && !matches!(tag, Tag::List(_) | Tag::Item | Tag::BlockQuote(_)) {
365 push(&range, &mut windows);
366 leaf_depth = Some(depth);
367 }
368 depth += 1;
369 }
370 Event::End(_) => {
371 depth = depth.saturating_sub(1);
372 if leaf_depth == Some(depth) {
373 leaf_depth = None;
374 }
375 }
376 Event::Rule if leaf_depth.is_none() => push(&range, &mut windows),
377 _ => {}
378 }
379 }
380 windows
381}
382
383fn expand_tabs(prefix: &str, mut column: usize) -> String {
386 let mut out = String::with_capacity(prefix.len());
387 for c in prefix.chars() {
388 if c == '\t' {
389 let width = 4 - column % 4;
390 out.extend(std::iter::repeat_n(' ', width));
391 column += width;
392 } else {
393 out.push(c);
394 column += 1;
395 }
396 }
397 out
398}
399
400fn strip_container_prefix(unit: &str, prefix_width: usize) -> String {
406 let mut out = String::with_capacity(unit.len());
407 let mut width = 0;
408 for (i, line) in unit.split_inclusive('\n').enumerate() {
409 if i == 0 {
410 let (prefix, rest) = line.split_at(prefix_width);
411 width = expand_tabs(prefix, 0).len();
412 out.push_str(rest);
413 } else {
414 let run = line.find(|c| !matches!(c, ' ' | '\t' | '>')).unwrap_or(line.len());
415 let expanded = expand_tabs(&line[..run], 0);
416 let strip = expanded
417 .bytes()
418 .take(width)
419 .take_while(|b| matches!(b, b' ' | b'>'))
420 .count();
421 out.push_str(&expanded[strip..]);
422 out.push_str(&line[run..]);
423 }
424 }
425 out
426}
427
428fn deletion_keeps_block_structure(content: &str, range: &Range<usize>, windows: &mut Option<Vec<BlockWindow>>) -> bool {
436 let line_start = content[..range.start].rfind('\n').map_or(0, |i| i + 1);
437 let line_end = content[range.end..].find('\n').map_or(content.len(), |i| range.end + i);
438 if edited_line_cannot_open_block(&content[line_start..range.start], &content[range.end..line_end]) {
439 return true;
440 }
441 let windows = windows.get_or_insert_with(|| block_windows(content));
442 let idx = windows.partition_point(|w| w.range.end < range.end);
443 let (window, prefix_width) = windows
444 .get(idx)
445 .filter(|w| w.range.start <= range.start)
446 .map_or((0..content.len(), 0), |w| (w.range.clone(), w.prefix_width));
447 let unit = &content[window.clone()];
448 let mut edited = String::with_capacity(unit.len());
449 edited.push_str(&content[window.start..range.start]);
450 edited.push_str(&content[range.end..window.end]);
451 let before = strip_container_prefix(unit, prefix_width);
452 let after = strip_container_prefix(&edited, prefix_width);
453 block_skeleton(&before) == block_skeleton(&after)
454}
455
456impl Rule for MD026NoTrailingPunctuation {
457 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
458 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(26))
459 }
460
461 fn name(&self) -> &'static str {
462 "MD026"
463 }
464
465 fn description(&self) -> &'static str {
466 "Trailing punctuation in heading"
467 }
468
469 fn category(&self) -> RuleCategory {
470 RuleCategory::Heading
471 }
472
473 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
474 self.warn_once_about_mdg_colon_override(ctx.flavor);
475
476 if !ctx.likely_has_headings() {
478 return true;
479 }
480 let punctuation = self.effective_punctuation(ctx.flavor);
482 !punctuation.chars().any(|p| ctx.has_char(p))
483 }
484
485 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
486 let content = ctx.content;
487 let punctuation = self.effective_punctuation(ctx.flavor);
488
489 self.warn_once_about_mdg_colon_override(ctx.flavor);
490
491 if content.is_empty() {
493 return Ok(Vec::new());
494 }
495
496 if punctuation == DEFAULT_PUNCTUATION {
499 if !QUICK_PUNCTUATION_CHECK.is_match(content) {
500 return Ok(Vec::new());
501 }
502 } else {
503 let has_custom_punctuation = punctuation.chars().any(|c| content.contains(c));
505 if !has_custom_punctuation {
506 return Ok(Vec::new());
507 }
508 }
509
510 if ctx.headings().next().is_none() {
512 return Ok(Vec::new());
513 }
514
515 let mut warnings = Vec::new();
516 let Ok(re) = self.get_punctuation_regex(punctuation) else {
517 return Ok(warnings);
518 };
519
520 let mut windows = None;
524 for parsed in ctx.headings() {
525 let heading = parsed.heading;
526 let is_setext = parsed.is_setext();
527 let marker_idx = if is_setext {
528 parsed.line_num
529 } else {
530 parsed.line_num - 1
531 };
532 if ctx
533 .lines
534 .get(marker_idx)
535 .is_some_and(|marker_line| ctx.is_inside_template_code(marker_line.byte_offset + marker_line.indent))
536 {
537 continue;
538 }
539
540 let text_to_check = heading.text.as_str();
544
545 let Some(run) = self.trailing_punctuation_run(text_to_check, &re) else {
546 continue;
547 };
548
549 let range = parsed.text_byte_range(ctx.content);
555 let run_text = &text_to_check[run.clone()];
556 let run_start = range
557 .end
558 .checked_sub(run_text.len())
559 .filter(|&start| ctx.content.get(start..range.end) == Some(run_text));
560 if run_start.is_some_and(|start| ctx.overlaps_template_code(start, range.end)) {
561 continue;
562 }
563 let (start_line, start_col) = ctx.offset_to_line_col(run_start.unwrap_or(range.start));
564 let (end_line, end_col) = ctx.offset_to_line_col(range.end);
565
566 let fix = run_start.and_then(|start| {
576 let fixed = self.remove_trailing_punctuation(text_to_check, &re);
577 let removed = text_to_check.trim().strip_prefix(fixed.as_str()).unwrap_or(run_text);
578 let mut delete = removed_source_range(ctx.content, range.end, removed).unwrap_or(start..range.end);
579 if !is_setext {
580 let tail = &ctx.content[range.end..];
584 let line_tail = tail.split(['\n', '\r']).next().unwrap_or("");
585 if line_tail.chars().all(|c| c == ' ' || c == '\t') {
586 delete.end = range.end + line_tail.len();
587 }
588 }
589 if !is_setext && !heading.has_closing_sequence {
593 let rest = ctx.content[delete.end..].split(['\n', '\r']).next().unwrap_or("");
594 if ends_the_heading_text(rest) && exposes_closing_sequence(&fixed) {
595 return None;
596 }
597 }
598 if is_setext && !deletion_keeps_block_structure(ctx.content, &delete, &mut windows) {
599 return None;
600 }
601 Some(Fix::new(delete, String::new()))
602 });
603
604 let fix = fix.filter(|fix| !ctx.overlaps_template_code(fix.range.start, fix.range.end));
605
606 let last_char = text_to_check.chars().last().unwrap_or(' ');
607 warnings.push(LintWarning {
608 rule_name: Some(self.name().to_string()),
609 line: start_line,
610 column: start_col,
611 end_line,
612 end_column: end_col,
613 message: format!("Heading '{text_to_check}' ends with punctuation '{last_char}'"),
614 severity: Severity::Warning,
615 fix,
616 });
617 }
618
619 Ok(warnings)
620 }
621
622 fn fixes_from_diagnostics(&self) -> bool {
623 true
624 }
625
626 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
627 if self.should_skip(ctx) {
628 return Ok(ctx.content.to_string());
629 }
630 let warnings = self.check(ctx)?;
631 if warnings.is_empty() {
632 return Ok(ctx.content.to_string());
633 }
634 let warnings =
635 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
636 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
637 .map_err(crate::rule::LintError::InvalidInput)
638 }
639
640 fn as_any(&self) -> &dyn std::any::Any {
641 self
642 }
643
644 crate::impl_rule_config_sections!(MD026Config);
645
646 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
647 where
648 Self: Sized,
649 {
650 let rule_config = crate::rule_config_serde::load_rule_config::<MD026Config>(config);
651
652 let punctuation_explicit = config
655 .rules
656 .get("MD026")
657 .is_some_and(|rule_cfg| rule_cfg.values.contains_key("punctuation"));
658
659 Box::new(Self::build(rule_config, punctuation_explicit))
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::lint_context::LintContext;
667
668 #[test]
669 fn test_no_trailing_punctuation() {
670 let rule = MD026NoTrailingPunctuation::new(None);
671 let content = "# This is a heading\n\n## Another heading";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let result = rule.check(&ctx).unwrap();
674 assert!(result.is_empty(), "Headings without punctuation should not be flagged");
675 }
676
677 #[test]
678 fn test_trailing_period() {
679 let rule = MD026NoTrailingPunctuation::new(None);
680 let content = "# This is a heading.\n\n## Another one.";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682 let result = rule.check(&ctx).unwrap();
683 assert_eq!(result.len(), 2);
684 assert_eq!(result[0].line, 1);
685 assert_eq!(result[0].column, 20);
686 assert!(result[0].message.contains("ends with punctuation '.'"));
687 assert_eq!(result[1].line, 3);
688 assert_eq!(result[1].column, 15);
689 }
690
691 #[test]
692 fn test_trailing_comma() {
693 let rule = MD026NoTrailingPunctuation::new(None);
694 let content = "# Heading,\n## Sub-heading,";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697 assert_eq!(result.len(), 2);
698 assert!(result[0].message.contains("ends with punctuation ','"));
699 }
700
701 #[test]
702 fn test_trailing_semicolon() {
703 let rule = MD026NoTrailingPunctuation::new(None);
704 let content = "# Title;\n## Subtitle;";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert_eq!(result.len(), 2);
708 assert!(result[0].message.contains("ends with punctuation ';'"));
709 }
710
711 #[test]
712 fn test_custom_punctuation() {
713 let rule = MD026NoTrailingPunctuation::new(Some("!".to_string()));
714 let content = "# Important!\n## Regular heading.";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert_eq!(result.len(), 1, "Only exclamation should be flagged with custom config");
718 assert_eq!(result[0].line, 1);
719 assert!(result[0].message.contains("ends with punctuation '!'"));
720 }
721
722 #[test]
723 fn test_legitimate_question_mark() {
724 let rule = MD026NoTrailingPunctuation::new(Some(".,;?".to_string()));
725 let content = "# What is this?\n# This is bad.";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert_eq!(result.len(), 2, "Both should be flagged with custom punctuation");
730 }
731
732 #[test]
733 fn test_question_marks_not_in_default() {
734 let rule = MD026NoTrailingPunctuation::new(None);
735 let content = "# What is Rust?\n# How does it work?\n# Is it fast?";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738 assert!(result.is_empty(), "Question marks are not in default punctuation list");
739 }
740
741 #[test]
742 fn test_colons_in_default() {
743 let rule = MD026NoTrailingPunctuation::new(None);
744 let content = "# FAQ:\n# API Reference:\n# Step 1:\n# Version 2.0:";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(
748 result.len(),
749 4,
750 "Colons are in default punctuation list and should be flagged"
751 );
752 }
753
754 #[test]
755 fn test_fix_atx_headings() {
756 let rule = MD026NoTrailingPunctuation::new(None);
757 let content = "# Title.\n## Subtitle,\n### Sub-subtitle;";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let fixed = rule.fix(&ctx).unwrap();
760 assert_eq!(fixed, "# Title\n## Subtitle\n### Sub-subtitle");
761 }
762
763 #[test]
764 fn test_fix_atx_heading_on_a_list_marker_line_keeps_the_marker() {
765 let rule = MD026NoTrailingPunctuation::new(None);
766 for (content, expected) in [
767 ("- ## a.\n", "- ## a\n"),
768 ("1. # a,\n", "1. # a\n"),
769 ("- - ## a: ##\n", "- - ## a ##\n"),
770 (" ## a.\n", " ## a\n"),
771 ] {
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
774 }
775 }
776
777 fn fix_of(content: &str) -> String {
778 let rule = MD026NoTrailingPunctuation::new(None);
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 rule.fix(&ctx).unwrap()
781 }
782
783 fn check_of(content: &str) -> Vec<crate::rule::LintWarning> {
784 let rule = MD026NoTrailingPunctuation::new(None);
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 rule.check(&ctx).unwrap()
787 }
788
789 #[test]
790 fn test_headings_inside_containers_are_checked_and_fixed() {
791 for (content, expected) in [
792 ("> ## a.\n", "> ## a\n"),
793 ("> # a:\n", "> # a\n"),
794 (">## a.\n", ">## a\n"),
795 ("> > ## a.\n", "> > ## a\n"),
796 ("> - ## a.\n", "> - ## a\n"),
797 ("- > ## a.\n", "- > ## a\n"),
798 ("- x\n\n > ## a.\n", "- x\n\n > ## a\n"),
799 ("- x\n\n > ## a.\n", "- x\n\n > ## a\n"),
800 ("- x\n\n ## a.\n", "- x\n\n ## a\n"),
801 ("> ## a. ##\n", "> ## a ##\n"),
802 ("> ## a. {#id}\n", "> ## a {#id}\n"),
803 ("> ## a. ## {#id}\n", "> ## a ## {#id}\n"),
804 ("> a.\n> ===\n", "> a\n> ===\n"),
805 ("> - a.\n> ---\n", "> - a\n> ---\n"),
806 ] {
807 let warnings = check_of(content);
808 assert_eq!(warnings.len(), 1, "{content:?}");
809 assert!(warnings[0].fix.is_some(), "{content:?} must be fixable");
810 assert_eq!(fix_of(content), expected, "{content:?}");
811 assert!(check_of(&fix_of(content)).is_empty(), "{content:?} not clean after fix");
812 }
813 }
814
815 #[test]
816 fn test_quoted_heading_warning_range_excludes_the_quote_prefix() {
817 let warnings = check_of("> ## Title.\n");
818 assert_eq!(warnings.len(), 1);
819 assert_eq!((warnings[0].line, warnings[0].column), (1, 11));
820 assert_eq!((warnings[0].end_line, warnings[0].end_column), (1, 12));
821 }
822
823 #[test]
824 fn test_container_text_that_is_not_a_heading_is_not_flagged() {
825 for content in [
826 "> a.\n",
827 "> ##a.\n",
828 "> text\n>\n> ## a.\n",
829 " ## a.\n",
830 "- x\n\n ## a.\n",
831 "```\n> ## a.\n```\n",
832 ] {
833 assert!(check_of(content).is_empty(), "{content:?}");
834 }
835 }
836
837 #[test]
838 fn test_deletion_fix_handles_closing_sequences_of_any_length() {
839 for (content, expected) in [
840 ("# a. #######\n", "# a #######\n"),
841 ("# a. ## \n", "# a ## \n"),
842 ("> ## a. #######\n", "> ## a #######\n"),
843 ] {
844 assert_eq!(fix_of(content), expected, "{content:?}");
845 }
846 }
847
848 #[test]
849 fn test_fix_keeps_trailing_unicode_whitespace() {
850 assert_eq!(fix_of("# a.\u{2003}\n"), "# a\u{2003}\n");
851 assert_eq!(fix_of("> ## a.\u{2003}\n"), "> ## a\u{2003}\n");
852 assert_eq!(fix_of("# a.\u{a0}\n"), "# a\u{a0}\n");
853 }
854
855 #[test]
856 fn test_fix_declines_when_removal_would_expose_a_closing_sequence() {
857 for content in ["# a #.\n", "> ## a #.\n", "## #.\n", "- ## a ###.\n"] {
858 let warnings = check_of(content);
859 assert_eq!(warnings.len(), 1, "{content:?}");
860 assert!(warnings[0].fix.is_none(), "{content:?} must not offer a fix");
861 assert_eq!(fix_of(content), content, "{content:?}");
862 }
863 assert_eq!(fix_of("# a\\#.\n"), "# a\\#\n");
864 assert_eq!(fix_of("# a#.\n"), "# a#\n");
865 assert_eq!(fix_of("# a. #\n"), "# a #\n");
866 }
867
868 #[test]
869 fn test_setext_fix_declines_when_removal_would_change_block_structure() {
870 for content in [
871 "a\n.\n===\n",
872 "> a\n> .\n> ===\n",
873 "> .\n> ===\n",
874 "> -.\n> ===\n",
875 "> ___.\n> ===\n",
876 "- item\n\n > .\n > ===\n",
877 ] {
878 let warnings = check_of(content);
879 assert_eq!(warnings.len(), 1, "{content:?}");
880 assert!(warnings[0].fix.is_none(), "{content:?} must not offer a fix");
881 assert_eq!(fix_of(content), content, "{content:?}");
882 }
883 }
884
885 #[test]
886 fn test_line_cannot_open_block() {
887 for (line, expected) in [
888 ("a", true),
889 ("> Version 1.2.3", true),
890 ("1.2.3", true),
891 ("2024", true),
892 ("1x", true),
893 ("1.x", true),
894 ("\"quoted\"", true),
895 ("(note)", true),
896 ("*bold* text", true),
897 ("_em_", true),
898 ("`a`", true),
899 ("``a``", true),
900 ("~~a~~", true),
901 ("**Bold**", true),
902 ("[link](x)", true),
903 ("", true),
904 ("\u{201c}quoted\u{201d}", true),
905 ("\\# a", true),
906 ("1.", false),
908 ("1. ", false),
909 ("1)\t", false),
910 ("- ", false),
911 ("-", false),
912 ("*", false),
913 ("* ", false),
914 ("***", false),
915 ("___", false),
916 ("```", false),
917 ("```rust", false),
918 ("~~~", false),
919 ("***", false),
920 ("* *", false),
921 ("**", false),
922 ("[ref]: x", false),
923 ("[^1]: x", false),
924 ("!!! note", false),
925 ("|a|", false),
926 (":::", false),
927 ("$$", false),
928 ("=", false),
929 ("<div>", false),
930 ("[ref]: x", false),
931 ("#", false),
932 ("", false),
933 ("> ", false),
934 ] {
935 assert_eq!(line_cannot_open_block(line), expected, "{line:?}");
936 }
937 }
938
939 #[test]
940 fn test_edited_line_cannot_open_block_skips_unchanged_markers() {
941 for (before, rest, expected) in [
942 ("- a", "", true),
943 ("> - a", "", true),
944 ("1. a", "", true),
945 (" * Version 1.2", "", true),
946 ("- - a", "", true),
947 ("-", " a", false),
949 ("- ", "", false),
950 ("- -", "", false),
951 ("- 1.", "", false),
952 ("> -", "", false),
953 ("1.", " a", false),
954 ("", "", false),
955 ] {
956 assert_eq!(
957 edited_line_cannot_open_block(before, rest),
958 expected,
959 "{before:?} + {rest:?}"
960 );
961 }
962 }
963
964 fn container_headings() -> Vec<String> {
967 let mut docs = Vec::new();
968 for (first, rest) in [
969 ("", ""),
970 ("- ", " "),
971 ("> ", "> "),
972 ("1. ", " "),
973 ("> - ", "> "),
974 ("- > ", " > "),
975 (" - ", " "),
976 ("* ", " "),
977 ] {
978 for body in [
979 "a.",
980 ".",
981 "-.",
982 "1.2.",
983 "#x.",
984 "> a.",
985 "* *.",
986 "***.",
987 "<span>a</span>.",
988 "a:",
989 ] {
990 for underline in ["===", "---"] {
991 let blank = rest.trim_end();
992 docs.push(format!(
993 "{first}{body}\n{rest}{underline}\n{blank}\n{rest}{body}\n{rest}{underline}\n"
994 ));
995 }
996 }
997 }
998 docs
999 }
1000
1001 #[test]
1004 fn test_windowed_structure_check_matches_the_whole_document() {
1005 for content in [
1006 "intro\n\na\n.\n===\n\nafter\n",
1007 "- x\n\n- .\n ===\n\n- y.\n ---\n",
1008 "- item\n\n > .\n > ===\n\ntail\n",
1009 "> a\n> .\n> ===\n\n> b.\n> ===\n",
1010 "para\n\n> -.\n> ===\n\n1. z\n\n 2) .\n ===\n",
1011 "```\ncode\n```\n\n___.\n===\n\n- - .\n ===\n",
1012 "> a.\n> ===\n>\n> ---\n>\n> b,\n> ---\n>\n> -.\n> ===\n>\n> - c.\n> ===\n",
1013 "- item\n\n a.\n ===\n\n -.\n ===\n\n b,\n ---\n",
1014 "1. item\n\n > x.\n > ===\n >\n > .\n > ===\n",
1015 "-\t.\n\t===\n\n- y.\n ===\n",
1016 "-\t<span>a</span>.\n\t===\n\n-\t<b>c</b>,\n\t---\n",
1017 ">\t- x.\n>\t ===\n",
1018 ]
1019 .map(String::from)
1020 .into_iter()
1021 .chain(container_headings())
1022 {
1023 let content = content.as_str();
1024 let mut windows = None;
1025 for (i, c) in content.char_indices() {
1026 let at_line_end = content[i + 1..].starts_with('\n');
1028 if !matches!(c, '.' | ',' | ':') || !at_line_end {
1029 continue;
1030 }
1031 let range = i..i + 1;
1032 let mut edited = content.to_string();
1033 edited.replace_range(range.clone(), "");
1034 let whole = block_skeleton(content) == block_skeleton(&edited);
1035 assert_eq!(
1036 deletion_keeps_block_structure(content, &range, &mut windows),
1037 whole,
1038 "{content:?} at {i}"
1039 );
1040 }
1041 }
1042 }
1043
1044 #[test]
1045 fn test_setext_fix_applies_when_structure_is_kept() {
1046 for (content, expected) in [
1047 ("a.\n===\n", "a\n===\n"),
1048 ("> a.\n> ===\n", "> a\n> ===\n"),
1049 ("a\nb.\n===\n", "a\nb\n===\n"),
1050 ("- a.\n ===\n", "- a\n ===\n"),
1051 ("1. a.\n ===\n", "1. a\n ===\n"),
1052 ("1.2.3.\n======\n", "1.2.3\n======\n"),
1053 ("> *Bold.*.\n> ===\n", "> *Bold.*\n> ===\n"),
1054 ] {
1055 assert_eq!(fix_of(content), expected, "{content:?}");
1056 }
1057 }
1058
1059 #[test]
1060 fn test_fix_setext_headings() {
1061 let rule = MD026NoTrailingPunctuation::new(None);
1062 let content = "Title.\n======\n\nSubtitle,\n---------";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1064 let fixed = rule.fix(&ctx).unwrap();
1065 assert_eq!(fixed, "Title\n======\n\nSubtitle\n---------");
1066 }
1067
1068 #[test]
1069 fn test_fix_preserves_trailing_hashes() {
1070 let rule = MD026NoTrailingPunctuation::new(None);
1071 let content = "# Title. #\n## Subtitle, ##";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let fixed = rule.fix(&ctx).unwrap();
1074 assert_eq!(fixed, "# Title #\n## Subtitle ##");
1075 }
1076
1077 #[test]
1078 fn test_indented_headings() {
1079 let rule = MD026NoTrailingPunctuation::new(None);
1080 let content = " # Title.\n ## Subtitle.";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert_eq!(result.len(), 2, "Indented headings (< 4 spaces) should be checked");
1084 }
1085
1086 #[test]
1087 fn test_deeply_indented_ignored() {
1088 let rule = MD026NoTrailingPunctuation::new(None);
1089 let content = " # This is code.";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let result = rule.check(&ctx).unwrap();
1092 assert!(result.is_empty(), "Deeply indented lines (4+ spaces) should be ignored");
1093 }
1094
1095 #[test]
1096 fn test_multiple_punctuation() {
1097 let rule = MD026NoTrailingPunctuation::new(None);
1098 let content = "# Title...";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert_eq!(result.len(), 1);
1102 assert_eq!(result[0].column, 8); }
1104
1105 #[test]
1106 fn test_empty_content() {
1107 let rule = MD026NoTrailingPunctuation::new(None);
1108 let content = "";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(result.is_empty());
1112 }
1113
1114 #[test]
1115 fn test_no_headings() {
1116 let rule = MD026NoTrailingPunctuation::new(None);
1117 let content = "This is just text.\nMore text with punctuation.";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120 assert!(result.is_empty(), "Non-heading lines should not be checked");
1121 }
1122
1123 #[test]
1124 fn test_get_punctuation_regex() {
1125 let rule = MD026NoTrailingPunctuation::new(Some("!?".to_string()));
1126 let regex = rule.get_punctuation_regex(&rule.config.punctuation).unwrap();
1127 assert!(regex.is_match("text!"));
1128 assert!(regex.is_match("text?"));
1129 assert!(!regex.is_match("text."));
1130 }
1131
1132 #[test]
1133 fn test_regex_caching() {
1134 let rule1 = MD026NoTrailingPunctuation::new(Some("!".to_string()));
1135 let rule2 = MD026NoTrailingPunctuation::new(Some("!".to_string()));
1136
1137 let _regex1 = rule1.get_punctuation_regex(&rule1.config.punctuation).unwrap();
1139 let _regex2 = rule2.get_punctuation_regex(&rule2.config.punctuation).unwrap();
1140
1141 let cache = PUNCTUATION_REGEX_CACHE.read().unwrap();
1143 assert!(cache.contains_key("!"));
1144 }
1145
1146 #[test]
1147 fn test_config_from_toml() {
1148 let mut config = crate::config::Config::default();
1149 let mut rule_config = crate::config::RuleConfig::default();
1150 rule_config
1151 .values
1152 .insert("punctuation".to_string(), toml::Value::String("!?".to_string()));
1153 config.rules.insert("MD026".to_string(), rule_config);
1154
1155 let rule = MD026NoTrailingPunctuation::from_config(&config);
1156 let content = "# Title!\n# Another?";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert_eq!(result.len(), 2, "Custom punctuation from config should be used");
1160 }
1161
1162 #[test]
1163 fn test_fix_removes_punctuation() {
1164 let rule = MD026NoTrailingPunctuation::new(None);
1165 let content = "# Title. \n## Subtitle, ";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let fixed = rule.fix(&ctx).unwrap();
1168 assert_eq!(fixed, "# Title\n## Subtitle");
1170 }
1171
1172 #[test]
1173 fn test_final_newline_preservation() {
1174 let rule = MD026NoTrailingPunctuation::new(None);
1175 let content = "# Title.\n";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let fixed = rule.fix(&ctx).unwrap();
1178 assert_eq!(fixed, "# Title\n");
1179
1180 let content_no_newline = "# Title.";
1181 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
1182 let fixed2 = rule.fix(&ctx2).unwrap();
1183 assert_eq!(fixed2, "# Title");
1184 }
1185
1186 #[test]
1192 fn test_mdg_punctuation_matrix() {
1193 let rule = MD026NoTrailingPunctuation::new(None);
1194 let cases = [
1196 ("## Notes:\n", 1, "## Notes\n", 0, "## Notes:\n"),
1197 ("## Scenario!:\n", 1, "## Scenario\n", 0, "## Scenario!:\n"),
1198 ("# Scenario! :\n", 1, "# Scenario\n", 0, "# Scenario! :\n"),
1199 ("## Scenario!\n", 1, "## Scenario\n", 1, "## Scenario\n"),
1200 ("## Notes::\n", 1, "## Notes\n", 0, "## Notes::\n"),
1201 (
1202 "# Feature: Checkout:\n",
1203 1,
1204 "# Feature: Checkout\n",
1205 0,
1206 "# Feature: Checkout:\n",
1207 ),
1208 ("### Rule.:\n", 1, "### Rule\n", 0, "### Rule.:\n"),
1209 ];
1210
1211 for (input, standard_count, standard_fixed, mdg_count, mdg_fixed) in cases {
1212 for (flavor, count, expected) in [
1213 (crate::config::MarkdownFlavor::Standard, standard_count, standard_fixed),
1214 (crate::config::MarkdownFlavor::MDG, mdg_count, mdg_fixed),
1215 ] {
1216 let ctx = LintContext::new(input, flavor, None);
1217 assert_eq!(
1218 rule.check(&ctx).unwrap().len(),
1219 count,
1220 "{flavor:?} warning count for {input:?}"
1221 );
1222
1223 let fixed = rule.fix(&ctx).unwrap();
1224 assert_eq!(fixed, expected, "{flavor:?} fix for {input:?}");
1225
1226 let fixed_ctx = LintContext::new(&fixed, flavor, None);
1227 assert!(
1228 rule.check(&fixed_ctx).unwrap().is_empty(),
1229 "{flavor:?} left a warning on the fixed {input:?}"
1230 );
1231 assert_eq!(
1232 rule.fix(&fixed_ctx).unwrap(),
1233 fixed,
1234 "{flavor:?} fix for {input:?} should be idempotent"
1235 );
1236 }
1237 }
1238 }
1239
1240 #[test]
1243 fn test_mdg_reports_an_explicitly_configured_colon_once() {
1244 fn configured(punctuation: &str) -> MD026NoTrailingPunctuation {
1245 let mut config = crate::config::Config::default();
1246 let mut rule_config = crate::config::RuleConfig::default();
1247 rule_config
1248 .values
1249 .insert("punctuation".to_string(), toml::Value::String(punctuation.to_string()));
1250 config.rules.insert("MD026".to_string(), rule_config);
1251
1252 MD026NoTrailingPunctuation::from_config(&config)
1253 .as_any()
1254 .downcast_ref::<MD026NoTrailingPunctuation>()
1255 .expect("MD026::from_config builds an MD026NoTrailingPunctuation")
1256 .clone()
1257 }
1258
1259 let explicit = configured(".,;:!");
1260 assert_eq!(
1261 explicit.effective_punctuation(crate::config::MarkdownFlavor::Standard),
1262 ".,;:!"
1263 );
1264 assert_eq!(
1265 explicit.effective_punctuation(crate::config::MarkdownFlavor::MDG),
1266 ".,;!"
1267 );
1268 assert!(
1269 !explicit.mdg_colon_override_applies(crate::config::MarkdownFlavor::Standard),
1270 "a non-Gherkin file never reports the override"
1271 );
1272 assert!(explicit.mdg_colon_override_applies(crate::config::MarkdownFlavor::MDG));
1273
1274 let emitted_by_check = configured(".,;:!");
1275 let ctx = LintContext::new("## Scenario!\n", crate::config::MarkdownFlavor::MDG, None);
1276 assert_eq!(emitted_by_check.check(&ctx).unwrap().len(), 1);
1277
1278 let emitted_before_skip = configured(".,;:!");
1279 let only_protected_punctuation = LintContext::new("#### Examples:\n", crate::config::MarkdownFlavor::MDG, None);
1280 assert!(
1281 emitted_before_skip.should_skip(&only_protected_punctuation),
1282 "the post-override punctuation set has nothing to inspect"
1283 );
1284
1285 let explicit_without_colon = configured(".,;!");
1286 assert!(
1287 !explicit_without_colon.mdg_colon_override_applies(crate::config::MarkdownFlavor::MDG),
1288 "nothing is overridden when the configured set has no colon"
1289 );
1290
1291 let default = MD026NoTrailingPunctuation::new(None);
1292 assert_eq!(
1293 default.effective_punctuation(crate::config::MarkdownFlavor::MDG),
1294 ".,;!",
1295 "the colon leaves the default set too"
1296 );
1297 assert!(
1298 !default.mdg_colon_override_applies(crate::config::MarkdownFlavor::MDG),
1299 "the default set is not an explicit configuration, so it is silent"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_mdg_exempts_a_lone_colon_behind_whitespace() {
1305 let rule = MD026NoTrailingPunctuation::new(None);
1309 let content = "# Scenario :\n## Notes:\n";
1310
1311 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let standard = rule.check(&standard_ctx).unwrap();
1313 assert_eq!(standard.len(), 2);
1314 assert_eq!((standard[0].line, standard[0].column), (1, 12));
1315 assert_eq!((standard[1].line, standard[1].column), (2, 9));
1316 let standard_fixed = rule.fix(&standard_ctx).unwrap();
1317 assert_eq!(standard_fixed, "# Scenario \n## Notes\n");
1318 let standard_fixed_ctx = LintContext::new(&standard_fixed, crate::config::MarkdownFlavor::Standard, None);
1319 assert_eq!(
1320 rule.fix(&standard_fixed_ctx).unwrap(),
1321 standard_fixed,
1322 "Standard fix should be idempotent"
1323 );
1324
1325 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1326 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
1327 assert_eq!(
1328 rule.fix(&mdg_ctx).unwrap(),
1329 content,
1330 "MDG leaves headings whose only trailing punctuation is the colon untouched"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_mdg_does_not_exempt_a_full_width_colon() {
1336 let rule = MD026NoTrailingPunctuation::new(Some(".,;:!?:".to_string()));
1339 assert_eq!(
1340 rule.effective_punctuation(crate::config::MarkdownFlavor::MDG),
1341 ".,;!?:",
1342 "only the ASCII colon leaves the set"
1343 );
1344
1345 let content = "## Scenario:\n";
1346 for flavor in [
1347 crate::config::MarkdownFlavor::MDG,
1348 crate::config::MarkdownFlavor::Standard,
1349 ] {
1350 let ctx = LintContext::new(content, flavor, None);
1351 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{flavor:?} must flag the `:`");
1352 assert_eq!(rule.fix(&ctx).unwrap(), "## Scenario\n");
1353 }
1354 }
1355}