1mod 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
22fn 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
49fn 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
62fn structure_keeping_space_runs(line: &str) -> Vec<Range<usize>> {
77 let mut runs = Vec::new();
78 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
102fn 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
116const STRUCTURE_RISK_NOTE: &str =
118 "; not fixed automatically because an ASCII space here could change the Markdown structure";
119
120fn fold_spaces(text: &str) -> String {
123 text.chars()
124 .map(|c| if space_name(c).is_some() { ' ' } else { c })
125 .collect()
126}
127
128fn 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
163fn 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
177const PARSE_BUDGET_FACTOR: usize = 16;
183const PARSE_BUDGET_FLOOR: usize = 16 * 1024;
184
185struct 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 fn signature(&self, text: &str) -> Option<Vec<String>> {
204 self.spend(text.len())?;
205 Some(structure_signature(text))
206 }
207
208 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 fn exhausted(&self) -> bool {
233 self.remaining.get() == 0
234 }
235}
236
237fn 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 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 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
303fn 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 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
331fn 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#[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 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 let mut space_fixes: Vec<(usize, usize)> = Vec::new();
454
455 for line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
456 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let refused = [
1111 "# title\u{a0}###\n", "## title\u{a0}## \n", "- [x]\u{a0}text\n", "- [ ]\u{a0}text\n", "Title\n===\u{a0}\n", "Title\n---\u{a0}\n", "*\u{a0}*\u{a0}*\n", "- -\u{a0}-\n", "| a | b |\n|---|\u{a0}---|\n", "[a]:\u{a0}/url\n\n[a]\n", "[a]: /url\u{a0}\"title\"\n\n[a]\n", "<div\u{a0}class=\"x\">\nhi\n</div>\n", "foo \u{a0}\nbar\n", "foo\u{a0}\u{a0}\nbar\n", "text\n\n \u{a0} code\n", ">\u{a0}quote\n", "-\u{a0}item\n", "1.\u{a0}item\n", "*\u{a0}a*\n", "- a\n -\u{a0}b\n", "> -\u{a0}b\n", ];
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 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 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 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 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 let refused = [
1214 "- a\n\n\u{a0}\u{a0}b\n", "[a]: /url\u{a0}\"title\"\n\n[a]\n", "\u{a0}```\nx\n\n# h\n\n```\n", "para\n\n\u{a0}\u{a0}\u{a0}\u{a0}code\n", "[a\u{a0}b]: /url\n\n[a b]\n", ];
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 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 let ample = ParseBudget::for_document(doc);
1255 assert!(isolate_structure_changes(doc, &candidates, &le).is_empty());
1256
1257 let none = ParseBudget::with_allowance(0);
1259 assert_eq!(isolate_structure_changes(doc, &candidates, &none), all);
1260
1261 let base_only = ParseBudget::with_allowance(doc.len());
1263 assert_eq!(isolate_structure_changes(doc, &candidates, &base_only), all);
1264
1265 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 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 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}