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;
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 check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
30        let bytes = doc.source().as_bytes();
31        for code in doc.inline_codes() {
32            let start = code.raw_range.start;
33            let end = code.raw_range.end;
34
35            let before_letter = start
36                .checked_sub(1)
37                .and_then(|i| bytes.get(i).copied())
38                .is_some_and(|b| b.is_ascii_alphabetic());
39
40            let after_letter = bytes.get(end).copied().is_some_and(|b| b.is_ascii_alphabetic())
41                && !is_plain_english_suffix(bytes, end);
42
43            if !before_letter && !after_letter {
44                continue;
45            }
46
47            let message = "inline code adjacent to a letter without whitespace — insert a \
48                 space between the code span and the surrounding word"
49                .to_owned();
50
51            if let Some(d) = Diagnostic::at(doc, start, 0..end.saturating_sub(start), message, None) {
52                out.push(d);
53            }
54        }
55    }
56}
57
58fn is_plain_english_suffix(bytes: &[u8], end: usize) -> bool {
59    match bytes.get(end..) {
60        Some([b's', next, ..]) if !next.is_ascii_alphabetic() => true,
61        Some([b'\'', b's', next, ..]) if !next.is_ascii_alphabetic() => true,
62        Some([b's'] | [b'\'', b's']) => true,
63        _ => false,
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use anyhow::Result;
70    use mdwright_document::Document;
71
72    use super::AdjacentCodeNoSpace;
73    use crate::rule_set::RuleSet;
74
75    fn diagnostics(src: &str) -> Result<usize> {
76        let mut rules = RuleSet::new();
77        rules
78            .add(Box::new(AdjacentCodeNoSpace))
79            .map_err(|e| anyhow::anyhow!("{e}"))?;
80        Ok(rules.check(&Document::parse(src)?).len())
81    }
82
83    #[test]
84    fn allows_common_inline_code_suffixes() -> Result<()> {
85        assert_eq!(diagnostics("Use `TODO`s and `Vec`'s capacity.\n")?, 0);
86        Ok(())
87    }
88
89    #[test]
90    fn still_flags_word_glued_after_code_span() -> Result<()> {
91        assert_eq!(diagnostics("Use `foo`bar here.\n")?, 1);
92        Ok(())
93    }
94}