Skip to main content

mdwright_lint/stdlib/
escaped_emphasis.rs

1//! `\_` or `\*` literal escape in prose.
2//!
3//! Almost always damage from a Markdown formatter that escaped
4//! italic delimiters after parsing a paragraph whose italic span
5//! happened to contain a subscript `_`. Default-off because the
6//! preferred fix is to use `*…*` italics from the start; the rule
7//! exists to repair existing `mdformat`-mangled corpora.
8//!
9//! The new IR exposes raw prose chunks with the backslash intact —
10//! something the prior implementation could not do because it sliced
11//! pulldown-cmark's Text-event ranges, which omit preceding `\`.
12
13use std::sync::OnceLock;
14
15use regex::Regex;
16
17use crate::diagnostic::{Diagnostic, Fix};
18use crate::regex_util::compile_static;
19use crate::rule::LintRule;
20use mdwright_document::Document;
21
22pub struct EscapedEmphasis;
23
24fn pattern() -> &'static Regex {
25    static RE: OnceLock<Regex> = OnceLock::new();
26    RE.get_or_init(|| compile_static(r"\\[_*`]"))
27}
28
29impl LintRule for EscapedEmphasis {
30    fn name(&self) -> &str {
31        "escaped-emphasis"
32    }
33
34    fn description(&self) -> &str {
35        "Literal `\\_`, `\\*`, or `` \\` `` escape in prose (mdformat damage)."
36    }
37
38    fn explain(&self) -> &str {
39        include_str!("explain/escaped_emphasis.md")
40    }
41
42    fn produces_fix(&self) -> bool {
43        true
44    }
45
46    fn is_default(&self) -> bool {
47        false
48    }
49
50    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
51        for chunk in doc.prose_chunks() {
52            for m in pattern().find_iter(&chunk.text) {
53                let target = m.as_str().as_bytes().get(1).copied();
54                let (fix_char, label) = match target {
55                    Some(b'_') => ("*", r"\_"),
56                    Some(b'*') => ("*", r"\*"),
57                    Some(b'`') => ("`", r"\`"),
58                    _ => continue,
59                };
60                let message = format!(
61                    "`{label}` escape — likely italic-delimiter damage from a previous \
62                     formatter pass; prefer `*…*` for italics, never `_…_`"
63                );
64                if let Some(d) = Diagnostic::at(
65                    doc,
66                    chunk.byte_offset,
67                    m.range(),
68                    message,
69                    Some(Fix {
70                        replacement: fix_char.to_owned(),
71                        safe: true,
72                    }),
73                ) {
74                    out.push(d);
75                }
76            }
77        }
78    }
79}