Skip to main content

rumdl_lib/rules/
md088_quotes_dashes.rs

1//! Rule MD088: Replace look-alike quotes, dashes and spaces with ASCII equivalents.
2//!
3//! This rule flags all kinds of Unicode quotes and optionally dashes and spaces
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::parser_options::rumdl_parser_options_for;
13use crate::utils::skip_context::is_in_math_context;
14use crate::utils::unicode;
15use md088_config::MD088Config;
16use pulldown_cmark::{Event, Parser, Tag};
17use std::cell::Cell;
18use std::cmp::Ordering;
19use std::collections::HashSet;
20use std::ops::Range;
21
22/// Name of a non-ASCII space character that `normalize-spaces` covers, or `None`
23/// for any other character.
24///
25/// The set is an explicit list rather than `char::is_whitespace`: U+200B belongs to
26/// MD084, U+2028/U+2029/U+0085 are line separators, and U+1680 is a script-specific
27/// space mark, so none of them are look-alikes for a typed space.
28fn space_name(c: char) -> Option<&'static str> {
29    Some(match c {
30        '\u{00A0}' => "No-Break Space",
31        '\u{2000}' => "En Quad",
32        '\u{2001}' => "Em Quad",
33        '\u{2002}' => "En Space",
34        '\u{2003}' => "Em Space",
35        '\u{2004}' => "Three-Per-Em Space",
36        '\u{2005}' => "Four-Per-Em Space",
37        '\u{2006}' => "Six-Per-Em Space",
38        '\u{2007}' => "Figure Space",
39        '\u{2008}' => "Punctuation Space",
40        '\u{2009}' => "Thin Space",
41        '\u{200A}' => "Hair Space",
42        '\u{202F}' => "Narrow No-Break Space",
43        '\u{205F}' => "Medium Mathematical Space",
44        '\u{3000}' => "Ideographic Space",
45        _ => return None,
46    })
47}
48
49/// Whether `token` is a block marker that a following space would activate: an ATX
50/// heading run, bullet, blockquote or thematic-break character run (any token with
51/// no letter or digit), an ordered-list number, or a link reference label.
52fn is_block_marker_token(token: &str) -> bool {
53    if !token.chars().any(char::is_alphanumeric) {
54        return true;
55    }
56    if let Some(number) = token.strip_suffix(['.', ')']) {
57        return (1..=9).contains(&number.len()) && number.bytes().all(|b| b.is_ascii_digit());
58    }
59    token.starts_with('[') && token.ends_with("]:")
60}
61
62/// The byte ranges of the whitespace runs of `line`, in order, in which replacing a
63/// space character with U+0020 leaves the line's Markdown structure unchanged.
64///
65/// CommonMark gives only an ASCII space or tab structural meaning, so a Unicode
66/// space can sit where an ASCII one would change the block: `#` + space opens a
67/// heading, `1.` + space a list item, a trailing pair a hard break, four leading
68/// spaces an indented code block. The substitution is therefore only safe when the
69/// whitespace run holding the character has text before and after it on the line
70/// and the text before it is not just block markers (which include a footnote or link
71/// label such as `[^1]:`). The tail of a URL is excluded as well, because a URL ends at
72/// an ASCII space but not necessarily at a Unicode one.
73///
74/// The line is read once, token by token, so a line holding many spaces costs no more
75/// than its length.
76fn structure_keeping_space_runs(line: &str) -> Vec<Range<usize>> {
77    let mut runs = Vec::new();
78    // Whether a token precedes the current position, whether every such token is a
79    // block marker, and whether the nearest one is the tail of a URL.
80    let mut seen_token = false;
81    let mut only_markers = true;
82    let mut after_url = false;
83    let mut pos = 0;
84    while pos < line.len() {
85        let rest = &line[pos..];
86        let token_start = pos + rest.len() - rest.trim_start_matches(char::is_whitespace).len();
87        let token = line[token_start..].split(char::is_whitespace).next().unwrap_or("");
88        if token.is_empty() {
89            break;
90        }
91        if token_start > pos && seen_token && !only_markers && !after_url {
92            runs.push(pos..token_start);
93        }
94        seen_token = true;
95        only_markers = only_markers && is_block_marker_token(token);
96        after_url = token.contains("://") || token.starts_with("www.");
97        pos = token_start + token.len();
98    }
99    runs
100}
101
102/// Whether byte `idx` lies in one of the ordered, disjoint `runs`.
103fn in_runs(runs: &[Range<usize>], idx: usize) -> bool {
104    runs.binary_search_by(|run| {
105        if run.end <= idx {
106            Ordering::Less
107        } else if run.start > idx {
108            Ordering::Greater
109        } else {
110            Ordering::Equal
111        }
112    })
113    .is_ok()
114}
115
116/// Appended to a space finding that is reported without a fix.
117const STRUCTURE_RISK_NOTE: &str =
118    "; not fixed automatically because an ASCII space here could change the Markdown structure";
119
120/// Maps every space `normalize-spaces` covers to an ASCII space, so that text can be
121/// compared with and without the replacement applied.
122fn fold_spaces(text: &str) -> String {
123    text.chars()
124        .map(|c| if space_name(c).is_some() { ' ' } else { c })
125        .collect()
126}
127
128/// The block and inline structure of `content` as the Markdown parser reads it: every
129/// event with its kind and tag data, and the text of each run of text, with the covered
130/// Unicode spaces folded to ASCII ones.
131///
132/// Two documents that differ only in which of those spaces are written as ASCII yield
133/// the same signature exactly when the substitution did not change how the document
134/// parses: a heading, list item, task marker, hard break, table, link or HTML block
135/// appearing or disappearing shows as a different event, and a closing-hash sequence or
136/// a collapsed trailing space shows as different text.
137fn structure_signature(content: &str) -> Vec<String> {
138    let mut signature = Vec::new();
139    let mut text = String::new();
140    for event in Parser::new_ext(content, rumdl_parser_options_for(content)) {
141        if let Event::Text(t) = &event {
142            text.push_str(&fold_spaces(t));
143            continue;
144        }
145        if !text.is_empty() {
146            signature.push(format!("text:{}", std::mem::take(&mut text)));
147        }
148        signature.push(match &event {
149            Event::Code(t) => format!("code:{}", fold_spaces(t)),
150            Event::Html(t) => format!("html:{}", fold_spaces(t)),
151            Event::InlineHtml(t) => format!("inline-html:{}", fold_spaces(t)),
152            Event::InlineMath(t) => format!("inline-math:{}", fold_spaces(t)),
153            Event::DisplayMath(t) => format!("display-math:{}", fold_spaces(t)),
154            other => format!("{other:?}"),
155        });
156    }
157    if !text.is_empty() {
158        signature.push(format!("text:{text}"));
159    }
160    signature
161}
162
163/// `content` with the character at each of the byte offsets `at` replaced by an ASCII
164/// space.
165fn with_ascii_spaces(content: &str, at: &[usize]) -> String {
166    let mut out = String::with_capacity(content.len());
167    let mut last = 0;
168    for &offset in at {
169        out.push_str(&content[last..offset]);
170        out.push(' ');
171        last = offset + content[offset..].chars().next().map_or(0, char::len_utf8);
172    }
173    out.push_str(&content[last..]);
174    out
175}
176
177/// Cap on the text the structure check may parse, as a multiple of the document length
178/// (plus a fixed allowance for small documents). Isolating many offending spaces in one
179/// block costs a parse of that block per probe; the cap keeps the total linear in the
180/// document, and a probe the cap refuses counts as a structure change, so the space it
181/// covers is reported without a fix instead of fixed unchecked.
182const PARSE_BUDGET_FACTOR: usize = 16;
183const PARSE_BUDGET_FLOOR: usize = 16 * 1024;
184
185/// Counts the bytes handed to the parser and refuses further parses once the allowance
186/// is spent.
187struct ParseBudget {
188    remaining: Cell<usize>,
189}
190
191impl ParseBudget {
192    fn for_document(content: &str) -> Self {
193        Self::with_allowance(content.len() * PARSE_BUDGET_FACTOR + PARSE_BUDGET_FLOOR)
194    }
195
196    fn with_allowance(bytes: usize) -> Self {
197        Self {
198            remaining: Cell::new(bytes),
199        }
200    }
201
202    /// The structure signature of `text`, or `None` when the allowance cannot cover it.
203    fn signature(&self, text: &str) -> Option<Vec<String>> {
204        self.spend(text.len())?;
205        Some(structure_signature(text))
206    }
207
208    /// The structure signature of `content` with the characters at `at` replaced by ASCII
209    /// spaces, or `None` when the allowance cannot cover it. The allowance is checked
210    /// against the length of the result before the result is built, so a refused probe
211    /// costs nothing proportional to the block.
212    fn signature_with_spaces(&self, content: &str, at: &[usize]) -> Option<Vec<String>> {
213        let saved: usize = at
214            .iter()
215            .map(|&offset| content[offset..].chars().next().map_or(0, |c| c.len_utf8() - 1))
216            .sum();
217        self.spend(content.len() - saved)?;
218        Some(structure_signature(&with_ascii_spaces(content, at)))
219    }
220
221    fn spend(&self, bytes: usize) -> Option<()> {
222        let left = self.remaining.get();
223        if left < bytes {
224            self.remaining.set(0);
225            return None;
226        }
227        self.remaining.set(left - bytes);
228        Some(())
229    }
230
231    /// Whether a probe has been refused, after which every further probe is too.
232    fn exhausted(&self) -> bool {
233        self.remaining.get() == 0
234    }
235}
236
237/// The offsets in `candidates` (sorted ascending) whose replacement by an ASCII space
238/// would change how `content` parses, either alone or together with the others.
239///
240/// A group that leaves the signature unchanged is safe as a whole, so a document in which
241/// every substitution is harmless costs one extra parse; a group that changes it is split
242/// until the offending offsets are isolated. Offsets the budget cannot cover are returned
243/// as changing the structure.
244fn isolate_structure_changes(content: &str, candidates: &[usize], budget: &ParseBudget) -> HashSet<usize> {
245    let Some(base) = budget.signature(content) else {
246        return candidates.iter().copied().collect();
247    };
248    let changes = |group: &[usize]| match budget.signature_with_spaces(content, group) {
249        Some(signature) => signature != base,
250        None => true,
251    };
252
253    fn isolate(group: &[usize], budget: &ParseBudget, changes: &dyn Fn(&[usize]) -> bool, found: &mut HashSet<usize>) {
254        if group.is_empty() {
255            return;
256        }
257        // Once the allowance is spent nothing more can be proven safe, so the whole
258        // group is reported without descending into it.
259        if budget.exhausted() {
260            found.extend(group);
261            return;
262        }
263        if !changes(group) {
264            return;
265        }
266        if let [only] = group {
267            found.insert(*only);
268            return;
269        }
270        let (left, right) = group.split_at(group.len() / 2);
271        isolate(left, budget, changes, found);
272        isolate(right, budget, changes, found);
273    }
274
275    let mut found = HashSet::new();
276    isolate(candidates, budget, &changes, &mut found);
277
278    // Spaces that are harmless alone can still change the structure together (the two
279    // spaces of a trailing pair, say). Re-test the remainder, one by one when needed,
280    // and give up on whatever still interacts.
281    let mut remaining: Vec<usize> = candidates.iter().copied().filter(|c| !found.contains(c)).collect();
282    if budget.exhausted() {
283        found.extend(remaining);
284        return found;
285    }
286    if changes(&remaining) {
287        for &offset in &remaining {
288            if budget.exhausted() {
289                break;
290            }
291            if changes(&[offset]) {
292                found.insert(offset);
293            }
294        }
295        remaining.retain(|c| !found.contains(c));
296        if changes(&remaining) {
297            found.extend(remaining);
298        }
299    }
300    found
301}
302
303/// Byte offsets, at line starts and ascending, where each top-level block of `content`
304/// (and each item of a top-level list) begins. The first entry is always 0.
305fn top_level_block_starts(content: &str) -> Vec<usize> {
306    let mut starts = vec![0];
307    let mut depth = 0usize;
308    for (event, range) in Parser::new_ext(content, rumdl_parser_options_for(content)).into_offset_iter() {
309        match event {
310            Event::Start(tag) => {
311                // The items of a top-level list are checked separately too: a list is
312                // often most of a document.
313                if depth == 0 || (depth == 1 && matches!(tag, Tag::Item)) {
314                    starts.push(range.start);
315                }
316                depth += 1;
317            }
318            Event::End(_) => depth = depth.saturating_sub(1),
319            _ if depth == 0 => starts.push(range.start),
320            _ => {}
321        }
322    }
323    for start in &mut starts {
324        *start = content[..*start].rfind('\n').map_or(0, |i| i + 1);
325    }
326    starts.sort_unstable();
327    starts.dedup();
328    starts
329}
330
331/// The offsets in `candidates` (sorted ascending) whose replacement by an ASCII space
332/// would change how `content` parses, either alone or together with the others.
333///
334/// Each top-level block is checked on its own text, so a probe costs a parse of one block
335/// rather than of the document. A change can still reach across blocks (a link
336/// reference definition whose title is read differently, a fence that opens), so the
337/// spaces judged safe are then checked together against the whole document, and
338/// isolated there when that fails. Both stages draw on a parse budget; whatever it
339/// cannot cover is returned as changing the structure.
340fn spaces_changing_structure(content: &str, candidates: &[usize]) -> HashSet<usize> {
341    if candidates.is_empty() {
342        return HashSet::new();
343    }
344    let starts = top_level_block_starts(content);
345    let budget = ParseBudget::for_document(content);
346    let mut found = HashSet::new();
347    let mut from = 0;
348    for (index, &start) in starts.iter().enumerate() {
349        let end = starts.get(index + 1).copied().unwrap_or(content.len());
350        let upto = from + candidates[from..].partition_point(|&c| c < end);
351        let block = &candidates[from..upto];
352        from = upto;
353        if block.is_empty() {
354            continue;
355        }
356        let local: Vec<usize> = block.iter().map(|&c| c - start).collect();
357        found.extend(
358            isolate_structure_changes(&content[start..end], &local, &budget)
359                .into_iter()
360                .map(|c| c + start),
361        );
362    }
363
364    let safe: Vec<usize> = candidates.iter().copied().filter(|c| !found.contains(c)).collect();
365    if !safe.is_empty() {
366        found.extend(isolate_structure_changes(
367            content,
368            &safe,
369            &ParseBudget::for_document(content),
370        ));
371    }
372    found
373}
374
375/// Rule MD088: Look-alike punctuation.
376#[derive(Debug, Clone)]
377pub struct MD088QuotesDashes {
378    config: MD088Config,
379}
380
381impl Default for MD088QuotesDashes {
382    fn default() -> Self {
383        Self::from_config_struct(MD088Config::default())
384    }
385}
386
387impl MD088QuotesDashes {
388    fn from_config_struct(config: MD088Config) -> Self {
389        Self { config }
390    }
391
392    #[inline]
393    fn is_allowed(&self, c: char) -> bool {
394        self.config.allow.contains(&c)
395    }
396
397    #[inline]
398    fn replacement_for(&self, c: char) -> Option<&'static str> {
399        if self.is_allowed(c) {
400            return None;
401        }
402
403        // U+02BC MODIFIER LETTER APOSTROPHE is deliberately absent. It is a
404        // Modifier_Letter, not punctuation: Crimean Tatar, Nenets and Chechen spell
405        // words with it, so rewriting it to ASCII changes a word rather than its
406        // typography. U+02BB, the Hawaiian okina, is the same category and is absent
407        // for the same reason.
408        match c {
409            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' | '\u{2032}' if self.config.normalize_quotes => Some("'"),
410            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' | '\u{2033}' if self.config.normalize_quotes => {
411                Some("\"")
412            }
413            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
414                if self.config.normalize_dashes =>
415            {
416                Some("-")
417            }
418            c if self.config.normalize_spaces && space_name(c).is_some() => Some(" "),
419            _ => None,
420        }
421    }
422
423    #[inline]
424    fn has_target_char(&self, ctx: &LintContext) -> bool {
425        ctx.content.chars().any(|c| self.replacement_for(c).is_some())
426    }
427}
428
429impl Rule for MD088QuotesDashes {
430    fn name(&self) -> &'static str {
431        "MD088"
432    }
433
434    fn description(&self) -> &'static str {
435        "Quotes and dashes should be replaced with ASCII equivalents"
436    }
437
438    fn category(&self) -> RuleCategory {
439        RuleCategory::Whitespace
440    }
441
442    fn fix_capability(&self) -> FixCapability {
443        FixCapability::FullyFixable
444    }
445
446    fn should_skip(&self, ctx: &LintContext) -> bool {
447        ctx.content.is_empty() || !self.has_target_char(ctx)
448    }
449
450    fn check(&self, ctx: &LintContext) -> LintResult {
451        let mut warnings = Vec::new();
452        // (warning index, absolute byte) of each space finding that carries a fix.
453        let mut space_fixes: Vec<(usize, usize)> = Vec::new();
454
455        for line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
456            // Computed for the first space finding of the line and shared by the rest.
457            let mut safe_space_runs: Option<Vec<Range<usize>>> = None;
458            for (char_idx, (byte_idx, c)) in line.content.char_indices().enumerate() {
459                let Some(replacement) = self.replacement_for(c) else {
460                    continue;
461                };
462
463                let absolute_byte = line.line_info.byte_offset + byte_idx;
464                if ctx.is_byte_offset_in_code_span(absolute_byte) {
465                    continue;
466                }
467
468                // Math is notation, not prose: `′` and `″` are the prime and double
469                // prime of a derivative or a unit, and their ASCII look-alikes do not
470                // typeset as primes.
471                if is_in_math_context(ctx, absolute_byte) {
472                    continue;
473                }
474
475                let column = char_idx + 1;
476                let space_name = space_name(c);
477                let structural_risk = space_name.is_some()
478                    && (ctx.is_in_html_tag(absolute_byte)
479                        || !in_runs(
480                            safe_space_runs.get_or_insert_with(|| structure_keeping_space_runs(line.content)),
481                            byte_idx,
482                        ));
483                let fix = if ctx.is_in_link(absolute_byte)
484                    || ctx.is_in_bare_url(absolute_byte)
485                    || ctx.is_in_unresolved_autoref_identifier(absolute_byte)
486                    || structural_risk
487                {
488                    None
489                } else {
490                    Some(Fix::new(
491                        absolute_byte..absolute_byte + c.len_utf8(),
492                        replacement.to_string(),
493                    ))
494                };
495
496                // A space is invisible, so the diagnostic names the code point rather
497                // than printing the character.
498                let message = match space_name {
499                    Some(name) => {
500                        let mut message = format!(
501                            "Unicode space {} ({}) should be replaced with an ASCII space",
502                            unicode::format_codepoint(c),
503                            name
504                        );
505                        if structural_risk {
506                            message.push_str(STRUCTURE_RISK_NOTE);
507                        }
508                        message
509                    }
510                    None => format!(
511                        "Unicode character {} ({}) should be replaced with {}",
512                        c,
513                        unicode::format_codepoint(c),
514                        replacement
515                    ),
516                };
517
518                if space_name.is_some() && fix.is_some() {
519                    space_fixes.push((warnings.len(), absolute_byte));
520                }
521                warnings.push(LintWarning {
522                    rule_name: Some(self.name().to_string()),
523                    line: line.line_num,
524                    column,
525                    end_line: line.line_num,
526                    end_column: column + 1,
527                    severity: Severity::Warning,
528                    message,
529                    fix,
530                });
531            }
532        }
533
534        // The cheap checks above only rule spaces out. A fix is kept when the parser
535        // reads the document the same way with the space replaced.
536        if !space_fixes.is_empty() {
537            let offsets: Vec<usize> = space_fixes.iter().map(|&(_, offset)| offset).collect();
538            let unsafe_offsets = spaces_changing_structure(ctx.content, &offsets);
539            for (index, offset) in space_fixes {
540                if unsafe_offsets.contains(&offset) {
541                    let warning = &mut warnings[index];
542                    warning.fix = None;
543                    warning.message.push_str(STRUCTURE_RISK_NOTE);
544                }
545            }
546        }
547
548        Ok(warnings)
549    }
550
551    fn fixes_from_diagnostics(&self) -> bool {
552        true
553    }
554
555    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
556        if self.should_skip(ctx) {
557            return Ok(ctx.content.to_string());
558        }
559
560        let warnings = self.check(ctx)?;
561        if warnings.is_empty() {
562            return Ok(ctx.content.to_string());
563        }
564
565        let warnings =
566            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
567        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
568            .map_err(crate::rule::LintError::InvalidInput)
569    }
570
571    fn as_any(&self) -> &dyn std::any::Any {
572        self
573    }
574
575    crate::impl_rule_config_methods!(MD088Config);
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::config::{Config, MarkdownFlavor};
582
583    #[test]
584    fn test_detects_and_fixes_single_and_double_quotes_look_alikes() {
585        let rule = MD088QuotesDashes::default();
586        let ctx = LintContext::new("It\u{2019}s \u{201C}fine\u{201D}.", MarkdownFlavor::Standard, None);
587
588        let warnings = rule.check(&ctx).unwrap();
589        assert_eq!(warnings.len(), 3);
590        assert!(warnings.iter().all(|w| w.fix.is_some()));
591
592        let fixed = rule.fix(&ctx).unwrap();
593        assert_eq!(fixed, "It's \"fine\".");
594    }
595
596    #[test]
597    fn test_detects_and_fixes_dash_look_alikes() {
598        let rule = MD088QuotesDashes::from_config_struct(MD088Config {
599            normalize_dashes: true,
600            ..Default::default()
601        });
602        let ctx = LintContext::new(
603            "Dash variants: \u{2010}\u{2011}\u{2012}\u{2013}\u{2014}\u{2015}",
604            MarkdownFlavor::Standard,
605            None,
606        );
607
608        let warnings = rule.check(&ctx).unwrap();
609        assert_eq!(warnings.len(), 6);
610        assert!(warnings.iter().all(|w| w.fix.is_some()));
611
612        let fixed = rule.fix(&ctx).unwrap();
613        assert_eq!(fixed, "Dash variants: ------");
614    }
615
616    #[test]
617    fn test_detects_and_fixes_prime_marks() {
618        let rule = MD088QuotesDashes::default();
619        let ctx = LintContext::new("Sizes: 6\u{2032} and 8\u{2033}", MarkdownFlavor::Standard, None);
620
621        let warnings = rule.check(&ctx).unwrap();
622        assert_eq!(warnings.len(), 2);
623        assert!(warnings.iter().all(|w| w.fix.is_some()));
624
625        let fixed = rule.fix(&ctx).unwrap();
626        assert_eq!(fixed, "Sizes: 6' and 8\"");
627    }
628
629    #[test]
630    fn test_modifier_letters_are_spelling_not_typography() {
631        // U+02BC and U+02BB are Modifier_Letter, so they spell words rather than quote
632        // them. Rewriting either to ASCII would change `Qaradenʼiz` into a different
633        // word, which no `allow` entry should be needed to prevent.
634        let rule = MD088QuotesDashes::default();
635        let content = "Qaraden\u{02BC}iz and Hawai\u{02BB}i.";
636        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
637
638        assert!(
639            rule.check(&ctx).unwrap().is_empty(),
640            "a modifier letter is part of a word's spelling"
641        );
642        assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not respell the words");
643
644        // Control: a real quotation mark in the same sentence is still normalized.
645        let ctx = LintContext::new("Qaraden\u{02BC}iz \u{2019}s.", MarkdownFlavor::Standard, None);
646        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
647        assert_eq!(rule.fix(&ctx).unwrap(), "Qaraden\u{02BC}iz 's.");
648    }
649
650    #[test]
651    fn test_skips_math() {
652        // In math, `′` and `″` are prime and double prime. ASCII `'` renders as a prime
653        // but `"` does not, so normalizing changes the notation the reader sees.
654        let rule = MD088QuotesDashes::default();
655        let content = "Prose \u{2019}quote.\n\n$$\nx\u{2032} = y\u{2033}\n$$\n\nInline $a\u{2033}$ too.\n";
656        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
657
658        let warnings = rule.check(&ctx).unwrap();
659        assert_eq!(
660            warnings.len(),
661            1,
662            "only the prose quote is a finding, got {warnings:#?}"
663        );
664        assert_eq!(warnings[0].line, 1);
665        assert_eq!(
666            rule.fix(&ctx).unwrap(),
667            "Prose 'quote.\n\n$$\nx\u{2032} = y\u{2033}\n$$\n\nInline $a\u{2033}$ too.\n"
668        );
669
670        // Control: the same primes outside math are still normalized, so the guard is
671        // about math and not about primes.
672        let ctx = LintContext::new("Sizes: 6\u{2032} and 8\u{2033}.", MarkdownFlavor::Standard, None);
673        assert_eq!(rule.check(&ctx).unwrap().len(), 2);
674        assert_eq!(rule.fix(&ctx).unwrap(), "Sizes: 6' and 8\".");
675    }
676
677    #[test]
678    fn test_skips_inline_code_spans() {
679        let rule = MD088QuotesDashes::default();
680        let content = "Prose \u{2019}quote and `code \u{2019}quote`";
681        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
682
683        let warnings = rule.check(&ctx).unwrap();
684        assert_eq!(warnings.len(), 1);
685        assert_eq!(warnings[0].column, 7);
686        assert_eq!(rule.fix(&ctx).unwrap(), "Prose 'quote and `code \u{2019}quote`");
687    }
688
689    #[test]
690    fn test_skips_fenced_and_indented_code_blocks() {
691        let rule = MD088QuotesDashes::default();
692        let content = r#"
693This is a ‘quote in prose’.
694
695```
696This is a ‘quote in fenced code block’
697```
698
699    This is a ‘quote in indented code block’
700"#;
701        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
702
703        let warnings = rule.check(&ctx).unwrap();
704        assert_eq!(warnings.len(), 2);
705        assert_eq!(
706            rule.fix(&ctx).unwrap(),
707            r#"
708This is a 'quote in prose'.
709
710```
711This is a ‘quote in fenced code block’
712```
713
714    This is a ‘quote in indented code block’
715"#
716        );
717    }
718
719    #[test]
720    fn test_no_findings_for_plain_ascii_quotes() {
721        let rule = MD088QuotesDashes::default();
722        let content = "He said, \"it's fine\".";
723        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
724
725        let warnings = rule.check(&ctx).unwrap();
726        assert!(warnings.is_empty());
727        assert_eq!(rule.fix(&ctx).unwrap(), content);
728    }
729
730    #[test]
731    fn test_allow_list_keeps_configured_codepoints() {
732        let config: Config = toml::from_str(
733            r#"
734            [MD088]
735            allow = ["U+2019", "U+2014"]
736            "#,
737        )
738        .unwrap();
739
740        let rule = MD088QuotesDashes::from_config(&config);
741        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
742
743        let content = "It\u{2019}s \u{2014} and \u{201C}quoted\u{201D}";
744        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
745        let fixed = rule.fix(&ctx).unwrap();
746
747        assert_eq!(fixed, "It\u{2019}s \u{2014} and \"quoted\"");
748    }
749
750    #[test]
751    fn test_reports_but_does_not_fix_bare_urls() {
752        let config: Config = toml::from_str(
753            r#"
754            [MD088]
755            normalize-quotes = true
756            normalize-dashes = true
757            "#,
758        )
759        .unwrap();
760
761        let rule = MD088QuotesDashes::from_config(&config);
762        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
763
764        let content = "Visit https://this\u{2010}site.com and \u{201C}enjoy\u{201D}!.";
765        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
766
767        let warnings = rule.check(&ctx).unwrap();
768        assert_eq!(warnings.len(), 3, "Expected 3 warnings, got {warnings:#?}");
769
770        let url_warning = warnings.iter().find(|w| w.column > 10).unwrap();
771        assert!(url_warning.fix.is_none());
772
773        let fixed = rule.fix(&ctx).unwrap();
774        assert_eq!(fixed, "Visit https://this\u{2010}site.com and \"enjoy\"!.");
775    }
776
777    #[test]
778    fn test_reports_but_does_not_fix_findings_in_link_destinations() {
779        let rule = MD088QuotesDashes::from_config_struct(MD088Config {
780            normalize_quotes: true,
781            normalize_dashes: true,
782            ..Default::default()
783        });
784        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
785
786        let content = "[link](https://example\u{2010}\u{2018}.com) and prose \u{2010}.";
787        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
788
789        let warnings = rule.check(&ctx).unwrap();
790        assert_eq!(warnings.len(), 3, "Expected 3 warnings, got {warnings:#?}");
791
792        let fixed = rule.fix(&ctx).unwrap();
793        assert_eq!(fixed, "[link](https://example\u{2010}\u{2018}.com) and prose -.");
794    }
795
796    // ---- normalize-spaces ----
797
798    fn spaces_rule() -> MD088QuotesDashes {
799        MD088QuotesDashes::from_config_struct(MD088Config {
800            normalize_spaces: true,
801            ..Default::default()
802        })
803    }
804
805    fn check_spaces(content: &str) -> (Vec<LintWarning>, String) {
806        let rule = spaces_rule();
807        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
808        (rule.check(&ctx).unwrap(), rule.fix(&ctx).unwrap())
809    }
810
811    /// Block structure of `content` per pulldown-cmark, with text stripped: the
812    /// shape a space substitution must not change.
813    fn block_shape(content: &str) -> Vec<String> {
814        use pulldown_cmark::{Event, Options, Parser};
815        Parser::new_ext(content, Options::all())
816            .filter_map(|event| match event {
817                Event::Start(tag) => Some(format!("start {tag:?}")),
818                Event::End(tag) => Some(format!("end {tag:?}")),
819                Event::HardBreak => Some("hardbreak".to_string()),
820                Event::Rule => Some("rule".to_string()),
821                _ => None,
822            })
823            .collect()
824    }
825
826    #[test]
827    fn test_spaces_reported_and_fixed_in_prose() {
828        let (warnings, fixed) = check_spaces("a\u{00A0}b and c\u{202F}d");
829        assert_eq!(warnings.len(), 2, "{warnings:#?}");
830        assert!(warnings.iter().all(|w| w.fix.is_some()));
831        assert_eq!(fixed, "a b and c d");
832        assert_eq!((warnings[0].column, warnings[1].column), (2, 10));
833    }
834
835    #[test]
836    fn test_space_diagnostics_name_the_code_point_not_the_glyph() {
837        let (warnings, _) = check_spaces("a\u{00A0}b c\u{202F}d");
838        assert!(warnings[0].message.contains("U+00A0"), "{}", warnings[0].message);
839        assert!(
840            warnings[0].message.contains("No-Break Space"),
841            "{}",
842            warnings[0].message
843        );
844        assert!(warnings[1].message.contains("U+202F"), "{}", warnings[1].message);
845        assert!(
846            warnings[1].message.contains("Narrow No-Break Space"),
847            "{}",
848            warnings[1].message
849        );
850        for w in &warnings {
851            assert!(
852                !w.message.chars().any(|c| c.is_whitespace() && c != ' '),
853                "invisible glyph printed: {:?}",
854                w.message
855            );
856        }
857    }
858
859    #[test]
860    fn test_every_listed_space_is_normalized() {
861        let listed: Vec<char> = std::iter::once('\u{00A0}')
862            .chain('\u{2000}'..='\u{200A}')
863            .chain(['\u{202F}', '\u{205F}', '\u{3000}'])
864            .collect();
865        for c in listed {
866            let content = format!("x{c}y");
867            let (warnings, fixed) = check_spaces(&content);
868            assert_eq!(warnings.len(), 1, "U+{:04X} not reported", c as u32);
869            assert_eq!(fixed, "x y", "U+{:04X} not fixed", c as u32);
870        }
871    }
872
873    #[test]
874    fn test_characters_owned_elsewhere_are_not_spaces_to_normalize() {
875        // U+200B belongs to MD084, U+2028/2029/0085 are line separators, and U+1680
876        // is the Ogham space mark, a script-specific character.
877        for c in ['\u{200B}', '\u{2028}', '\u{2029}', '\u{0085}', '\u{1680}'] {
878            let content = format!("x{c}y");
879            let (warnings, fixed) = check_spaces(&content);
880            assert!(warnings.is_empty(), "U+{:04X} reported: {warnings:#?}", c as u32);
881            assert_eq!(fixed, content);
882        }
883    }
884
885    #[test]
886    fn test_default_config_leaves_spaces_alone() {
887        let rule = MD088QuotesDashes::default();
888        let content = "a\u{00A0}b c\u{202F}d\u{3000}e";
889        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
890        assert!(rule.should_skip(&ctx));
891        assert!(rule.check(&ctx).unwrap().is_empty());
892        assert_eq!(rule.fix(&ctx).unwrap(), content);
893
894        // Control: the same rule still handles a quote in the same text.
895        let content = "a\u{00A0}b \u{2019}c";
896        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
897        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
898        assert_eq!(rule.fix(&ctx).unwrap(), "a\u{00A0}b 'c");
899    }
900
901    #[test]
902    fn test_spaces_option_is_independent_of_quotes_and_dashes() {
903        let rule = MD088QuotesDashes::from_config_struct(MD088Config {
904            normalize_quotes: false,
905            normalize_spaces: true,
906            ..Default::default()
907        });
908        let content = "it\u{2019}s a\u{00A0}b";
909        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
910        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
911        assert_eq!(rule.fix(&ctx).unwrap(), "it\u{2019}s a b");
912    }
913
914    #[test]
915    fn test_allow_exempts_spaces() {
916        let config: Config = toml::from_str(
917            r#"
918            [MD088]
919            normalize-spaces = true
920            allow = ["U+202F"]
921            "#,
922        )
923        .unwrap();
924        let rule = MD088QuotesDashes::from_config(&config);
925        let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
926
927        let content = "Quoi\u{202F}? Oui\u{00A0}!";
928        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
929        let warnings = rule.check(&ctx).unwrap();
930        assert_eq!(warnings.len(), 1, "{warnings:#?}");
931        assert!(warnings[0].message.contains("U+00A0"));
932        assert_eq!(rule.fix(&ctx).unwrap(), "Quoi\u{202F}? Oui !");
933    }
934
935    #[test]
936    fn test_spaces_skipped_in_code_front_matter_and_math() {
937        let content = "---\ntitle: a\u{00A0}b\n---\n\nCode `a\u{00A0}b` here.\n\n```\na\u{00A0}b\n```\n\n    a\u{00A0}b\n\n$$\nx\u{00A0}= 1\n$$\n\nInline $a\u{00A0}b$ too.\n";
938        let (warnings, fixed) = check_spaces(content);
939        assert!(warnings.is_empty(), "{warnings:#?}");
940        assert_eq!(fixed, content);
941
942        // Control: the same character in prose is reported.
943        let (warnings, _) = check_spaces("Prose a\u{00A0}b.\n");
944        assert_eq!(warnings.len(), 1);
945    }
946
947    #[test]
948    fn test_spaces_in_link_destinations_and_urls_are_reported_without_fix() {
949        let content = "[l](https://example.com/a\u{00A0}b) https://example.com/c\u{00A0}d and e\u{00A0}f";
950        let (warnings, fixed) = check_spaces(content);
951        assert_eq!(warnings.len(), 3, "{warnings:#?}");
952        assert!(warnings[0].fix.is_none());
953        assert!(warnings[1].fix.is_none());
954        assert!(warnings[2].fix.is_some());
955        assert_eq!(
956            fixed,
957            "[l](https://example.com/a\u{00A0}b) https://example.com/c\u{00A0}d and e f"
958        );
959    }
960
961    #[test]
962    fn test_space_fix_never_creates_markdown_structure() {
963        // Each of these is a paragraph today. Replacing the space would make it a
964        // heading, list item, quote, thematic break, definition, task item, hard
965        // break, indented code block or an HTML tag, so the space is reported only.
966        let reported_only = [
967            "#\u{00A0}x",
968            "###\u{00A0}x",
969            "1.\u{00A0}x",
970            "1)\u{00A0}x",
971            "-\u{00A0}x",
972            "+\u{00A0}x",
973            "*\u{00A0}x",
974            "- 1.\u{00A0}x",
975            ">\u{00A0}x",
976            "> -\u{00A0}x",
977            "*\u{00A0}*\u{00A0}*",
978            "-\u{00A0}-\u{00A0}-",
979            "[a]:\u{00A0}/url",
980            "[^1]:\u{00A0}note",
981            "[a]: /url\u{00A0}\"title\"",
982            "see https://example.com/c\u{00A0}d",
983            "see www.example.com/c\u{00A0}d",
984            "- [\u{00A0}] task",
985            "foo\u{00A0}",
986            "foo\u{00A0}\u{00A0}",
987            "foo\u{00A0} ",
988            "\u{00A0}foo",
989            "\u{00A0}\u{00A0}\u{00A0}\u{00A0}code",
990            "<a\u{00A0}href=\"x\">t</a>",
991        ];
992        for content in reported_only {
993            let document = format!("para\n\n{content}\n\nend\n");
994            let (warnings, fixed) = check_spaces(&document);
995            assert!(!warnings.is_empty(), "{content:?}: not reported");
996            assert!(
997                warnings.iter().all(|w| w.fix.is_none()),
998                "{content:?}: offered a fix: {warnings:#?}"
999            );
1000            assert_eq!(fixed, document, "{content:?}: fix changed the document");
1001        }
1002    }
1003
1004    #[test]
1005    fn test_space_fix_applies_where_structure_cannot_change() {
1006        let fixable = [
1007            ("100\u{00A0}km", "100 km"),
1008            ("foo\u{00A0}bar", "foo bar"),
1009            ("a.\u{00A0}b", "a. b"),
1010            ("# Title\u{00A0}here", "# Title here"),
1011            ("- item\u{00A0}x", "- item x"),
1012            ("1. item\u{00A0}x", "1. item x"),
1013            ("> quote\u{00A0}x", "> quote x"),
1014            ("Hello\u{00A0}\u{00A0}world", "Hello  world"),
1015            ("Hello \u{00A0}world", "Hello  world"),
1016            ("| a\u{00A0}b | c |", "| a b | c |"),
1017            ("Quoi\u{202F}?", "Quoi ?"),
1018        ];
1019        for (content, expected) in fixable {
1020            let document = format!("para\n\n{content}\n\nend\n");
1021            let (warnings, fixed) = check_spaces(&document);
1022            assert!(!warnings.is_empty(), "{content:?}: not reported");
1023            assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:#?}");
1024            assert_eq!(fixed, format!("para\n\n{expected}\n\nend\n"), "{content:?}");
1025            assert_eq!(
1026                block_shape(&fixed),
1027                block_shape(&document),
1028                "{content:?}: the fix changed the block structure"
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn test_space_fix_with_following_lines_keeps_structure() {
1035        // A paragraph continuation line that starts after a marker-like word, and a
1036        // line ending in a lone space before a blank line.
1037        let document = "foo\u{00A0}bar\nbaz\u{00A0}\n\nqux\n";
1038        let (warnings, fixed) = check_spaces(document);
1039        assert_eq!(warnings.len(), 2);
1040        assert!(warnings[0].fix.is_some());
1041        assert!(warnings[1].fix.is_none(), "trailing space is reported only");
1042        assert_eq!(fixed, "foo bar\nbaz\u{00A0}\n\nqux\n");
1043        assert_eq!(block_shape(&fixed), block_shape(document));
1044    }
1045
1046    #[test]
1047    fn test_spaces_with_crlf_line_endings() {
1048        let document = "a\u{00A0}b\r\nc\u{00A0}\r\n\r\n#\u{00A0}x\r\nd\u{00A0}e\r\n";
1049        let (warnings, fixed) = check_spaces(document);
1050        assert_eq!(warnings.len(), 4, "{warnings:#?}");
1051        let fixes: Vec<bool> = warnings.iter().map(|w| w.fix.is_some()).collect();
1052        assert_eq!(fixes, [true, false, false, true], "{warnings:#?}");
1053        assert_eq!(fixed, "a b\r\nc\u{00A0}\r\n\r\n#\u{00A0}x\r\nd e\r\n");
1054    }
1055
1056    #[test]
1057    fn test_space_fix_is_idempotent() {
1058        let document = "---\ntitle: a\u{00A0}b\n---\n\nIt\u{2019}s a\u{00A0}b.\n\n#\u{00A0}x\n\nfoo\u{00A0}\n\n- 1.\u{00A0}y\u{202F}z\n";
1059        let rule = MD088QuotesDashes::from_config_struct(MD088Config {
1060            normalize_spaces: true,
1061            normalize_dashes: true,
1062            ..Default::default()
1063        });
1064        let ctx = LintContext::new(document, MarkdownFlavor::Standard, None);
1065        let once = rule.fix(&ctx).unwrap();
1066        assert_ne!(once, document, "the fix must change something");
1067        let ctx = LintContext::new(&once, MarkdownFlavor::Standard, None);
1068        let twice = rule.fix(&ctx).unwrap();
1069        assert_eq!(once, twice);
1070    }
1071
1072    /// Block structure and text of `content` per pulldown-cmark with every option on,
1073    /// the covered spaces folded to ASCII ones: what a substitution must not change.
1074    fn parsed_shape(content: &str) -> Vec<String> {
1075        use pulldown_cmark::{Event, Options, Parser};
1076        let mut shape = Vec::new();
1077        let mut text = String::new();
1078        for event in Parser::new_ext(content, Options::all()) {
1079            if let Event::Text(t) = &event {
1080                text.extend(t.chars().map(|c| if space_name(c).is_some() { ' ' } else { c }));
1081                continue;
1082            }
1083            if !text.is_empty() {
1084                shape.push(format!("text {}", std::mem::take(&mut text)));
1085            }
1086            shape.push(format!("{event:?}").replace(['\u{a0}', '\u{202f}', '\u{3000}'], " "));
1087        }
1088        if !text.is_empty() {
1089            shape.push(format!("text {text}"));
1090        }
1091        shape
1092    }
1093
1094    /// Fixing `doc` must leave the parse unchanged; returns (findings, fixes offered, fixed text).
1095    fn fix_keeps_parse(doc: &str) -> (usize, usize, String) {
1096        let (warnings, fixed) = check_spaces(doc);
1097        assert_eq!(
1098            parsed_shape(&fixed),
1099            parsed_shape(doc),
1100            "fix changed the parse of {doc:?} -> {fixed:?}"
1101        );
1102        let offered = warnings.iter().filter(|w| w.fix.is_some()).count();
1103        (warnings.len(), offered, fixed)
1104    }
1105
1106    #[test]
1107    fn test_space_fix_is_withheld_where_the_parser_reads_the_result_differently() {
1108        // Each document has exactly one covered space, shown as U+00A0, and an ASCII
1109        // space there would change the parse; the finding stays without a fix.
1110        let refused = [
1111            "# title\u{a0}###\n",                   // closing hash sequence swallows the hashes
1112            "## title\u{a0}##   \n",                // same, with trailing spaces
1113            "- [x]\u{a0}text\n",                    // task list marker
1114            "- [ ]\u{a0}text\n",                    // unchecked task marker
1115            "Title\n===\u{a0}\n",                   // setext underline
1116            "Title\n---\u{a0}\n",                   // setext underline, dashes
1117            "*\u{a0}*\u{a0}*\n",                    // thematic break
1118            "- -\u{a0}-\n",                         // thematic break, dashes
1119            "| a | b |\n|---|\u{a0}---|\n",         // delimiter row that stops being invalid
1120            "[a]:\u{a0}/url\n\n[a]\n",              // link reference definition
1121            "[a]: /url\u{a0}\"title\"\n\n[a]\n",    // definition title
1122            "<div\u{a0}class=\"x\">\nhi\n</div>\n", // HTML block start
1123            "foo \u{a0}\nbar\n",                    // hard break
1124            "foo\u{a0}\u{a0}\nbar\n",               // hard break from two spaces
1125            "text\n\n  \u{a0}  code\n",             // indented code
1126            ">\u{a0}quote\n",                       // block quote marker
1127            "-\u{a0}item\n",                        // bullet
1128            "1.\u{a0}item\n",                       // ordered marker
1129            "*\u{a0}a*\n",                          // emphasis marker turned list marker
1130            "- a\n  -\u{a0}b\n",                    // nested bullet
1131            "> -\u{a0}b\n",                         // bullet in a quote
1132        ];
1133        for doc in refused {
1134            let (found, offered, fixed) = fix_keeps_parse(doc);
1135            assert_eq!(found, doc.matches('\u{a0}').count(), "{doc:?}");
1136            assert_eq!(offered, 0, "a fix was offered for {doc:?}");
1137            assert_eq!(fixed, doc, "{doc:?}");
1138        }
1139    }
1140
1141    #[test]
1142    fn test_space_fix_is_offered_where_the_parse_cannot_change() {
1143        let applied = [
1144            ("100\u{a0}km\n", "100 km\n"),
1145            ("# title\u{a0}text\n", "# title text\n"),
1146            ("- [x] done\u{a0}today\n", "- [x] done today\n"),
1147            ("x *\u{a0}a* y\n", "x * a* y\n"),
1148            (
1149                "| a | b |\n|---|---|\n| c\u{a0}d | e |\n",
1150                "| a | b |\n|---|---|\n| c d | e |\n",
1151            ),
1152            ("> quote\u{a0}text\n", "> quote text\n"),
1153            ("Title\u{a0}one\n===\n", "Title one\n===\n"),
1154            ("1. item\u{a0}two\n", "1. item two\n"),
1155        ];
1156        for (doc, expected) in applied {
1157            let (found, offered, fixed) = fix_keeps_parse(doc);
1158            assert_eq!((found, offered), (1, 1), "{doc:?}");
1159            assert_eq!(fixed, expected);
1160        }
1161    }
1162
1163    #[test]
1164    fn test_space_fix_withholds_only_the_offending_spaces_of_a_document() {
1165        // Many harmless spaces and one that closes a heading's hash run: the harmless
1166        // ones are still fixed, so the parser check is not all-or-nothing.
1167        let mut doc = String::new();
1168        for i in 0..40 {
1169            doc.push_str(&format!("line{i} a\u{a0}b\n\n"));
1170        }
1171        doc.push_str("# title\u{a0}###\n\ntail c\u{a0}d\n");
1172        let (found, offered, fixed) = fix_keeps_parse(&doc);
1173        assert_eq!((found, offered), (42, 41));
1174        assert!(fixed.contains("# title\u{a0}###\n"));
1175        assert!(fixed.contains("tail c d"));
1176        assert!(!fixed[..fixed.find("# title").unwrap()].contains('\u{a0}'));
1177    }
1178
1179    #[test]
1180    fn test_space_fix_withholds_spaces_that_only_interact() {
1181        // Alone, either space leaves the line a plain paragraph; replaced together they
1182        // would form a hard break. Neither may be fixed.
1183        let doc = "foo\u{a0}\u{a0}\nbar\n";
1184        let (found, offered, fixed) = fix_keeps_parse(doc);
1185        assert_eq!((found, offered), (2, 0));
1186        assert_eq!(fixed, doc);
1187    }
1188
1189    #[test]
1190    fn test_spaces_that_change_the_parse_only_together_are_all_flagged() {
1191        // Replacing either space alone leaves the second row an invalid delimiter row;
1192        // replacing both turns the lines into a table.
1193        let doc = "| a | b |\n|---\u{a0}|\u{a0}---|\n";
1194        let offsets: Vec<usize> = doc.match_indices('\u{a0}').map(|(i, _)| i).collect();
1195        assert_eq!(offsets.len(), 2);
1196        for &single in &offsets {
1197            assert!(spaces_changing_structure(doc, &[single]).is_empty());
1198        }
1199        let flagged = spaces_changing_structure(doc, &offsets);
1200        assert_eq!(flagged, offsets.iter().copied().collect::<HashSet<_>>());
1201
1202        // A harmless space elsewhere is not flagged when the interacting pair is isolated.
1203        let doc = format!("{doc}\nplain a\u{a0}b\n");
1204        let all: Vec<usize> = doc.match_indices('\u{a0}').map(|(i, _)| i).collect();
1205        let flagged = spaces_changing_structure(&doc, &all);
1206        assert!(flagged.contains(&all[0]) && flagged.contains(&all[1]));
1207    }
1208
1209    #[test]
1210    fn test_space_fix_is_withheld_where_the_change_reaches_another_block() {
1211        // Each space is harmless within its own block read alone; together with the
1212        // neighbouring block the parse changes, so no fix may be offered.
1213        let refused = [
1214            "- a\n\n\u{a0}\u{a0}b\n",                 // text joins the list item above
1215            "[a]: /url\u{a0}\"title\"\n\n[a]\n",      // definition read differently by a later link
1216            "\u{a0}```\nx\n\n# h\n\n```\n",           // a fence opens and swallows what follows
1217            "para\n\n\u{a0}\u{a0}\u{a0}\u{a0}code\n", // indented code
1218            "[a\u{a0}b]: /url\n\n[a b]\n",            // a later reference starts to resolve
1219        ];
1220        for doc in refused {
1221            let (found, offered, fixed) = fix_keeps_parse(doc);
1222            assert_eq!(found, doc.matches('\u{a0}').count(), "{doc:?}");
1223            assert_eq!(offered, 0, "a fix was offered for {doc:?}");
1224            assert_eq!(fixed, doc);
1225        }
1226    }
1227
1228    #[test]
1229    fn test_space_guard_catches_a_change_visible_only_in_another_block() {
1230        // The definition reads the same alone with either space; the link in a later
1231        // block resolves only with the ASCII one. Blocks are checked apart, so the
1232        // spaces judged safe are checked once more against the whole document.
1233        for doc in [
1234            "[a\u{a0}b]: /url\n\n# h\n\n[a b]\n",
1235            "[a]: /url\u{a0}\"t\"\n\n# h\n\n[a]\n",
1236        ] {
1237            let offsets: Vec<usize> = doc.match_indices('\u{a0}').map(|(i, _)| i).collect();
1238            let starts = top_level_block_starts(doc);
1239            assert!(starts.len() > 2, "{doc:?}");
1240            let alone = isolate_structure_changes(&doc[..starts[1]], &offsets, &ParseBudget::for_document(doc));
1241            assert!(alone.is_empty(), "the block alone must look harmless: {doc:?}");
1242            let flagged = spaces_changing_structure(doc, &offsets);
1243            assert_eq!(flagged, offsets.iter().copied().collect::<HashSet<_>>(), "{doc:?}");
1244        }
1245    }
1246
1247    #[test]
1248    fn test_space_guard_budget_never_turns_into_an_unchecked_fix() {
1249        let doc = "a\u{a0}b\n\nc\u{a0}d\n";
1250        let candidates: Vec<usize> = doc.match_indices('\u{a0}').map(|(i, _)| i).collect();
1251        let all: HashSet<usize> = candidates.iter().copied().collect();
1252
1253        // Without a cap both are proven harmless.
1254        let ample = ParseBudget::for_document(doc);
1255        assert!(isolate_structure_changes(doc, &candidates, &ample).is_empty());
1256
1257        // A cap too small to parse the document leaves every offset unproven.
1258        let none = ParseBudget::with_allowance(0);
1259        assert_eq!(isolate_structure_changes(doc, &candidates, &none), all);
1260
1261        // A cap that covers the base parse and nothing else does too.
1262        let base_only = ParseBudget::with_allowance(doc.len());
1263        assert_eq!(isolate_structure_changes(doc, &candidates, &base_only), all);
1264
1265        // End to end: one block quote with thousands of interleaved unsafe and harmless
1266        // spaces needs more probes than the cap pays for. The unsafe spaces never get a
1267        // fix, whatever the cap leaves unproven, and the result parses as before.
1268        let unit = "> - [x]\u{a0}done\n> - ok a\u{a0}b\n";
1269        let doc = unit.repeat(3000);
1270        let (warnings, fixed) = check_spaces(&doc);
1271        assert_eq!(warnings.len(), 6000);
1272        for (i, warning) in warnings.iter().enumerate() {
1273            if i % 2 == 0 {
1274                assert!(warning.fix.is_none(), "unsafe space {i} was offered a fix");
1275            }
1276        }
1277        let offered = warnings.iter().filter(|w| w.fix.is_some()).count();
1278        assert!(offered < 3000, "the cap should have left harmless spaces unproven");
1279        assert_eq!(parsed_shape(&fixed), parsed_shape(&doc));
1280    }
1281
1282    #[test]
1283    fn test_space_guard_checks_each_block_on_its_own_text() {
1284        // Offending spaces in separate blocks are isolated without reparsing the whole
1285        // document for each: the cap here is far below what a whole-document probe per
1286        // space would need, and every offender is still found.
1287        let unit = "# title\u{a0}###\n\nplain a\u{a0}b\n\n";
1288        let doc = unit.repeat(300);
1289        let offsets: Vec<usize> = doc.match_indices('\u{a0}').map(|(i, _)| i).collect();
1290        let flagged = spaces_changing_structure(&doc, &offsets);
1291        assert_eq!(flagged.len(), 300);
1292        for (i, offset) in offsets.iter().enumerate() {
1293            assert_eq!(flagged.contains(offset), i % 2 == 0, "offset {offset}");
1294        }
1295    }
1296
1297    #[test]
1298    fn test_space_fix_respects_tag_data_such_as_heading_attributes() {
1299        // The attribute block keeps its classes only with an ASCII space between them.
1300        let doc = "# t {.a\u{a0}.b}\n";
1301        let (found, offered, fixed) = fix_keeps_parse(doc);
1302        assert_eq!((found, offered), (1, 0), "{fixed:?}");
1303    }
1304}