mdwright_lint/stdlib/
adjacent_code.rs1use 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}