1#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub enum CodeRules {
72 #[default]
73 CommonMark,
75 SkillExport,
77}
78
79fn fence_run(line: &[u8]) -> Option<(u8, usize, usize)> {
84 let at = leading_spaces(line, 3);
85 let character = *line.get(at)?;
86 if character != b'`' && character != b'~' {
87 return None;
88 }
89 let length = line[at..].iter().take_while(|b| **b == character).count();
90 (length >= 3).then_some((character, length, at + length))
91}
92
93fn opens_fence(line: &[u8], rules: CodeRules) -> Option<(u8, usize)> {
94 let (character, length, end) = fence_run(line)?;
95 let backtick_in_info = character == b'`' && line[end..].contains(&b'`');
96 if rules == CodeRules::CommonMark && backtick_in_info {
97 return None;
98 }
99 Some((character, length))
100}
101
102fn closes_fence(line: &[u8], (open_char, open_len): (u8, usize), rules: CodeRules) -> bool {
103 let Some((character, length, end)) = fence_run(line) else {
104 return false;
105 };
106 character == open_char
107 && length >= open_len
108 && (rules == CodeRules::SkillExport || is_blank(&line[end..]))
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum FenceLine {
114 Marker,
117 Inside,
119 Outside,
121}
122
123#[derive(Debug, Clone, Copy, Default)]
138pub struct Fences {
139 rules: CodeRules,
140 open: Option<(u8, usize)>,
141}
142
143impl Fences {
144 pub fn new(rules: CodeRules) -> Self {
146 Self { rules, open: None }
147 }
148
149 pub fn line(&mut self, line: &[u8]) -> FenceLine {
151 match self.open {
152 None => match opens_fence(line, self.rules) {
153 Some(fence) => {
154 self.open = Some(fence);
155 FenceLine::Marker
156 }
157 None => FenceLine::Outside,
158 },
159 Some(fence) => {
160 if closes_fence(line, fence, self.rules) {
161 self.open = None;
162 FenceLine::Marker
163 } else if self.rules == CodeRules::SkillExport && fence_run(line).is_some() {
164 FenceLine::Marker
165 } else {
166 FenceLine::Inside
167 }
168 }
169 }
170 }
171}
172
173#[derive(Debug, Clone)]
198pub struct Spans<'a> {
199 bytes: &'a [u8],
200 rules: CodeRules,
201 one_line: bool,
203 no_close: Option<BlockRuns>,
204}
205
206#[derive(Debug, Clone)]
209struct BlockRuns {
210 rules: CodeRules,
211 start: usize,
212 end: usize,
213 last_close: Vec<(usize, usize)>,
216}
217
218impl<'a> Spans<'a> {
219 pub fn new(rules: CodeRules, bytes: &'a [u8]) -> Self {
221 Self {
222 bytes,
223 rules,
224 one_line: false,
225 no_close: None,
226 }
227 }
228
229 pub fn line(&mut self, line: &[u8]) {
233 self.one_line = self.rules == CodeRules::CommonMark && is_one_line_block(line);
234 }
235
236 pub fn close(&mut self, start: usize) -> (usize, Option<usize>) {
241 debug_assert_eq!(self.bytes.get(start), Some(&b'`'));
242 let length = run_length(self.bytes, start);
243 let from = start + length;
244 if let Some(runs) = &self.no_close
245 && runs.start <= from
246 && from < runs.end
247 && !runs.has_close(length, from)
248 {
249 return (length, None);
250 }
251 match self.search(length, from) {
252 Ok(close) => (length, Some(close)),
253 Err(end) => {
254 self.no_close = Some(BlockRuns::new(self.bytes, from, end, self.rules));
255 (length, None)
256 }
257 }
258 }
259
260 fn search(&self, length: usize, from: usize) -> Result<usize, usize> {
262 let bytes = self.bytes;
263 let mut at = from;
264 loop {
265 while at < bytes.len() && bytes[at] != b'\n' {
266 if bytes[at] != b'`' {
267 at += 1;
268 continue;
269 }
270 let run = run_length(bytes, at);
271 if closes(run, length, self.rules) {
272 return Ok(at);
273 }
274 at += run;
275 }
276 let next = at + 1;
277 if self.one_line || next >= bytes.len() {
278 return Err(next.min(bytes.len()));
279 }
280 if ends_span_block(&bytes[next..line_end(bytes, next)], self.rules) {
281 return Err(next);
282 }
283 at = next;
284 }
285 }
286}
287
288impl BlockRuns {
289 fn new(bytes: &[u8], start: usize, end: usize, rules: CodeRules) -> Self {
290 let mut last = std::collections::BTreeMap::new();
291 let mut at = start;
292 while at < end {
293 if bytes[at] != b'`' {
294 at += 1;
295 continue;
296 }
297 let run = run_length(&bytes[..end], at);
298 last.insert(run, at);
299 at += run;
300 }
301 let mut last_close: Vec<(usize, usize)> = last.into_iter().collect();
302 if rules == CodeRules::SkillExport {
303 for i in (0..last_close.len().saturating_sub(1)).rev() {
305 last_close[i].1 = last_close[i].1.max(last_close[i + 1].1);
306 }
307 }
308 Self {
309 rules,
310 start,
311 end,
312 last_close,
313 }
314 }
315
316 fn has_close(&self, length: usize, from: usize) -> bool {
319 let at = self.last_close.partition_point(|(run, _)| *run < length);
320 self.last_close.get(at).is_some_and(|&(run, start)| {
321 start >= from && (run == length || self.rules == CodeRules::SkillExport)
322 })
323 }
324}
325
326fn closes(run: usize, length: usize, rules: CodeRules) -> bool {
327 match rules {
328 CodeRules::CommonMark => run == length,
329 CodeRules::SkillExport => run >= length,
330 }
331}
332
333fn run_length(bytes: &[u8], at: usize) -> usize {
334 bytes[at..].iter().take_while(|b| **b == b'`').count()
335}
336
337fn ends_span_block(line: &[u8], rules: CodeRules) -> bool {
339 let line = line.strip_suffix(b"\r").unwrap_or(line);
340 if is_blank(line) {
341 return true;
342 }
343 match rules {
344 CodeRules::SkillExport => fence_run(line).is_some(),
345 CodeRules::CommonMark => opens_fence(line, rules).is_some() || starts_block(line),
346 }
347}
348
349fn is_one_line_block(line: &[u8]) -> bool {
351 let indented = &line[leading_spaces(line, 3)..];
352 atx_heading(indented) || indented.first() == Some(&b'|')
353}
354
355fn atx_heading(line: &[u8]) -> bool {
358 let hashes = line.iter().take_while(|b| **b == b'#').count();
359 (1..=6).contains(&hashes) && matches!(line.get(hashes), None | Some(b' ' | b'\t' | b'\r'))
360}
361
362fn starts_block(line: &[u8]) -> bool {
365 let indented = &line[leading_spaces(line, 3)..];
366 let rest = &line[line
367 .iter()
368 .take_while(|b| matches!(b, b' ' | b'\t'))
369 .count()..];
370 let ends_marker = |at: usize, text: &[u8]| matches!(text.get(at), None | Some(b' ' | b'\t'));
371
372 let equals = indented.iter().take_while(|b| **b == b'=').count();
373 let setext = equals > 0 && is_blank(&indented[equals..]);
374 let quote = rest.first() == Some(&b'>');
375 let bullet = matches!(rest.first(), Some(b'-' | b'*' | b'+')) && ends_marker(1, rest);
376 let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
377 let ordered = (1..=9).contains(&digits)
378 && matches!(rest.get(digits), Some(b'.' | b')'))
379 && ends_marker(digits + 1, rest);
380 is_one_line_block(line) || setext || quote || bullet || ordered || thematic_break(indented)
381}
382
383fn thematic_break(line: &[u8]) -> bool {
385 let Some(&mark @ (b'-' | b'*' | b'_')) = line.first() else {
386 return false;
387 };
388 line.iter()
389 .all(|b| matches!(*b, b' ' | b'\t') || *b == mark)
390 && line.iter().filter(|b| **b == mark).count() >= 3
391}
392
393fn leading_spaces(line: &[u8], max: usize) -> usize {
394 line.iter().take(max).take_while(|b| **b == b' ').count()
395}
396
397fn is_blank(text: &[u8]) -> bool {
398 text.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r'))
399}
400
401fn line_end(bytes: &[u8], from: usize) -> usize {
404 bytes[from..]
405 .iter()
406 .position(|b| *b == b'\n')
407 .map_or(bytes.len(), |i| from + i)
408}
409
410pub fn strip_code(text: &str) -> String {
426 strip_code_with(text, CodeRules::CommonMark)
427}
428
429pub fn strip_code_with(text: &str, rules: CodeRules) -> String {
445 let bytes = text.as_bytes();
446 let mut out = String::with_capacity(text.len());
447 let mut fences = Fences::new(rules);
448 let mut spans = Spans::new(rules, bytes);
449 let mut written_up_to = 0usize;
450 let mut blank = |out: &mut String, start: usize, end: usize| {
451 out.push_str(&text[written_up_to..start]);
452 out.extend(
453 text[start..end]
454 .chars()
455 .map(|c| if c == '\n' { '\n' } else { ' ' }),
456 );
457 written_up_to = end;
458 };
459
460 let mut offset = 0usize;
461 while offset < bytes.len() {
462 if offset == 0 || bytes[offset - 1] == b'\n' {
463 let end = line_end(bytes, offset);
464 let line = &bytes[offset..end];
465 if fences.line(line) != FenceLine::Outside {
466 blank(&mut out, offset, end);
467 offset = end + 1;
468 continue;
469 }
470 spans.line(line);
471 }
472 if bytes[offset] == b'`' {
473 let (run, close) = spans.close(offset);
474 offset = match close {
475 Some(close) => {
476 blank(&mut out, offset, close + run);
477 close + run
478 }
479 None => offset + run,
480 };
481 continue;
482 }
483 offset += 1;
484 }
485 out.push_str(&text[written_up_to..]);
486 out
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 const BOTH: [CodeRules; 2] = [CodeRules::CommonMark, CodeRules::SkillExport];
494
495 fn find_span_close(bytes: &[u8], rules: CodeRules) -> Option<usize> {
498 let mut spans = Spans::new(rules, bytes);
499 spans.line(&bytes[..line_end(bytes, 0)]);
500 spans.close(0).1
501 }
502
503 #[test]
504 fn a_fence_becomes_spaces_and_keeps_its_newlines() {
505 let out = strip_code("a\n```\nhidden\n```\nb\n");
506 assert_eq!(out, "a\n \n \n \nb\n");
507 }
508
509 #[test]
510 fn an_inline_span_becomes_spaces() {
511 assert_eq!(strip_code("a `x` b"), "a b");
512 }
513
514 #[test]
515 fn an_unterminated_inline_backtick_is_left_alone() {
516 assert_eq!(strip_code("a `x b"), "a `x b");
517 }
518
519 #[test]
520 fn the_char_count_is_preserved_exactly() {
521 for rules in BOTH {
523 for src in [
524 "`é` after",
525 "```\nsanté\n```\n",
526 "~~~\nsanté\n",
527 "``a ` é``",
528 "plain text",
529 "a `x` b `y`",
530 ] {
531 assert_eq!(
532 strip_code_with(src, rules).chars().count(),
533 src.chars().count(),
534 "for {src:?} under {rules:?}"
535 );
536 }
537 }
538 }
539
540 #[test]
541 fn fence_markers_take_up_to_three_spaces_and_three_or_more_characters() {
542 assert_eq!(fence_run(b"```"), Some((b'`', 3, 3)));
543 assert_eq!(fence_run(b" ~~~~ info"), Some((b'~', 4, 7)));
544 assert_eq!(fence_run(b" ```"), None);
545 assert_eq!(fence_run(b"``"), None);
546 assert_eq!(fence_run(b"text ```"), None);
547 }
548
549 #[test]
550 fn a_fence_closes_only_on_its_own_character_at_its_length_or_more() {
551 for rules in BOTH {
552 let mut fences = Fences::new(rules);
553 let kinds: Vec<FenceLine> = ["````", "```", "~~~~~", "x", "`````", "y"]
554 .iter()
555 .map(|line| fences.line(line.as_bytes()))
556 .collect();
557 let inner_marker = match rules {
558 CodeRules::CommonMark => FenceLine::Inside,
559 CodeRules::SkillExport => FenceLine::Marker,
560 };
561 assert_eq!(
562 kinds,
563 vec![
564 FenceLine::Marker,
565 inner_marker,
566 inner_marker,
567 FenceLine::Inside,
568 FenceLine::Marker,
569 FenceLine::Outside,
570 ],
571 "under {rules:?}"
572 );
573 }
574 }
575
576 #[test]
577 fn a_span_does_not_cross_a_blank_line_or_a_fence_line() {
578 for rules in BOTH {
579 assert_eq!(find_span_close(b"`a\nb`", rules), Some(4));
580 assert_eq!(find_span_close(b"`a\n\nb`", rules), None);
581 assert_eq!(find_span_close(b"`a\n```\nb`", rules), None);
582 }
583 }
584
585 #[test]
586 fn block_starts_end_a_commonmark_span_only() {
587 for next in [
588 "# h",
589 "###### h",
590 "===",
591 "***",
592 "_ _ _",
593 "> q",
594 "- i",
595 " * i",
596 " + nested",
597 "1. i",
598 "12) i",
599 ] {
600 let src = format!("`a\n{next}\nb`");
601 let bytes = src.as_bytes();
602 assert_eq!(
603 find_span_close(bytes, CodeRules::CommonMark),
604 None,
605 "{next:?}"
606 );
607 assert!(
608 find_span_close(bytes, CodeRules::SkillExport).is_some(),
609 "{next:?}"
610 );
611 }
612 for next in [
613 "#hashtag",
614 "####### seven",
615 "-dash",
616 "1.5 kg",
617 " # indented",
618 ] {
619 let src = format!("`a\n{next}\nb`");
620 assert!(
621 find_span_close(src.as_bytes(), CodeRules::CommonMark).is_some(),
622 "{next:?}"
623 );
624 }
625 }
626
627 #[test]
631 fn a_pass_is_linear_on_long_lines_and_unclosed_runs() {
632 let long_line = "`a` [[x]] ".repeat(200_000);
633 fn unclosed(lengths: impl Iterator<Item = usize>) -> String {
634 lengths
635 .map(|n| format!("x {} text\n", "`".repeat(n)))
636 .collect()
637 }
638 let rising = unclosed(1..=2000);
639 let falling = unclosed((1..=2000).rev());
640 for rules in BOTH {
641 for text in [&long_line, &rising, &falling] {
642 let started = std::time::Instant::now();
643 let out = strip_code_with(text, rules);
644 let elapsed = started.elapsed();
645 assert_eq!(out.len(), text.len());
646 assert!(
647 elapsed < std::time::Duration::from_secs(10),
648 "{} KB under {rules:?} took {elapsed:?}",
649 text.len() / 1000
650 );
651 }
652 }
653 }
654
655 #[test]
656 fn commonmark_closes_a_span_only_on_a_run_of_its_own_length() {
657 assert_eq!(find_span_close(b"`a``b`", CodeRules::CommonMark), Some(5));
658 assert_eq!(find_span_close(b"`a``b`", CodeRules::SkillExport), Some(2));
659 }
660}