Skip to main content

mdwright_lint/stdlib/
heading_punctuation.rs

1//! Trailing `.` or `:` on an ATX or setext heading.
2//!
3//! Headings are titles, not sentences. A trailing period reads as
4//! a stray dot; a trailing colon usually means the author wanted a
5//! list or paragraph instead.
6
7use crate::diagnostic::{Diagnostic, Fix};
8use crate::rule::LintRule;
9use mdwright_document::Document;
10
11pub struct HeadingPunctuation;
12
13impl LintRule for HeadingPunctuation {
14    fn name(&self) -> &str {
15        "heading-punctuation"
16    }
17
18    fn description(&self) -> &str {
19        "Trailing `.` or `:` on a heading."
20    }
21
22    fn explain(&self) -> &str {
23        include_str!("explain/heading_punctuation.md")
24    }
25
26    fn produces_fix(&self) -> bool {
27        true
28    }
29
30    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
31        for h in doc.headings() {
32            let last = h.text.chars().last();
33            let Some(c) = last else { continue };
34            if c != '.' && c != ':' {
35                continue;
36            }
37            let trailing_byte_len = c.len_utf8();
38            let local_start = h.text.len().saturating_sub(trailing_byte_len);
39            let local_end = h.text.len();
40            let message = format!("heading ends with `{c}` — headings should not carry sentence punctuation");
41            let fix = Some(Fix {
42                replacement: String::new(),
43                safe: true,
44            });
45            if let Some(d) = Diagnostic::at(doc, h.byte_offset, local_start..local_end, message, fix) {
46                out.push(d);
47            }
48        }
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use anyhow::Result;
55    use mdwright_document::Document;
56
57    use super::HeadingPunctuation;
58    use crate::apply_safe_fixes;
59    use crate::rule_set::RuleSet;
60
61    fn rules() -> Result<RuleSet> {
62        let mut rs = RuleSet::new();
63        rs.add(Box::new(HeadingPunctuation))
64            .map_err(|e| anyhow::anyhow!("{e}"))?;
65        Ok(rs)
66    }
67
68    #[test]
69    fn emits_safe_fix() -> Result<()> {
70        let doc = Document::parse("# Title.\n")?;
71        let diags = rules()?.check(&doc);
72        assert_eq!(diags.len(), 1);
73        let fix = diags
74            .first()
75            .and_then(|d| d.fix.as_ref())
76            .ok_or_else(|| anyhow::anyhow!("fix"))?;
77        assert!(fix.safe);
78        assert_eq!(fix.replacement, "");
79        Ok(())
80    }
81
82    #[test]
83    fn fix_strips_trailing_punctuation_and_is_idempotent() -> Result<()> {
84        let src = "# Intro:\n\n## Methods.\n";
85        let doc = Document::parse(src)?;
86        let diags = rules()?.check(&doc);
87        let (out, applied) = apply_safe_fixes(&doc, &diags);
88        assert_eq!(applied, 2);
89        assert_eq!(out, "# Intro\n\n## Methods\n");
90        let doc2 = Document::parse(&out)?;
91        assert!(rules()?.check(&doc2).is_empty());
92        Ok(())
93    }
94}