omni_dev/jev/
markdown_code.rs1use std::ops::Range;
30
31const MASK_CHAR: char = '\u{FFFC}';
37
38const MIN_FENCE_LEN: usize = 3;
40
41#[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
49fn 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#[derive(Clone, Copy)]
72struct Fence {
73 marker: char,
74 len: usize,
75}
76
77fn 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..], )
96 })
97}
98
99fn opening_fence(line: &str) -> Option<Fence> {
102 let (fence, info) = parse_fence_line(line)?;
103 (fence.marker != '`' || !info.contains('`')).then_some(fence)
104}
105
106fn 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
114fn 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
140fn 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
166struct Run {
168 range: Range<usize>,
169 escaped: bool,
174}
175
176impl Run {
177 fn opening_len(&self) -> usize {
179 self.range.len() - usize::from(self.escaped)
180 }
181}
182
183fn 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
213fn 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 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 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 #[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 #[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 #[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 #[test]
365 fn a_backslash_before_a_closing_backtick_still_closes_the_span() {
366 assert_eq!(visible("`a\\` #1"), "____ #1");
367 }
368
369 #[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 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 #[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}