Skip to main content

rumdl_lib/rules/
md026_no_trailing_punctuation.rs

1/// Rule MD026: No trailing punctuation in headings
2///
3/// See [docs/md026.md](../../docs/md026.md) for full documentation, configuration, and examples.
4use 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
16// Fast check patterns for early returns - match defaults
17static QUICK_PUNCTUATION_CHECK: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(&format!(r"[{}]", regex::escape(DEFAULT_PUNCTUATION))).unwrap());
19
20// Regex cache for punctuation patterns
21static PUNCTUATION_REGEX_CACHE: LazyLock<RwLock<HashMap<String, Regex>>> =
22    LazyLock::new(|| RwLock::new(HashMap::new()));
23
24/// Reports the MDG punctuation override once per process, across every config group.
25static MDG_PUNCTUATION_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
26
27/// Rule MD026: Trailing punctuation in heading
28#[derive(Clone)]
29pub struct MD026NoTrailingPunctuation {
30    config: MD026Config,
31    // A Gherkin structure is an ATX heading spelled `Keyword: name`, so the colon after the
32    // keyword is what makes the keyword a keyword. Dropping it from the punctuation set for
33    // that flavor keeps the rule from ever deleting it, however `punctuation` is configured.
34    mdg_punctuation: String,
35    // Whether the colon the Gherkin flavor drops was asked for rather than inherited from
36    // the default.
37    colon_configured_explicitly: bool,
38}
39
40impl Default for MD026NoTrailingPunctuation {
41    fn default() -> Self {
42        Self::new(None)
43    }
44}
45
46impl MD026NoTrailingPunctuation {
47    /// The default policy can only remove a suffix from its own punctuation
48    /// set. Question marks and other Unicode punctuation keep their text.
49    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    /// The punctuation set the flavor actually enforces.
82    #[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    /// Whether MDG is overriding a colon that came from explicit configuration.
92    fn mdg_colon_override_applies(&self, flavor: crate::config::MarkdownFlavor) -> bool {
93        flavor == crate::config::MarkdownFlavor::MDG && self.colon_configured_explicitly
94    }
95
96    /// Report the effective punctuation override before any content-based
97    /// shortcut can skip this rule. Direct `check` callers also pass through
98    /// here; the shared notice keeps it process-local and one-shot even when a
99    /// run creates separate rule instances for multiple config groups.
100    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        // Check cache first
115        {
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        // Compile and cache the regex
123        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    /// The run of punctuation that ends `text`, as a byte range.
135    ///
136    /// A punctuation character that closes an HTML entity reference (`&amp;`, `&#59;`,
137    /// `&#x3B;`) or an emoji shortcode (`:tada:`) is that construct's delimiter, not
138    /// punctuation: removing it would turn the construct into literal text. The run
139    /// therefore starts after the last such construct, so `Fish &amp;` has no run and
140    /// `Fish &amp;.` has the run `.`.
141    ///
142    /// A backslash-escaped opener (`\&amp;`, `\:tada:`) renders as literal text, so
143    /// its closing character is ordinary punctuation and counts toward the run.
144    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    /// True when the character at `pos` is preceded by an odd number of backslashes,
159    /// which escapes it: `\&` is a literal ampersand while `\\&` is an escaped
160    /// backslash followed by a live one.
161    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    // Remove trailing punctuation from text.
166    //
167    // A single removal is not enough when punctuation is separated from the end by
168    // interior whitespace (e.g. `. :`): removing `:` leaves `. `, whose trailing space
169    // then exposes `.`, so a second fix pass would change the result again. This keeps
170    // stripping while trimming the exposed whitespace reveals further trailing
171    // punctuation, making one fix call fully converge (idempotent). Trailing whitespace
172    // that does not hide more punctuation is preserved, matching the single-punctuation
173    // behavior (e.g. `Title :` -> `Title `).
174    #[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                // No trailing punctuation run at the very end.
180                return result;
181            };
182            result.truncate(run.start);
183            // Continue only if trimming the whitespace exposed by this removal reveals
184            // more trailing punctuation; otherwise keep the result (whitespace and all).
185            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
195/// The bytes of `source` before `end` that `removed`, a suffix of a heading's
196/// display text, is written as, read backwards together: a whitespace run of
197/// the text is the whitespace run of the source it renders, a line ending and
198/// the indentation below it included, and any other character is itself. None
199/// where the source reads differently, as it does when an anchor element sits
200/// inside the removed text.
201fn 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
222/// Whether removing the trailing punctuation from an ATX heading without a
223/// closing sequence would turn what is left into one. `a #` followed by a
224/// removed `.` renders as `a #.` today and as `a` once the dot is gone, so the
225/// fix would delete more than the punctuation. `remaining` is the display text
226/// that stays.
227fn 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
236/// Whether `rest`, the source after the text of an ATX heading, leaves the text
237/// at the end of the heading: nothing follows it but spaces and tabs, or an
238/// attribute block (`{#id}`, `{.class}`) set off from it by a space or tab.
239fn 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
246/// The block structure of a document: its block-level start events in order.
247fn 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
273/// Whether a line that reads `line` once an edit is made cannot open a block of
274/// its own: it does not start with a list marker, a thematic break, a fence, an
275/// HTML tag, a reference definition or any other block syntax, so the edit
276/// cannot change how the document divides into blocks. Container prefixes (`>`
277/// and indentation) are ignored. Every start that is not known to be inline
278/// text is unknown, and the caller checks it with a parse.
279fn 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        // No block syntax starts with a letter or any non-ASCII character
288        c if !c.is_ascii() || c.is_ascii_alphabetic() => true,
289        // An ordered list marker is digits, `.` or `)`, then a space, a tab or the end
290        '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        // Emphasis: a delimiter touching text. A list marker has a space, a tab or
299        // the end after it, and a thematic break has nothing but delimiters.
300        '*' | '_' => {
301            second.is_some_and(|c| !matches!(c, ' ' | '\t'))
302                && line.chars().any(|c| !matches!(c, '*' | '_' | '-' | '=' | ' ' | '\t'))
303        }
304        // A code span or strikethrough opens with fewer than the three characters of a fence
305        '`' | '~' => line.chars().take_while(|&c| c == first).count() < 3,
306        // A link or footnote reference is text unless it is a definition
307        '[' => !line.contains("]:"),
308        '!' => second == Some('['),
309        _ => false,
310    }
311}
312
313/// Whether the line `before` + `rest`, where `before` is the start of a line up
314/// to an edit and `rest` what follows it, cannot open a block of its own once the
315/// edit is made. List markers and quote markers that end inside `before` are
316/// unaffected by the edit and are skipped, so an item's text is judged on its own.
317fn 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
337/// A leaf block of the document that can be checked on its own: the byte range
338/// from the start of its first line to its end, and the width of the container
339/// prefix (list indentation, blockquote markers) in front of its first line.
340struct BlockWindow {
341    range: Range<usize>,
342    prefix_width: usize,
343}
344
345/// The leaf blocks of a document, in order: every block that is not itself a
346/// list, list item or blockquote, however deeply those nest.
347fn 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    // Nesting depth of the block being walked, and the depth at which a leaf block opened
352    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
383/// Replaces the tabs in `prefix` by the spaces they stand for, `column` being
384/// the column the text starts at.
385fn 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
400/// Removes the container prefix from the lines of a leaf block so the block
401/// reads the same parsed alone. The first line loses `prefix_width` bytes; each
402/// later line loses as many leading spaces and `>` markers as the first line's
403/// prefix is wide. The tabs in a prefix are expanded to spaces first, which
404/// reads the same to the parser.
405fn 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
428/// Whether deleting `range` from `content` leaves the blocks of the document
429/// as they were. Deleting the text before a line break can turn the rest of
430/// the heading into a list item, a thematic break or a paragraph of its own.
431/// A line that cannot open a block needs no parse. Any other is checked in the
432/// leaf block that holds the heading, with its container prefix removed: no
433/// other block depends on the end of one of its lines. `windows` is built on
434/// first use and shared by the headings of one document.
435fn 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        // Skip if no heading markers
477        if !ctx.likely_has_headings() {
478            return true;
479        }
480        // Skip if none of the configured punctuation exists
481        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        // Early returns for performance
492        if content.is_empty() {
493            return Ok(Vec::new());
494        }
495
496        // Quick check for any punctuation we care about
497        // For custom punctuation, we need to check differently
498        if punctuation == DEFAULT_PUNCTUATION {
499            if !QUICK_PUNCTUATION_CHECK.is_match(content) {
500                return Ok(Vec::new());
501            }
502        } else {
503            // For custom punctuation, check if any of those characters exist
504            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        // Check if we have any headings from pre-computed line info
511        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        // Headings come from the parsed view, which includes the ones inside
521        // blockquotes. A Setext heading is recorded on the last line of its
522        // text, and the parsed form locates where that text ends.
523        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            // LintContext already strips Kramdown IDs from heading.text
541            // So we just check the heading text directly for trailing punctuation
542            // This correctly flags "# Heading." even if it has {#id}
543            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            // The run closes the heading's display text, so in the source
550            // it sits at the end of the text range, whichever line of a
551            // Setext heading that ends on, unless an anchor element written
552            // inside the run keeps the two apart. Then the whole text is
553            // reported, and the punctuation is left in place.
554            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            // The fix deletes the removed text where it stands, so a closing
567            // sequence, an attribute list or anchor element after it, the
568            // whitespace around it, and the container prefix of a heading
569            // written inside a quote or list item, stay as they are.
570            // Whitespace between two runs of punctuation goes with them, a
571            // line break included; where the source reads differently, only
572            // the run itself goes. Template-bearing headings use the mapped
573            // source run too: an anchor lookalike inside a template value is
574            // not an ID.
575            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                    // Spaces and tabs between the text and the end of the line go
581                    // with the punctuation; a closing sequence or attribute list
582                    // after them keeps its separator.
583                    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                // What is left closes the heading only when nothing but spaces and
590                // tabs, or an attribute block set off by them, follows it on the
591                // line; other whitespace stays text.
592                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        // Check if punctuation was explicitly set in the config; the Gherkin flavor drops
653        // the colon from it, and an override the user asked for is worth reporting.
654        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        // With custom punctuation, legitimate punctuation exceptions don't apply
729        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            ("![img](x)", true),
904            ("\u{201c}quoted\u{201d}", true),
905            ("\\# a", true),
906            // list markers, thematic breaks, fences and the like need a parse
907            ("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 marker the edit completes or leaves bare still needs a parse
948            ("-", " 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    /// Setext headings with several container prefixes and punctuation shapes,
965    /// two per document so the second sits in a container the first shared.
966    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    /// The windowed structure check agrees with parsing the whole document, for
1002    /// every punctuation character that ends a line of the documents below.
1003    #[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                // the rule deletes a run at the end of a line
1027                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); // Points to first period
1103    }
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        // Both should get the same cached regex
1138        let _regex1 = rule1.get_punctuation_regex(&rule1.config.punctuation).unwrap();
1139        let _regex2 = rule2.get_punctuation_regex(&rule2.config.punctuation).unwrap();
1140
1141        // Check cache has the entry
1142        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        // The current implementation doesn't preserve trailing whitespace after punctuation removal
1169        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    /// The whole MDG matrix in one place, against the Standard behavior it departs from.
1187    ///
1188    /// The rule's pattern is `([<punctuation>]+)$`, so taking the colon out of the set is
1189    /// the entire flavor difference: no heading whose last character is a colon can match
1190    /// any more, and `## Scenario!:` is left exactly as its author wrote it.
1191    #[test]
1192    fn test_mdg_punctuation_matrix() {
1193        let rule = MD026NoTrailingPunctuation::new(None);
1194        // (input, standard warnings, standard fix, MDG warnings, MDG fix)
1195        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    /// The colon leaves the effective set whatever `punctuation` says, so a future change
1241    /// to the default cannot put it back; only an explicit setting is worth reporting.
1242    #[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        // The colon is not punctuation under MDG, and it is the last character here, so
1306        // there is nothing for the `$`-anchored pattern to match. Standard still strips it,
1307        // keeping the whitespace that preceded it.
1308        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        // Gherkin only recognizes the ASCII colon, so a full-width one carries
1337        // no structural meaning and stays in the punctuation set.
1338        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}