1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::FlavorOverrideNotice;
6use crate::utils::range_utils::calculate_match_range;
7use regex::Regex;
8use std::collections::HashMap;
9use std::sync::LazyLock;
10use std::sync::RwLock;
11
12mod md026_config;
13use md026_config::{DEFAULT_PUNCTUATION, MD026Config};
14
15static ATX_HEADING_UNIFIED: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"^( {0,3})(#{1,6})(\s+)(.+?)(\s+#{1,6})?$").unwrap());
18
19static QUICK_PUNCTUATION_CHECK: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(&format!(r"[{}]", regex::escape(DEFAULT_PUNCTUATION))).unwrap());
22
23static PUNCTUATION_REGEX_CACHE: LazyLock<RwLock<HashMap<String, Regex>>> =
25 LazyLock::new(|| RwLock::new(HashMap::new()));
26
27static MDG_PUNCTUATION_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
29
30#[derive(Clone)]
32pub struct MD026NoTrailingPunctuation {
33 config: MD026Config,
34 mdg_punctuation: String,
38 colon_configured_explicitly: bool,
41}
42
43impl Default for MD026NoTrailingPunctuation {
44 fn default() -> Self {
45 Self::new(None)
46 }
47}
48
49impl MD026NoTrailingPunctuation {
50 pub fn new(punctuation: Option<String>) -> Self {
51 let explicit = punctuation.is_some();
52 Self::build(
53 MD026Config {
54 punctuation: punctuation.unwrap_or_else(|| DEFAULT_PUNCTUATION.to_string()),
55 },
56 explicit,
57 )
58 }
59
60 pub fn from_config_struct(config: MD026Config) -> Self {
61 Self::build(config, false)
62 }
63
64 fn build(config: MD026Config, punctuation_explicit: bool) -> Self {
65 let colon_configured_explicitly = punctuation_explicit && config.punctuation.contains(':');
66 let mdg_punctuation = config.punctuation.replace(':', "");
67
68 Self {
69 config,
70 mdg_punctuation,
71 colon_configured_explicitly,
72 }
73 }
74
75 #[inline]
77 fn effective_punctuation(&self, flavor: crate::config::MarkdownFlavor) -> &str {
78 if flavor == crate::config::MarkdownFlavor::MDG {
79 &self.mdg_punctuation
80 } else {
81 &self.config.punctuation
82 }
83 }
84
85 fn mdg_colon_override_applies(&self, flavor: crate::config::MarkdownFlavor) -> bool {
87 flavor == crate::config::MarkdownFlavor::MDG && self.colon_configured_explicitly
88 }
89
90 fn warn_once_about_mdg_colon_override(&self, flavor: crate::config::MarkdownFlavor) {
95 if self.mdg_colon_override_applies(flavor) {
96 MDG_PUNCTUATION_OVERRIDE.report(
97 "MD026",
98 "punctuation",
99 &self.config.punctuation,
100 &self.mdg_punctuation,
101 "the ASCII colon after a Gherkin keyword is structural",
102 );
103 }
104 }
105
106 #[inline]
107 fn get_punctuation_regex(&self, punctuation: &str) -> Result<Regex, regex::Error> {
108 {
110 let cache = PUNCTUATION_REGEX_CACHE.read().unwrap();
111 if let Some(cached_regex) = cache.get(punctuation) {
112 return Ok(cached_regex.clone());
113 }
114 }
115
116 let pattern = format!(r"([{}]+)$", regex::escape(punctuation));
118 let regex = Regex::new(&pattern)?;
119
120 {
121 let mut cache = PUNCTUATION_REGEX_CACHE.write().unwrap();
122 cache.insert(punctuation.to_string(), regex.clone());
123 }
124
125 Ok(regex)
126 }
127
128 #[inline]
129 fn has_trailing_punctuation(&self, text: &str, re: &Regex) -> bool {
130 let trimmed = text.trim();
131 re.is_match(trimmed)
132 }
133
134 #[inline]
144 fn remove_trailing_punctuation(&self, text: &str, re: &Regex) -> String {
145 let mut result = text.trim().to_string();
146 loop {
147 let stripped = re.replace(&result, "").into_owned();
148 if stripped.len() == result.len() {
149 return stripped;
151 }
152 let trimmed = stripped.trim_end();
155 if trimmed.len() != stripped.len() && re.is_match(trimmed) {
156 result = trimmed.to_string();
157 } else {
158 return stripped;
159 }
160 }
161 }
162
163 #[inline]
165 fn fix_atx_heading(&self, line: &str, re: &Regex) -> String {
166 if let Some(captures) = ATX_HEADING_UNIFIED.captures(line) {
167 let indentation = captures.get(1).unwrap().as_str();
168 let hashes = captures.get(2).unwrap().as_str();
169 let space = captures.get(3).unwrap().as_str();
170 let content = captures.get(4).unwrap().as_str();
171
172 let fixed_content = if let Some(id_pos) = content.rfind(" {#") {
175 let before_id = &content[..id_pos];
177 let id_part = &content[id_pos..];
178 let fixed_before = self.remove_trailing_punctuation(before_id, re);
179 format!("{fixed_before}{id_part}")
180 } else {
181 self.remove_trailing_punctuation(content, re)
183 };
184
185 if let Some(trailing) = captures.get(5) {
187 return format!(
188 "{}{}{}{}{}",
189 indentation,
190 hashes,
191 space,
192 fixed_content,
193 trailing.as_str()
194 );
195 }
196
197 return format!("{indentation}{hashes}{space}{fixed_content}");
198 }
199
200 line.to_string()
202 }
203
204 #[inline]
206 fn fix_setext_heading(&self, content_line: &str, re: &Regex) -> String {
207 let trimmed = content_line.trim_end();
208 let mut whitespace = "";
209
210 if content_line.len() > trimmed.len() {
212 whitespace = &content_line[trimmed.len()..];
213 }
214
215 format!("{}{}", self.remove_trailing_punctuation(trimmed, re), whitespace)
217 }
218}
219
220impl Rule for MD026NoTrailingPunctuation {
221 fn name(&self) -> &'static str {
222 "MD026"
223 }
224
225 fn description(&self) -> &'static str {
226 "Trailing punctuation in heading"
227 }
228
229 fn category(&self) -> RuleCategory {
230 RuleCategory::Heading
231 }
232
233 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
234 self.warn_once_about_mdg_colon_override(ctx.flavor);
235
236 if !ctx.likely_has_headings() {
238 return true;
239 }
240 let punctuation = self.effective_punctuation(ctx.flavor);
242 !punctuation.chars().any(|p| ctx.content.contains(p))
243 }
244
245 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
246 let content = ctx.content;
247 let punctuation = self.effective_punctuation(ctx.flavor);
248
249 self.warn_once_about_mdg_colon_override(ctx.flavor);
250
251 if content.is_empty() {
253 return Ok(Vec::new());
254 }
255
256 if punctuation == DEFAULT_PUNCTUATION {
259 if !QUICK_PUNCTUATION_CHECK.is_match(content) {
260 return Ok(Vec::new());
261 }
262 } else {
263 let has_custom_punctuation = punctuation.chars().any(|c| content.contains(c));
265 if !has_custom_punctuation {
266 return Ok(Vec::new());
267 }
268 }
269
270 let has_headings = ctx.lines.iter().any(|line| line.heading.is_some());
272 if !has_headings {
273 return Ok(Vec::new());
274 }
275
276 let mut warnings = Vec::new();
277 let Ok(re) = self.get_punctuation_regex(punctuation) else {
278 return Ok(warnings);
279 };
280
281 for (line_num, line_info) in ctx.lines.iter().enumerate() {
283 if let Some(heading) = &line_info.heading {
284 if !heading.is_valid {
286 continue;
287 }
288
289 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
291 continue;
292 }
293
294 let text_to_check = heading.text.as_str();
298
299 if self.has_trailing_punctuation(text_to_check, &re) {
300 if let Some(punctuation_match) = re.find(text_to_check) {
302 let line = line_info.content(ctx.content);
303
304 let punctuation_pos_in_text = punctuation_match.start();
306 let text_pos_in_line = line.find(&heading.text).unwrap_or(heading.content_column);
307 let punctuation_start_in_line = text_pos_in_line + punctuation_pos_in_text;
308 let punctuation_len = punctuation_match.len();
309
310 let (start_line, start_col, end_line, end_col) = calculate_match_range(
311 line_num + 1, line,
313 punctuation_start_in_line,
314 punctuation_len,
315 );
316
317 let last_char = text_to_check.chars().last().unwrap_or(' ');
318 warnings.push(LintWarning {
319 rule_name: Some(self.name().to_string()),
320 line: start_line,
321 column: start_col,
322 end_line,
323 end_column: end_col,
324 message: format!("Heading '{text_to_check}' ends with punctuation '{last_char}'"),
325 severity: Severity::Warning,
326 fix: Some(Fix::new(
327 ctx.line_content_byte_range(line_num + 1),
328 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
329 self.fix_atx_heading(line, &re)
330 } else {
331 self.fix_setext_heading(line, &re)
332 },
333 )),
334 });
335 }
336 }
337 }
338 }
339
340 Ok(warnings)
341 }
342
343 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
344 if self.should_skip(ctx) {
345 return Ok(ctx.content.to_string());
346 }
347 let warnings = self.check(ctx)?;
348 if warnings.is_empty() {
349 return Ok(ctx.content.to_string());
350 }
351 let warnings =
352 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
353 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
354 .map_err(crate::rule::LintError::InvalidInput)
355 }
356
357 fn as_any(&self) -> &dyn std::any::Any {
358 self
359 }
360
361 crate::impl_rule_config_sections!(MD026Config);
362
363 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
364 where
365 Self: Sized,
366 {
367 let rule_config = crate::rule_config_serde::load_rule_config::<MD026Config>(config);
368
369 let punctuation_explicit = config
372 .rules
373 .get("MD026")
374 .is_some_and(|rule_cfg| rule_cfg.values.contains_key("punctuation"));
375
376 Box::new(Self::build(rule_config, punctuation_explicit))
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::lint_context::LintContext;
384
385 #[test]
386 fn test_no_trailing_punctuation() {
387 let rule = MD026NoTrailingPunctuation::new(None);
388 let content = "# This is a heading\n\n## Another heading";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391 assert!(result.is_empty(), "Headings without punctuation should not be flagged");
392 }
393
394 #[test]
395 fn test_trailing_period() {
396 let rule = MD026NoTrailingPunctuation::new(None);
397 let content = "# This is a heading.\n\n## Another one.";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400 assert_eq!(result.len(), 2);
401 assert_eq!(result[0].line, 1);
402 assert_eq!(result[0].column, 20);
403 assert!(result[0].message.contains("ends with punctuation '.'"));
404 assert_eq!(result[1].line, 3);
405 assert_eq!(result[1].column, 15);
406 }
407
408 #[test]
409 fn test_trailing_comma() {
410 let rule = MD026NoTrailingPunctuation::new(None);
411 let content = "# Heading,\n## Sub-heading,";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert_eq!(result.len(), 2);
415 assert!(result[0].message.contains("ends with punctuation ','"));
416 }
417
418 #[test]
419 fn test_trailing_semicolon() {
420 let rule = MD026NoTrailingPunctuation::new(None);
421 let content = "# Title;\n## Subtitle;";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert_eq!(result.len(), 2);
425 assert!(result[0].message.contains("ends with punctuation ';'"));
426 }
427
428 #[test]
429 fn test_custom_punctuation() {
430 let rule = MD026NoTrailingPunctuation::new(Some("!".to_string()));
431 let content = "# Important!\n## Regular heading.";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert_eq!(result.len(), 1, "Only exclamation should be flagged with custom config");
435 assert_eq!(result[0].line, 1);
436 assert!(result[0].message.contains("ends with punctuation '!'"));
437 }
438
439 #[test]
440 fn test_legitimate_question_mark() {
441 let rule = MD026NoTrailingPunctuation::new(Some(".,;?".to_string()));
442 let content = "# What is this?\n# This is bad.";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445 assert_eq!(result.len(), 2, "Both should be flagged with custom punctuation");
447 }
448
449 #[test]
450 fn test_question_marks_not_in_default() {
451 let rule = MD026NoTrailingPunctuation::new(None);
452 let content = "# What is Rust?\n# How does it work?\n# Is it fast?";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let result = rule.check(&ctx).unwrap();
455 assert!(result.is_empty(), "Question marks are not in default punctuation list");
456 }
457
458 #[test]
459 fn test_colons_in_default() {
460 let rule = MD026NoTrailingPunctuation::new(None);
461 let content = "# FAQ:\n# API Reference:\n# Step 1:\n# Version 2.0:";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert_eq!(
465 result.len(),
466 4,
467 "Colons are in default punctuation list and should be flagged"
468 );
469 }
470
471 #[test]
472 fn test_fix_atx_headings() {
473 let rule = MD026NoTrailingPunctuation::new(None);
474 let content = "# Title.\n## Subtitle,\n### Sub-subtitle;";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let fixed = rule.fix(&ctx).unwrap();
477 assert_eq!(fixed, "# Title\n## Subtitle\n### Sub-subtitle");
478 }
479
480 #[test]
481 fn test_fix_setext_headings() {
482 let rule = MD026NoTrailingPunctuation::new(None);
483 let content = "Title.\n======\n\nSubtitle,\n---------";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let fixed = rule.fix(&ctx).unwrap();
486 assert_eq!(fixed, "Title\n======\n\nSubtitle\n---------");
487 }
488
489 #[test]
490 fn test_fix_preserves_trailing_hashes() {
491 let rule = MD026NoTrailingPunctuation::new(None);
492 let content = "# Title. #\n## Subtitle, ##";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let fixed = rule.fix(&ctx).unwrap();
495 assert_eq!(fixed, "# Title #\n## Subtitle ##");
496 }
497
498 #[test]
499 fn test_indented_headings() {
500 let rule = MD026NoTrailingPunctuation::new(None);
501 let content = " # Title.\n ## Subtitle.";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504 assert_eq!(result.len(), 2, "Indented headings (< 4 spaces) should be checked");
505 }
506
507 #[test]
508 fn test_deeply_indented_ignored() {
509 let rule = MD026NoTrailingPunctuation::new(None);
510 let content = " # This is code.";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty(), "Deeply indented lines (4+ spaces) should be ignored");
514 }
515
516 #[test]
517 fn test_multiple_punctuation() {
518 let rule = MD026NoTrailingPunctuation::new(None);
519 let content = "# Title...";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1);
523 assert_eq!(result[0].column, 8); }
525
526 #[test]
527 fn test_empty_content() {
528 let rule = MD026NoTrailingPunctuation::new(None);
529 let content = "";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert!(result.is_empty());
533 }
534
535 #[test]
536 fn test_no_headings() {
537 let rule = MD026NoTrailingPunctuation::new(None);
538 let content = "This is just text.\nMore text with punctuation.";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.check(&ctx).unwrap();
541 assert!(result.is_empty(), "Non-heading lines should not be checked");
542 }
543
544 #[test]
545 fn test_get_punctuation_regex() {
546 let rule = MD026NoTrailingPunctuation::new(Some("!?".to_string()));
547 let regex = rule.get_punctuation_regex(&rule.config.punctuation).unwrap();
548 assert!(regex.is_match("text!"));
549 assert!(regex.is_match("text?"));
550 assert!(!regex.is_match("text."));
551 }
552
553 #[test]
554 fn test_regex_caching() {
555 let rule1 = MD026NoTrailingPunctuation::new(Some("!".to_string()));
556 let rule2 = MD026NoTrailingPunctuation::new(Some("!".to_string()));
557
558 let _regex1 = rule1.get_punctuation_regex(&rule1.config.punctuation).unwrap();
560 let _regex2 = rule2.get_punctuation_regex(&rule2.config.punctuation).unwrap();
561
562 let cache = PUNCTUATION_REGEX_CACHE.read().unwrap();
564 assert!(cache.contains_key("!"));
565 }
566
567 #[test]
568 fn test_config_from_toml() {
569 let mut config = crate::config::Config::default();
570 let mut rule_config = crate::config::RuleConfig::default();
571 rule_config
572 .values
573 .insert("punctuation".to_string(), toml::Value::String("!?".to_string()));
574 config.rules.insert("MD026".to_string(), rule_config);
575
576 let rule = MD026NoTrailingPunctuation::from_config(&config);
577 let content = "# Title!\n# Another?";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let result = rule.check(&ctx).unwrap();
580 assert_eq!(result.len(), 2, "Custom punctuation from config should be used");
581 }
582
583 #[test]
584 fn test_fix_removes_punctuation() {
585 let rule = MD026NoTrailingPunctuation::new(None);
586 let content = "# Title. \n## Subtitle, ";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let fixed = rule.fix(&ctx).unwrap();
589 assert_eq!(fixed, "# Title\n## Subtitle");
591 }
592
593 #[test]
594 fn test_final_newline_preservation() {
595 let rule = MD026NoTrailingPunctuation::new(None);
596 let content = "# Title.\n";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let fixed = rule.fix(&ctx).unwrap();
599 assert_eq!(fixed, "# Title\n");
600
601 let content_no_newline = "# Title.";
602 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
603 let fixed2 = rule.fix(&ctx2).unwrap();
604 assert_eq!(fixed2, "# Title");
605 }
606
607 #[test]
613 fn test_mdg_punctuation_matrix() {
614 let rule = MD026NoTrailingPunctuation::new(None);
615 let cases = [
617 ("## Notes:\n", 1, "## Notes\n", 0, "## Notes:\n"),
618 ("## Scenario!:\n", 1, "## Scenario\n", 0, "## Scenario!:\n"),
619 ("# Scenario! :\n", 1, "# Scenario\n", 0, "# Scenario! :\n"),
620 ("## Scenario!\n", 1, "## Scenario\n", 1, "## Scenario\n"),
621 ("## Notes::\n", 1, "## Notes\n", 0, "## Notes::\n"),
622 (
623 "# Feature: Checkout:\n",
624 1,
625 "# Feature: Checkout\n",
626 0,
627 "# Feature: Checkout:\n",
628 ),
629 ("### Rule.:\n", 1, "### Rule\n", 0, "### Rule.:\n"),
630 ];
631
632 for (input, standard_count, standard_fixed, mdg_count, mdg_fixed) in cases {
633 for (flavor, count, expected) in [
634 (crate::config::MarkdownFlavor::Standard, standard_count, standard_fixed),
635 (crate::config::MarkdownFlavor::MDG, mdg_count, mdg_fixed),
636 ] {
637 let ctx = LintContext::new(input, flavor, None);
638 assert_eq!(
639 rule.check(&ctx).unwrap().len(),
640 count,
641 "{flavor:?} warning count for {input:?}"
642 );
643
644 let fixed = rule.fix(&ctx).unwrap();
645 assert_eq!(fixed, expected, "{flavor:?} fix for {input:?}");
646
647 let fixed_ctx = LintContext::new(&fixed, flavor, None);
648 assert!(
649 rule.check(&fixed_ctx).unwrap().is_empty(),
650 "{flavor:?} left a warning on the fixed {input:?}"
651 );
652 assert_eq!(
653 rule.fix(&fixed_ctx).unwrap(),
654 fixed,
655 "{flavor:?} fix for {input:?} should be idempotent"
656 );
657 }
658 }
659 }
660
661 #[test]
664 fn test_mdg_reports_an_explicitly_configured_colon_once() {
665 fn configured(punctuation: &str) -> MD026NoTrailingPunctuation {
666 let mut config = crate::config::Config::default();
667 let mut rule_config = crate::config::RuleConfig::default();
668 rule_config
669 .values
670 .insert("punctuation".to_string(), toml::Value::String(punctuation.to_string()));
671 config.rules.insert("MD026".to_string(), rule_config);
672
673 MD026NoTrailingPunctuation::from_config(&config)
674 .as_any()
675 .downcast_ref::<MD026NoTrailingPunctuation>()
676 .expect("MD026::from_config builds an MD026NoTrailingPunctuation")
677 .clone()
678 }
679
680 let explicit = configured(".,;:!");
681 assert_eq!(
682 explicit.effective_punctuation(crate::config::MarkdownFlavor::Standard),
683 ".,;:!"
684 );
685 assert_eq!(
686 explicit.effective_punctuation(crate::config::MarkdownFlavor::MDG),
687 ".,;!"
688 );
689 assert!(
690 !explicit.mdg_colon_override_applies(crate::config::MarkdownFlavor::Standard),
691 "a non-Gherkin file never reports the override"
692 );
693 assert!(explicit.mdg_colon_override_applies(crate::config::MarkdownFlavor::MDG));
694
695 let emitted_by_check = configured(".,;:!");
696 let ctx = LintContext::new("## Scenario!\n", crate::config::MarkdownFlavor::MDG, None);
697 assert_eq!(emitted_by_check.check(&ctx).unwrap().len(), 1);
698
699 let emitted_before_skip = configured(".,;:!");
700 let only_protected_punctuation = LintContext::new("#### Examples:\n", crate::config::MarkdownFlavor::MDG, None);
701 assert!(
702 emitted_before_skip.should_skip(&only_protected_punctuation),
703 "the post-override punctuation set has nothing to inspect"
704 );
705
706 let explicit_without_colon = configured(".,;!");
707 assert!(
708 !explicit_without_colon.mdg_colon_override_applies(crate::config::MarkdownFlavor::MDG),
709 "nothing is overridden when the configured set has no colon"
710 );
711
712 let default = MD026NoTrailingPunctuation::new(None);
713 assert_eq!(
714 default.effective_punctuation(crate::config::MarkdownFlavor::MDG),
715 ".,;!",
716 "the colon leaves the default set too"
717 );
718 assert!(
719 !default.mdg_colon_override_applies(crate::config::MarkdownFlavor::MDG),
720 "the default set is not an explicit configuration, so it is silent"
721 );
722 }
723
724 #[test]
725 fn test_mdg_exempts_a_lone_colon_behind_whitespace() {
726 let rule = MD026NoTrailingPunctuation::new(None);
730 let content = "# Scenario :\n## Notes:\n";
731
732 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let standard = rule.check(&standard_ctx).unwrap();
734 assert_eq!(standard.len(), 2);
735 assert_eq!((standard[0].line, standard[0].column), (1, 12));
736 assert_eq!((standard[1].line, standard[1].column), (2, 9));
737 let standard_fixed = rule.fix(&standard_ctx).unwrap();
738 assert_eq!(standard_fixed, "# Scenario \n## Notes\n");
739 let standard_fixed_ctx = LintContext::new(&standard_fixed, crate::config::MarkdownFlavor::Standard, None);
740 assert_eq!(
741 rule.fix(&standard_fixed_ctx).unwrap(),
742 standard_fixed,
743 "Standard fix should be idempotent"
744 );
745
746 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
747 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
748 assert_eq!(
749 rule.fix(&mdg_ctx).unwrap(),
750 content,
751 "MDG leaves headings whose only trailing punctuation is the colon untouched"
752 );
753 }
754
755 #[test]
756 fn test_mdg_does_not_exempt_a_full_width_colon() {
757 let rule = MD026NoTrailingPunctuation::new(Some(".,;:!?:".to_string()));
760 assert_eq!(
761 rule.effective_punctuation(crate::config::MarkdownFlavor::MDG),
762 ".,;!?:",
763 "only the ASCII colon leaves the set"
764 );
765
766 let content = "## Scenario:\n";
767 for flavor in [
768 crate::config::MarkdownFlavor::MDG,
769 crate::config::MarkdownFlavor::Standard,
770 ] {
771 let ctx = LintContext::new(content, flavor, None);
772 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{flavor:?} must flag the `:`");
773 assert_eq!(rule.fix(&ctx).unwrap(), "## Scenario\n");
774 }
775 }
776}