Skip to main content

omni_dev/jev/
markdown_code.rs

1//! Masks the code in a piece of Markdown, so text scanners can skip it (#2003).
2//!
3//! A quoted error message or example inside a fenced code block or an inline
4//! code span is not prose: `owner/repo#123` in one is an example, not a
5//! reference. [`mask_code`] replaces every code character with
6//! `MASK_CHAR`, leaving everything else (including every newline) as it
7//! was, so an existing regex can run on the result unchanged.
8//!
9//! This is deliberately a small subset of CommonMark, not a parser:
10//!
11//! - **Fenced blocks** are recognised only when *closed*. CommonMark runs an
12//!   unclosed fence to the end of the document, but an unclosed fence would
13//!   then hide every citation after it. Leaving it as text falls back to the
14//!   pre-#2003 behaviour, which is the safe direction. A fence may be indented
15//!   (a list item's) or quoted (`> `), which is why indentation is not
16//!   limited: an indented *code block* is not recognised either way, and a
17//!   fence-looking line inside one only matters if a matching closer follows.
18//! - **Inline spans** must open and close within one paragraph, so a stray
19//!   backtick cannot pair with one paragraphs later and mask what lies
20//!   between. A fenced block ends the paragraph it interrupts, and a
21//!   backslash-escaped backtick cannot open a span.
22//! - **Indented code blocks** are not recognised: without a full parser they
23//!   cannot be told apart from list continuation.
24//!
25//! Callers with several bodies (an issue and its comments) must mask each one
26//! on its own, so an unclosed fence or a stray backtick in one cannot pair
27//! with markup in another.
28
29use std::ops::Range;
30
31/// What a code character is replaced with.
32///
33/// Neither whitespace nor a word character, so masking a span can neither
34/// glue the tokens around it together (`PR`, a span, `#5`) nor make a number
35/// before it run into a word character (`#12` then a span).
36const MASK_CHAR: char = '\u{FFFC}';
37
38/// The shortest run of fence characters that opens or closes a fence.
39const MIN_FENCE_LEN: usize = 3;
40
41/// Returns `text` with fenced code blocks and inline code spans replaced by
42/// `MASK_CHAR`, newlines preserved.
43#[must_use]
44pub fn mask_code(text: &str) -> String {
45    let unfenced = apply_mask(text, &fenced_ranges(text));
46    apply_mask(&unfenced, &inline_ranges(&unfenced))
47}
48
49/// Replaces every non-newline character inside `ranges` (sorted and
50/// non-overlapping) with `MASK_CHAR`.
51fn apply_mask(text: &str, ranges: &[Range<usize>]) -> String {
52    if ranges.is_empty() {
53        return text.to_string();
54    }
55    let mut out = String::with_capacity(text.len());
56    let mut cursor = 0;
57    for range in ranges {
58        out.push_str(&text[cursor..range.start]);
59        out.extend(
60            text[range.clone()]
61                .chars()
62                .map(|c| if c == '\n' { c } else { MASK_CHAR }),
63        );
64        cursor = range.end;
65    }
66    out.push_str(&text[cursor..]);
67    out
68}
69
70/// A fence's marker character and the length of its opening run.
71#[derive(Clone, Copy)]
72struct Fence {
73    marker: char,
74    len: usize,
75}
76
77/// Reads `line` as a fence line: any indentation and blockquote markers
78/// (`> `), then a run of at least [`MIN_FENCE_LEN`] identical backticks or
79/// tildes. Returns the fence and what follows the run.
80fn parse_fence_line(line: &str) -> Option<(Fence, &str)> {
81    let mut rest = line.trim_end_matches(['\n', '\r']);
82    loop {
83        rest = rest.trim_start_matches([' ', '\t']);
84        match rest.strip_prefix('>') {
85            Some(after) => rest = after,
86            None => break,
87        }
88    }
89    let marker = rest.chars().next().filter(|c| matches!(c, '`' | '~'))?;
90    let len = rest.chars().take_while(|&c| c == marker).count();
91    (len >= MIN_FENCE_LEN).then(|| {
92        (
93            Fence { marker, len },
94            &rest[len..], // the marker is one byte, so `len` chars is `len` bytes
95        )
96    })
97}
98
99/// Whether `line` opens a fence, returning it. A backtick fence's info
100/// string may not contain a backtick, or the line is an inline span.
101fn opening_fence(line: &str) -> Option<Fence> {
102    let (fence, info) = parse_fence_line(line)?;
103    (fence.marker != '`' || !info.contains('`')).then_some(fence)
104}
105
106/// Whether `line` closes `open`: the same marker, at least as long, and
107/// nothing after it but whitespace.
108fn closes_fence(line: &str, open: Fence) -> bool {
109    parse_fence_line(line).is_some_and(|(fence, after)| {
110        fence.marker == open.marker && fence.len >= open.len && after.trim().is_empty()
111    })
112}
113
114/// The byte range of every *closed* fenced block, opening line through
115/// closing line.
116fn fenced_ranges(text: &str) -> Vec<Range<usize>> {
117    let mut ranges = Vec::new();
118    let mut open: Option<(Fence, usize)> = None;
119    let mut offset = 0;
120    for line in text.split_inclusive('\n') {
121        let end = offset + line.len();
122        match open {
123            None => {
124                if let Some(fence) = opening_fence(line) {
125                    open = Some((fence, offset));
126                }
127            }
128            Some((fence, start)) => {
129                if closes_fence(line, fence) {
130                    ranges.push(start..end);
131                    open = None;
132                }
133            }
134        }
135        offset = end;
136    }
137    ranges
138}
139
140/// The byte range of every paragraph in `text`: a maximal run of lines that
141/// are neither blank nor (already masked) fenced code, so a fenced block ends
142/// the paragraph it interrupts.
143fn paragraphs(text: &str) -> Vec<Range<usize>> {
144    let mut paragraphs = Vec::new();
145    let mut start: Option<usize> = None;
146    let mut offset = 0;
147    for line in text.split_inclusive('\n') {
148        let trimmed = line.trim_start();
149        let separator = trimmed.is_empty() || trimmed.starts_with(MASK_CHAR);
150        match (separator, start) {
151            (false, None) => start = Some(offset),
152            (true, Some(s)) => {
153                paragraphs.push(s..offset);
154                start = None;
155            }
156            _ => {}
157        }
158        offset += line.len();
159    }
160    if let Some(s) = start {
161        paragraphs.push(s..text.len());
162    }
163    paragraphs
164}
165
166/// A maximal run of backticks.
167struct Run {
168    range: Range<usize>,
169    /// Whether a backslash escapes the run's first backtick. That backtick
170    /// is then literal text, so the run cannot open a span from it — but
171    /// inside a span a backslash is literal, so the run still closes one at
172    /// its full length.
173    escaped: bool,
174}
175
176impl Run {
177    /// The length of the run when it is an opener.
178    fn opening_len(&self) -> usize {
179        self.range.len() - usize::from(self.escaped)
180    }
181}
182
183/// The byte ranges of every inline code span: a run of backticks closed by
184/// the next run of the same length, within one paragraph. An unmatched run is
185/// literal.
186fn inline_ranges(text: &str) -> Vec<Range<usize>> {
187    let mut ranges = Vec::new();
188    for paragraph in paragraphs(text) {
189        let runs = backtick_runs(text, paragraph);
190        let mut k = 0;
191        while k < runs.len() {
192            let opener = &runs[k];
193            let closer = (opener.opening_len() > 0)
194                .then(|| {
195                    runs[k + 1..]
196                        .iter()
197                        .position(|run| run.range.len() == opener.opening_len())
198                })
199                .flatten();
200            match closer {
201                Some(offset) => {
202                    let start = opener.range.end - opener.opening_len();
203                    ranges.push(start..runs[k + 1 + offset].range.end);
204                    k += offset + 2;
205                }
206                None => k += 1,
207            }
208        }
209    }
210    ranges
211}
212
213/// Every maximal run of backticks within `paragraph`.
214fn backtick_runs(text: &str, paragraph: Range<usize>) -> Vec<Run> {
215    let mut runs = Vec::new();
216    let mut start: Option<usize> = None;
217    let mut push = |start: usize, end: usize| {
218        let backslashes = text[..start]
219            .chars()
220            .rev()
221            .take_while(|&c| c == '\\')
222            .count();
223        runs.push(Run {
224            range: start..end,
225            escaped: backslashes % 2 == 1,
226        });
227    };
228    for (i, c) in text[paragraph.clone()].char_indices() {
229        let i = paragraph.start + i;
230        match (c == '`', start) {
231            (true, None) => start = Some(i),
232            (false, Some(s)) => {
233                push(s, i);
234                start = None;
235            }
236            _ => {}
237        }
238    }
239    if let Some(s) = start {
240        push(s, paragraph.end);
241    }
242    runs
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    /// Masks `text` and renders each masked character as `_`, so an
250    /// expectation reads as the visible remainder.
251    fn visible(text: &str) -> String {
252        mask_code(text)
253            .chars()
254            .map(|c| if c == MASK_CHAR { '_' } else { c })
255            .collect()
256    }
257
258    #[test]
259    fn text_without_code_is_unchanged() {
260        let text = "See #1614 and owner/repo#5.\n\nA second paragraph.";
261        assert_eq!(mask_code(text), text);
262    }
263
264    #[test]
265    fn a_backtick_fence_is_masked_through_its_closing_line() {
266        assert_eq!(
267            visible("before\n```\no/r#1\n```\nafter #2"),
268            "before\n___\n_____\n___\nafter #2"
269        );
270    }
271
272    #[test]
273    fn a_tilde_fence_is_masked() {
274        assert_eq!(visible("~~~\no/r#1\n~~~\n#2"), "___\n_____\n___\n#2");
275    }
276
277    #[test]
278    fn a_fence_info_string_is_part_of_the_block() {
279        assert_eq!(
280            visible("```rust\no/r#1\n```\n#2"),
281            "_______\n_____\n___\n#2"
282        );
283    }
284
285    #[test]
286    fn a_closing_fence_may_be_longer_than_the_opening_one() {
287        assert_eq!(visible("```\no/r#1\n`````\n#2"), "___\n_____\n_____\n#2");
288    }
289
290    #[test]
291    fn a_shorter_run_does_not_close_a_fence() {
292        // The four-backtick fence stays open past the three-backtick line and
293        // is closed by the last line.
294        assert_eq!(
295            visible("````\n```\no/r#1\n````\n#2"),
296            "____\n___\n_____\n____\n#2"
297        );
298    }
299
300    #[test]
301    fn a_fence_of_the_other_marker_does_not_close_a_fence() {
302        assert_eq!(
303            visible("```\n~~~\no/r#1\n```\n#2"),
304            "___\n___\n_____\n___\n#2"
305        );
306    }
307
308    #[test]
309    fn a_closing_fence_carrying_text_does_not_close_the_fence() {
310        assert_eq!(
311            visible("```\n``` x\no/r#1\n```\n#2"),
312            "___\n_____\n_____\n___\n#2"
313        );
314    }
315
316    #[test]
317    fn a_fence_indented_up_to_three_spaces_is_still_a_fence() {
318        assert_eq!(
319            visible("   ```\no/r#1\n   ```\n#2"),
320            "______\n_____\n______\n#2"
321        );
322    }
323
324    /// A fence nested in a list item is indented past three spaces.
325    #[test]
326    fn a_fence_nested_in_a_list_item_is_masked() {
327        assert_eq!(
328            visible("1. step\n\n       ~~~\n       o/r#1\n       ~~~\n\n#2"),
329            "1. step\n\n__________\n____________\n__________\n\n#2"
330        );
331    }
332
333    #[test]
334    fn a_blockquoted_fence_is_masked() {
335        assert_eq!(
336            visible("> ~~~\n> o/r#1\n> ~~~\n#2"),
337            "_____\n_______\n_____\n#2"
338        );
339    }
340
341    /// A fenced block ends the paragraph it interrupts, so backticks either
342    /// side of it cannot pair across it and hide the text between.
343    #[test]
344    fn a_span_does_not_pair_across_a_fenced_block() {
345        assert_eq!(
346            visible("a ` b\n```\ncode\n```\nreal #1 ` d"),
347            "a ` b\n___\n____\n___\nreal #1 ` d"
348        );
349    }
350
351    /// An escaped backtick is literal text, not a span delimiter.
352    #[test]
353    fn an_escaped_backtick_does_not_open_a_span() {
354        let text = "use \\`#5\\` literally, see #6";
355        assert_eq!(mask_code(text), text);
356    }
357
358    #[test]
359    fn an_escaped_backtick_does_not_stop_a_later_span() {
360        assert_eq!(visible("a \\` b `c` d"), "a \\` b ___ d");
361    }
362
363    /// Inside a span a backslash is literal, so `\`` still closes it.
364    #[test]
365    fn a_backslash_before_a_closing_backtick_still_closes_the_span() {
366        assert_eq!(visible("`a\\` #1"), "____ #1");
367    }
368
369    /// Masking one body at a time is the caller's job: an unclosed fence in
370    /// one pairs with a fence in the next when they are masked together.
371    #[test]
372    fn bodies_masked_together_can_pair_a_stray_fence_with_a_later_block() {
373        assert_ne!(
374            mask_code("```\nstray\n\n```\nquoted o/r#9\n```"),
375            format!(
376                "{}{}",
377                mask_code("```\nstray\n"),
378                mask_code("\n```\nquoted o/r#9\n```")
379            )
380        );
381    }
382
383    #[test]
384    fn an_unclosed_fence_is_left_as_text() {
385        let text = "```\no/r#1\nstill going #2";
386        assert_eq!(mask_code(text), text);
387    }
388
389    #[test]
390    fn a_backtick_line_whose_info_string_has_a_backtick_is_not_a_fence() {
391        // Inline code, not a fence: only the span is masked.
392        assert_eq!(visible("``` a`b` c\n#2"), "``` a___ c\n#2");
393    }
394
395    #[test]
396    fn a_crlf_fence_is_masked() {
397        assert_eq!(
398            visible("```\r\no/r#1\r\n```\r\n#2"),
399            "____\n______\n____\n#2"
400        );
401    }
402
403    #[test]
404    fn an_inline_span_is_masked_and_the_rest_is_kept() {
405        assert_eq!(visible("use `o/r#1` here #2"), "use _______ here #2");
406    }
407
408    #[test]
409    fn a_double_backtick_span_may_contain_a_single_backtick() {
410        assert_eq!(visible("x ``a ` b`` y"), "x _________ y");
411    }
412
413    #[test]
414    fn an_unmatched_backtick_run_is_literal() {
415        let text = "a ` b #1";
416        assert_eq!(mask_code(text), text);
417    }
418
419    #[test]
420    fn runs_of_different_lengths_do_not_pair() {
421        let text = "a `` b ` c #1";
422        assert_eq!(mask_code(text), text);
423    }
424
425    #[test]
426    fn an_inline_span_may_cross_a_line_break() {
427        assert_eq!(visible("`o/r\n#1` #2"), "____\n___ #2");
428    }
429
430    /// A stray backtick must not pair with one in a later paragraph and hide
431    /// what is between them.
432    #[test]
433    fn an_inline_span_does_not_cross_a_blank_line() {
434        let text = "a ` b\n\nreal #1\n\nc ` d";
435        assert_eq!(mask_code(text), text);
436    }
437
438    #[test]
439    fn an_unmatched_opener_does_not_stop_a_later_pair_matching() {
440        assert_eq!(visible("`a\n\nb `c` d"), "`a\n\nb ___ d");
441    }
442
443    #[test]
444    fn two_spans_on_one_line_are_masked_separately() {
445        assert_eq!(visible("`a` #1 `b`"), "___ #1 ___");
446    }
447
448    #[test]
449    fn a_span_inside_a_fence_does_not_open_a_second_mask() {
450        assert_eq!(visible("```\n`a`\n```\n#1"), "___\n___\n___\n#1");
451    }
452
453    #[test]
454    fn masking_keeps_multibyte_text_around_a_span_intact() {
455        assert_eq!(visible("café `é#1` ünï #2"), "café _____ ünï #2");
456    }
457
458    #[test]
459    fn every_newline_survives_masking() {
460        let text = "a\n```\nb\n\nc\n```\n`d\ne`\nf\n";
461        assert_eq!(
462            mask_code(text).matches('\n').count(),
463            text.matches('\n').count()
464        );
465    }
466
467    #[test]
468    fn an_empty_string_masks_to_an_empty_string() {
469        assert_eq!(mask_code(""), "");
470    }
471}