Skip to main content

mdwright_lint/stdlib/
adjacent_code.rs

1//! Inline code spans with no separating whitespace from a neighbouring
2//! letter, e.g. `` `foo`bar ``.
3//!
4//! `CommonMark` renders these correctly, but several Markdown
5//! renderers (mdformat with the mkdocs plugin, in particular)
6//! re-tokenise ambiguously and surrounding `_` or `*` get mangled.
7//! The structural fix is to always put a space between an inline
8//! code span and an adjacent word.
9
10use crate::diagnostic::{Diagnostic, Fix};
11use crate::rule::LintRule;
12use mdwright_document::Document;
13
14pub struct AdjacentCodeNoSpace;
15
16impl LintRule for AdjacentCodeNoSpace {
17    fn name(&self) -> &str {
18        "adjacent-code-no-space"
19    }
20
21    fn description(&self) -> &str {
22        "Inline code span adjacent to a letter without whitespace."
23    }
24
25    fn explain(&self) -> &str {
26        include_str!("explain/adjacent_code_no_space.md")
27    }
28
29    fn produces_fix(&self) -> bool {
30        true
31    }
32
33    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
34        let bytes = doc.source().as_bytes();
35        for code in doc.inline_codes() {
36            let start = code.raw_range.start;
37            let end = code.raw_range.end;
38
39            let before_letter = start
40                .checked_sub(1)
41                .and_then(|i| bytes.get(i).copied())
42                .is_some_and(|b| b.is_ascii_alphabetic());
43
44            let after_letter = bytes.get(end).copied().is_some_and(|b| b.is_ascii_alphabetic())
45                && !is_plain_english_suffix(bytes, end);
46
47            if before_letter {
48                let message = "letter directly precedes an inline code span — insert a space \
49                     between the word and the opening backtick"
50                    .to_owned();
51                let fix = Some(Fix {
52                    replacement: " ".to_owned(),
53                    safe: true,
54                });
55                if let Some(d) = Diagnostic::at(doc, start, 0..0, message, fix) {
56                    out.push(d);
57                }
58            }
59
60            if after_letter {
61                let message = "letter directly follows an inline code span — insert a space \
62                     between the closing backtick and the word"
63                    .to_owned();
64                let fix = Some(Fix {
65                    replacement: " ".to_owned(),
66                    safe: true,
67                });
68                if let Some(d) = Diagnostic::at(doc, end, 0..0, message, fix) {
69                    out.push(d);
70                }
71            }
72        }
73    }
74}
75
76fn is_plain_english_suffix(bytes: &[u8], end: usize) -> bool {
77    match bytes.get(end..) {
78        Some([b's', next, ..]) if !next.is_ascii_alphabetic() => true,
79        Some([b'\'', b's', next, ..]) if !next.is_ascii_alphabetic() => true,
80        Some([b's'] | [b'\'', b's']) => true,
81        _ => false,
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use anyhow::Result;
88    use mdwright_document::Document;
89
90    use super::AdjacentCodeNoSpace;
91    use crate::apply_safe_fixes;
92    use crate::rule_set::RuleSet;
93
94    fn rules() -> Result<RuleSet> {
95        let mut rs = RuleSet::new();
96        rs.add(Box::new(AdjacentCodeNoSpace))
97            .map_err(|e| anyhow::anyhow!("{e}"))?;
98        Ok(rs)
99    }
100
101    fn diagnostic_count(src: &str) -> Result<usize> {
102        Ok(rules()?.check(&Document::parse(src)?).len())
103    }
104
105    #[test]
106    fn allows_common_inline_code_suffixes() -> Result<()> {
107        assert_eq!(diagnostic_count("Use `TODO`s and `Vec`'s capacity.\n")?, 0);
108        Ok(())
109    }
110
111    #[test]
112    fn still_flags_word_glued_after_code_span() -> Result<()> {
113        assert_eq!(diagnostic_count("Use `foo`bar here.\n")?, 1);
114        Ok(())
115    }
116
117    #[test]
118    fn flags_both_sides_when_glued_on_each_end() -> Result<()> {
119        assert_eq!(diagnostic_count("Use x`foo`bar here.\n")?, 2);
120        Ok(())
121    }
122
123    #[test]
124    fn fix_inserts_space_after_code_span() -> Result<()> {
125        let src = "Use `foo`bar here.\n";
126        let doc = Document::parse(src)?;
127        let diags = rules()?.check(&doc);
128        let fix = diags
129            .first()
130            .and_then(|d| d.fix.as_ref())
131            .ok_or_else(|| anyhow::anyhow!("fix"))?;
132        assert!(fix.safe);
133        assert_eq!(fix.replacement, " ");
134        let (out, applied) = apply_safe_fixes(&doc, &diags);
135        assert_eq!(applied, 1);
136        assert_eq!(out, "Use `foo` bar here.\n");
137        let doc2 = Document::parse(&out)?;
138        assert!(rules()?.check(&doc2).is_empty());
139        Ok(())
140    }
141
142    #[test]
143    fn fix_inserts_space_on_both_sides() -> Result<()> {
144        let src = "x`foo`bar\n";
145        let doc = Document::parse(src)?;
146        let diags = rules()?.check(&doc);
147        assert_eq!(diags.len(), 2);
148        let (out, applied) = apply_safe_fixes(&doc, &diags);
149        assert_eq!(applied, 2);
150        assert_eq!(out, "x `foo` bar\n");
151        let doc2 = Document::parse(&out)?;
152        assert!(rules()?.check(&doc2).is_empty());
153        Ok(())
154    }
155}