Skip to main content

mdwright_lint/stdlib/
unbalanced_backtick.rs

1//! A backtick in prose that pulldown-cmark could not pair.
2//!
3//! `CommonMark`'s parser pairs backtick runs greedily. If a literal
4//! `` ` `` survives into a prose chunk, no matching closing fence
5//! was found and the inline code span did not close. Renderers that
6//! treat the unmatched run as prose tend to mangle nearby `_` or `*`
7//! — flagging this early prevents that.
8
9use 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                // A `\`` is a CommonMark-escaped literal backtick, not an
33                // unpaired code-span delimiter. Parity is computed against
34                // the full source at the absolute offset, not within
35                // `chunk.text`: prose chunks can split mid-backslash-run
36                // (a `\\` escape straddling two chunks), so a chunk-local
37                // count would misjudge the escape.
38                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
52/// A byte is escaped iff preceded by an odd run of backslashes (`CommonMark`
53/// §2.4). Mirrors `preceding_backslashes_even` in `mdwright-math`'s scanner;
54/// the rule is a frozen part of the `CommonMark` spec, so the two copies cannot
55/// drift. Extract to a shared crate only once a third caller needs it. `bytes`
56/// must be the full source so a backslash run is never truncated at a boundary.
57fn 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        // `\`` is a CommonMark literal backtick, not an unpaired span.
85        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        // A real stray backtick in prose must still be reported.
94        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        // `\\` is an escaped backslash (literal `\`); the following `` ` ``
103        // is a real, unpaired delimiter and must still be flagged.
104        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}