mdwright_lint/stdlib/
unbalanced_backtick.rs1use crate::diagnostic::Diagnostic;
10use crate::rule::LintRule;
11use mdwright_document::Document;
12
13pub struct UnbalancedBacktick;
14
15impl LintRule for UnbalancedBacktick {
16 fn name(&self) -> &str {
17 "unbalanced-backtick"
18 }
19
20 fn description(&self) -> &str {
21 "Backtick in prose that could not be paired with a closing fence."
22 }
23
24 fn explain(&self) -> &str {
25 include_str!("explain/unbalanced_backtick.md")
26 }
27
28 fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
29 let source = doc.source().as_bytes();
30 for chunk in doc.prose_chunks() {
31 for (idx, _) in chunk.text.match_indices('`') {
32 if backtick_is_escaped(source, chunk.byte_offset.saturating_add(idx)) {
39 continue;
40 }
41 let message = "unclosed inline code span — pulldown-cmark could not pair \
42 this backtick with a closing fence"
43 .to_owned();
44 if let Some(d) = Diagnostic::at(doc, chunk.byte_offset, idx..idx.saturating_add(1), message, None) {
45 out.push(d);
46 }
47 }
48 }
49 }
50}
51
52fn backtick_is_escaped(bytes: &[u8], i: usize) -> bool {
58 let mut count = 0usize;
59 let mut j = i;
60 while j > 0 && bytes.get(j.saturating_sub(1)).copied() == Some(b'\\') {
61 count = count.saturating_add(1);
62 j = j.saturating_sub(1);
63 }
64 count % 2 == 1
65}
66
67#[cfg(test)]
68mod tests {
69 use anyhow::Result;
70 use mdwright_document::Document;
71
72 use super::UnbalancedBacktick;
73 use crate::rule_set::RuleSet;
74
75 fn rules() -> Result<RuleSet> {
76 let mut rs = RuleSet::new();
77 rs.add(Box::new(UnbalancedBacktick))
78 .map_err(|e| anyhow::anyhow!("{e}"))?;
79 Ok(rs)
80 }
81
82 #[test]
83 fn escaped_backtick_is_not_flagged() -> Result<()> {
84 let doc = Document::parse(r"Use \`ls\` to list files.")?;
86 let diags = rules()?.check(&doc);
87 assert!(diags.is_empty(), "escaped backticks should not flag: {diags:?}");
88 Ok(())
89 }
90
91 #[test]
92 fn genuine_unpaired_backtick_is_flagged() -> Result<()> {
93 let doc = Document::parse("a stray ` backtick here\n")?;
95 let diags = rules()?.check(&doc);
96 assert_eq!(diags.len(), 1, "expected one diagnostic: {diags:?}");
97 Ok(())
98 }
99
100 #[test]
101 fn even_backslashes_leave_backtick_unescaped() -> Result<()> {
102 let doc = Document::parse(r"path C:\\` trailing")?;
105 let diags = rules()?.check(&doc);
106 assert_eq!(diags.len(), 1, "expected one diagnostic: {diags:?}");
107 Ok(())
108 }
109}