Skip to main content

rumdl_lib/rules/
md088_quotes_dashes.rs

1//! Rule MD088: Replace look-alike quotes and dashes with ASCII equivalents.
2//!
3//! This rule flags all kinds of Unicode quotes and optionally dashes
4//! that are often introduced by smart-quote substitutions and rich text copy/paste,
5//! and replaces them with plain ASCII equivalents.
6
7mod md088_config;
8
9use crate::filtered_lines::FilteredLinesExt;
10use crate::lint_context::LintContext;
11use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::utils::range_utils::byte_to_char_count;
13use crate::utils::skip_context::is_in_math_context;
14use crate::utils::unicode;
15use md088_config::MD088Config;
16
17/// Rule MD088: Look-alike punctuation.
18#[derive(Debug, Clone)]
19pub struct MD088QuotesDashes {
20    config: MD088Config,
21}
22
23impl Default for MD088QuotesDashes {
24    fn default() -> Self {
25        Self::from_config_struct(MD088Config::default())
26    }
27}
28
29impl MD088QuotesDashes {
30    fn from_config_struct(config: MD088Config) -> Self {
31        Self { config }
32    }
33
34    #[inline]
35    fn is_allowed(&self, c: char) -> bool {
36        self.config.allow.contains(&c)
37    }
38
39    #[inline]
40    fn replacement_for(&self, c: char) -> Option<&'static str> {
41        if self.is_allowed(c) {
42            return None;
43        }
44
45        // U+02BC MODIFIER LETTER APOSTROPHE is deliberately absent. It is a
46        // Modifier_Letter, not punctuation: Crimean Tatar, Nenets and Chechen spell
47        // words with it, so rewriting it to ASCII changes a word rather than its
48        // typography. U+02BB, the Hawaiian okina, is the same category and is absent
49        // for the same reason.
50        match c {
51            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' | '\u{2032}' if self.config.normalize_quotes => Some("'"),
52            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' | '\u{2033}' if self.config.normalize_quotes => {
53                Some("\"")
54            }
55            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
56                if self.config.normalize_dashes =>
57            {
58                Some("-")
59            }
60            _ => None,
61        }
62    }
63
64    #[inline]
65    fn has_target_char(&self, ctx: &LintContext) -> bool {
66        ctx.content.chars().any(|c| self.replacement_for(c).is_some())
67    }
68}
69
70impl Rule for MD088QuotesDashes {
71    fn name(&self) -> &'static str {
72        "MD088"
73    }
74
75    fn description(&self) -> &'static str {
76        "Quotes and dashes should be replaced with ASCII equivalents"
77    }
78
79    fn category(&self) -> RuleCategory {
80        RuleCategory::Whitespace
81    }
82
83    fn fix_capability(&self) -> FixCapability {
84        FixCapability::FullyFixable
85    }
86
87    fn should_skip(&self, ctx: &LintContext) -> bool {
88        ctx.content.is_empty() || !self.has_target_char(ctx)
89    }
90
91    fn check(&self, ctx: &LintContext) -> LintResult {
92        let mut warnings = Vec::new();
93
94        for line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
95            for (byte_idx, c) in line.content.char_indices() {
96                let Some(replacement) = self.replacement_for(c) else {
97                    continue;
98                };
99
100                let absolute_byte = line.line_info.byte_offset + byte_idx;
101                if ctx.is_byte_offset_in_code_span(absolute_byte) {
102                    continue;
103                }
104
105                // Math is notation, not prose: `′` and `″` are the prime and double
106                // prime of a derivative or a unit, and their ASCII look-alikes do not
107                // typeset as primes.
108                if is_in_math_context(ctx, absolute_byte) {
109                    continue;
110                }
111
112                let column = byte_to_char_count(line.content, byte_idx);
113                let fix = if ctx.is_in_link(absolute_byte) || ctx.is_in_bare_url(absolute_byte) {
114                    None
115                } else {
116                    Some(Fix::new(
117                        ctx.line_column_byte_range_with_length(line.line_num, column, 1),
118                        replacement.to_string(),
119                    ))
120                };
121
122                warnings.push(LintWarning {
123                    rule_name: Some(self.name().to_string()),
124                    line: line.line_num,
125                    column,
126                    end_line: line.line_num,
127                    end_column: column + 1,
128                    severity: Severity::Warning,
129                    message: format!(
130                        "Unicode character {} ({}) should be replaced with {}",
131                        c,
132                        unicode::format_codepoint(c),
133                        replacement
134                    ),
135                    fix,
136                });
137            }
138        }
139
140        Ok(warnings)
141    }
142
143    fn fixes_from_diagnostics(&self) -> bool {
144        true
145    }
146
147    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
148        if self.should_skip(ctx) {
149            return Ok(ctx.content.to_string());
150        }
151
152        let warnings = self.check(ctx)?;
153        if warnings.is_empty() {
154            return Ok(ctx.content.to_string());
155        }
156
157        let warnings =
158            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
159        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
160            .map_err(crate::rule::LintError::InvalidInput)
161    }
162
163    fn as_any(&self) -> &dyn std::any::Any {
164        self
165    }
166
167    crate::impl_rule_config_methods!(MD088Config);
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::config::{Config, MarkdownFlavor};
174
175    #[test]
176    fn test_detects_and_fixes_single_and_double_quotes_look_alikes() {
177        let rule = MD088QuotesDashes::default();
178        let ctx = LintContext::new("It\u{2019}s \u{201C}fine\u{201D}.", MarkdownFlavor::Standard, None);
179
180        let warnings = rule.check(&ctx).unwrap();
181        assert_eq!(warnings.len(), 3);
182        assert!(warnings.iter().all(|w| w.fix.is_some()));
183
184        let fixed = rule.fix(&ctx).unwrap();
185        assert_eq!(fixed, "It's \"fine\".");
186    }
187
188    #[test]
189    fn test_detects_and_fixes_dash_look_alikes() {
190        let rule = MD088QuotesDashes::from_config_struct(MD088Config {
191            normalize_dashes: true,
192            ..Default::default()
193        });
194        let ctx = LintContext::new(
195            "Dash variants: \u{2010}\u{2011}\u{2012}\u{2013}\u{2014}\u{2015}",
196            MarkdownFlavor::Standard,
197            None,
198        );
199
200        let warnings = rule.check(&ctx).unwrap();
201        assert_eq!(warnings.len(), 6);
202        assert!(warnings.iter().all(|w| w.fix.is_some()));
203
204        let fixed = rule.fix(&ctx).unwrap();
205        assert_eq!(fixed, "Dash variants: ------");
206    }
207
208    #[test]
209    fn test_detects_and_fixes_prime_marks() {
210        let rule = MD088QuotesDashes::default();
211        let ctx = LintContext::new("Sizes: 6\u{2032} and 8\u{2033}", MarkdownFlavor::Standard, None);
212
213        let warnings = rule.check(&ctx).unwrap();
214        assert_eq!(warnings.len(), 2);
215        assert!(warnings.iter().all(|w| w.fix.is_some()));
216
217        let fixed = rule.fix(&ctx).unwrap();
218        assert_eq!(fixed, "Sizes: 6' and 8\"");
219    }
220
221    #[test]
222    fn test_modifier_letters_are_spelling_not_typography() {
223        // U+02BC and U+02BB are Modifier_Letter, so they spell words rather than quote
224        // them. Rewriting either to ASCII would change `Qaradenʼiz` into a different
225        // word, which no `allow` entry should be needed to prevent.
226        let rule = MD088QuotesDashes::default();
227        let content = "Qaraden\u{02BC}iz and Hawai\u{02BB}i.";
228        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
229
230        assert!(
231            rule.check(&ctx).unwrap().is_empty(),
232            "a modifier letter is part of a word's spelling"
233        );
234        assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not respell the words");
235
236        // Control: a real quotation mark in the same sentence is still normalized.
237        let ctx = LintContext::new("Qaraden\u{02BC}iz \u{2019}s.", MarkdownFlavor::Standard, None);
238        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
239        assert_eq!(rule.fix(&ctx).unwrap(), "Qaraden\u{02BC}iz 's.");
240    }
241
242    #[test]
243    fn test_skips_math() {
244        // In math, `′` and `″` are prime and double prime. ASCII `'` renders as a prime
245        // but `"` does not, so normalizing changes the notation the reader sees.
246        let rule = MD088QuotesDashes::default();
247        let content = "Prose \u{2019}quote.\n\n$$\nx\u{2032} = y\u{2033}\n$$\n\nInline $a\u{2033}$ too.\n";
248        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
249
250        let warnings = rule.check(&ctx).unwrap();
251        assert_eq!(
252            warnings.len(),
253            1,
254            "only the prose quote is a finding, got {warnings:#?}"
255        );
256        assert_eq!(warnings[0].line, 1);
257        assert_eq!(
258            rule.fix(&ctx).unwrap(),
259            "Prose 'quote.\n\n$$\nx\u{2032} = y\u{2033}\n$$\n\nInline $a\u{2033}$ too.\n"
260        );
261
262        // Control: the same primes outside math are still normalized, so the guard is
263        // about math and not about primes.
264        let ctx = LintContext::new("Sizes: 6\u{2032} and 8\u{2033}.", MarkdownFlavor::Standard, None);
265        assert_eq!(rule.check(&ctx).unwrap().len(), 2);
266        assert_eq!(rule.fix(&ctx).unwrap(), "Sizes: 6' and 8\".");
267    }
268
269    #[test]
270    fn test_skips_inline_code_spans() {
271        let rule = MD088QuotesDashes::default();
272        let content = "Prose \u{2019}quote and `code \u{2019}quote`";
273        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
274
275        let warnings = rule.check(&ctx).unwrap();
276        assert_eq!(warnings.len(), 1);
277        assert_eq!(warnings[0].column, 7);
278        assert_eq!(rule.fix(&ctx).unwrap(), "Prose 'quote and `code \u{2019}quote`");
279    }
280
281    #[test]
282    fn test_skips_fenced_and_indented_code_blocks() {
283        let rule = MD088QuotesDashes::default();
284        let content = r#"
285This is a ‘quote in prose’.
286
287```
288This is a ‘quote in fenced code block’
289```
290
291    This is a ‘quote in indented code block’
292"#;
293        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
294
295        let warnings = rule.check(&ctx).unwrap();
296        assert_eq!(warnings.len(), 2);
297        assert_eq!(
298            rule.fix(&ctx).unwrap(),
299            r#"
300This is a 'quote in prose'.
301
302```
303This is a ‘quote in fenced code block’
304```
305
306    This is a ‘quote in indented code block’
307"#
308        );
309    }
310
311    #[test]
312    fn test_no_findings_for_plain_ascii_quotes() {
313        let rule = MD088QuotesDashes::default();
314        let content = "He said, \"it's fine\".";
315        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
316
317        let warnings = rule.check(&ctx).unwrap();
318        assert!(warnings.is_empty());
319        assert_eq!(rule.fix(&ctx).unwrap(), content);
320    }
321
322    #[test]
323    fn test_allow_list_keeps_configured_codepoints() {
324        let config: Config = toml::from_str(
325            r#"
326            [MD088]
327            allow = ["U+2019", "U+2014"]
328            "#,
329        )
330        .unwrap();
331
332        let rule = MD088QuotesDashes::from_config(&config);
333        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
334
335        let content = "It\u{2019}s \u{2014} and \u{201C}quoted\u{201D}";
336        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
337        let fixed = rule.fix(&ctx).unwrap();
338
339        assert_eq!(fixed, "It\u{2019}s \u{2014} and \"quoted\"");
340    }
341
342    #[test]
343    fn test_reports_but_does_not_fix_bare_urls() {
344        let config: Config = toml::from_str(
345            r#"
346            [MD088]
347            normalize-quotes = true
348            normalize-dashes = true
349            "#,
350        )
351        .unwrap();
352
353        let rule = MD088QuotesDashes::from_config(&config);
354        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
355
356        let content = "Visit https://this\u{2010}site.com and \u{201C}enjoy\u{201D}!.";
357        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
358
359        let warnings = rule.check(&ctx).unwrap();
360        assert_eq!(warnings.len(), 3, "Expected 3 warnings, got {warnings:#?}");
361
362        let url_warning = warnings.iter().find(|w| w.column > 10).unwrap();
363        assert!(url_warning.fix.is_none());
364
365        let fixed = rule.fix(&ctx).unwrap();
366        assert_eq!(fixed, "Visit https://this\u{2010}site.com and \"enjoy\"!.");
367    }
368
369    #[test]
370    fn test_reports_but_does_not_fix_findings_in_link_destinations() {
371        let rule = MD088QuotesDashes::from_config_struct(MD088Config {
372            normalize_quotes: true,
373            normalize_dashes: true,
374            ..Default::default()
375        });
376        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
377
378        let content = "[link](https://example\u{2010}\u{2018}.com) and prose \u{2010}.";
379        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
380
381        let warnings = rule.check(&ctx).unwrap();
382        assert_eq!(warnings.len(), 3, "Expected 3 warnings, got {warnings:#?}");
383
384        let fixed = rule.fix(&ctx).unwrap();
385        assert_eq!(fixed, "[link](https://example\u{2010}\u{2018}.com) and prose -.");
386    }
387}