Skip to main content

markdown_link/
code.rs

1//! Code fences and inline code spans: finding them, and blanking them so a
2//! lexer never harvests out of them.
3//!
4//! One lexer for every caller. This crate's link scanners, a `#tag` lexer and
5//! any tool that rewrites markdown all ask "is this code", and a second copy
6//! would be a second answer.
7//!
8//! [`CodeRules`] picks between two rule sets.
9//!
10//! [`CodeRules::CommonMark`], the default, follows CommonMark:
11//!
12//! - A fence opens on a line of up to three spaces and a run of three or more
13//!   backticks or tildes. A backtick fence's info string may not hold a
14//!   backtick. The fence closes on a line holding only a run of the same
15//!   character, at least as long, and whitespace. An unclosed fence runs to
16//!   the end of the input.
17//! - An inline span opens on a run of backticks and closes on the next run of
18//!   exactly that many. A run with no close is literal text.
19//! - A span stays inside its block. A blank line, a fence, an ATX heading, a
20//!   setext underline, a thematic break, a block quote or a list item ends the
21//!   block. A span opened on an ATX heading stays on that line.
22//!
23//! [`CodeRules::SkillExport`] keeps the looser rules of some agent-skill
24//! renderers, for tools that must match them:
25//!
26//! - A backtick fence's info string may hold backticks.
27//! - Any fence marker line of the same character and at least the same length
28//!   closes a fence, whatever follows the run.
29//! - A longer backtick run closes a shorter span.
30//! - Only a blank line or a fence marker line ends a span's block.
31//!
32//! Both rule sets read one line at a time, so neither knows about container
33//! blocks. Indented code blocks are not code. A fence is found only when its
34//! marker starts within three spaces of the line start, so a fence nested in
35//! a list item or a block quote is missed. A list item or block quote marker
36//! ends a span's block at any indent and with any start number, even with no
37//! text after it, so it may end a span that CommonMark would continue. Nested
38//! items are indented, and ending a span early keeps text visible.
39//!
40//! [`CodeRules::CommonMark`] also treats a line starting with `|` after up to
41//! three spaces as a one-line block, table or not. A span cannot enter or
42//! leave that line, but a span in it may cross a cell. GFM reads a pipe line
43//! without a delimiter row as paragraph text, and splits a table row into
44//! cells before it reads spans.
45//!
46//! Every marker is ASCII, so a byte scan never splits a UTF-8 character.
47//!
48//! ```
49//! use markdown_link::code::{CodeRules, strip_code_with};
50//!
51//! let text = "keep [[a]] `[[b]]`";
52//! let blanked = strip_code_with(text, CodeRules::CommonMark);
53//! assert_eq!(blanked, "keep [[a]]        ");
54//! ```
55
56/// Which rules the lexer follows. See the module doc.
57///
58/// # Examples
59///
60/// ```
61/// use markdown_link::code::{CodeRules, strip_code_with};
62///
63/// // A longer backtick run closes a shorter span under `SkillExport` only.
64/// // (It closes with the first backtick of the run; the rest stays.)
65/// let text = "`a`` b";
66/// assert_eq!(strip_code_with(text, CodeRules::CommonMark), text);
67/// assert_eq!(strip_code_with(text, CodeRules::SkillExport), "   ` b");
68/// assert_eq!(CodeRules::default(), CodeRules::CommonMark);
69/// ```
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub enum CodeRules {
72    #[default]
73    /// CommonMark's rules.
74    CommonMark,
75    /// The looser rules of some agent-skill renderers. See the module doc.
76    SkillExport,
77}
78
79/// `^ {0,3}(`{3,}|~{3,})` — the character and run length of a fence marker
80/// line, and the index just past the run, or `None`. `line` excludes its
81/// `\n`. This is the marker's shape only; [`Fences`] applies the rules for
82/// opening and closing.
83fn 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/// Where a line sits relative to fenced code, as [`Fences::line`] reports it.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum FenceLine {
114    /// A line that opens or closes a fence. Under [`CodeRules::SkillExport`],
115    /// also any other fence marker line inside an open fence.
116    Marker,
117    /// A line inside an open fence.
118    Inside,
119    /// A line outside every fence.
120    Outside,
121}
122
123/// The fence state of a line-by-line scan: which fence, if any, is open.
124///
125/// # Examples
126///
127/// ```
128/// use markdown_link::code::{CodeRules, FenceLine, Fences};
129///
130/// let mut fences = Fences::new(CodeRules::CommonMark);
131/// assert_eq!(fences.line(b"text"), FenceLine::Outside);
132/// assert_eq!(fences.line(b"```rust"), FenceLine::Marker);
133/// assert_eq!(fences.line(b"[[not-a-link]]"), FenceLine::Inside);
134/// assert_eq!(fences.line(b"```"), FenceLine::Marker);
135/// assert_eq!(fences.line(b"text"), FenceLine::Outside);
136/// ```
137#[derive(Debug, Clone, Copy, Default)]
138pub struct Fences {
139    rules: CodeRules,
140    open: Option<(u8, usize)>,
141}
142
143impl Fences {
144    /// A scan with no fence open, following `rules`.
145    pub fn new(rules: CodeRules) -> Self {
146        Self { rules, open: None }
147    }
148
149    /// Classify the next line and step past it. `line` excludes its `\n`.
150    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/// Finds where inline code spans close, for a scan that reads its input once,
174/// left to right.
175///
176/// Call [`Spans::line`] at each line start the scan reaches outside a fence,
177/// and [`Spans::close`] at each backtick run it reaches. A whole pass is
178/// linear in the size of its input.
179///
180/// # Examples
181///
182/// ```
183/// use markdown_link::code::{CodeRules, Spans};
184///
185/// let bytes = b"`a` b";
186/// let mut spans = Spans::new(CodeRules::CommonMark, bytes);
187/// spans.line(bytes);
188/// // A one-backtick span opens at byte 0 and closes at byte 2.
189/// assert_eq!(spans.close(0), (1, Some(2)));
190///
191/// let open = b"`a b";
192/// let mut spans = Spans::new(CodeRules::CommonMark, open);
193/// spans.line(open);
194/// // No closing run: the backtick is literal text.
195/// assert_eq!(spans.close(0), (1, None));
196/// ```
197#[derive(Debug, Clone)]
198pub struct Spans<'a> {
199    bytes: &'a [u8],
200    rules: CodeRules,
201    /// Whether the current line is one a span cannot leave.
202    one_line: bool,
203    no_close: Option<BlockRuns>,
204}
205
206/// The backtick runs in `start..end`, the rest of a block from where a search
207/// found no close.
208#[derive(Debug, Clone)]
209struct BlockRuns {
210    rules: CodeRules,
211    start: usize,
212    end: usize,
213    /// `(length, start)` for each run length that occurs, sorted by length:
214    /// where the last run that would close a span of that length starts.
215    last_close: Vec<(usize, usize)>,
216}
217
218impl<'a> Spans<'a> {
219    /// A scanner over `bytes`, the whole input, following `rules`.
220    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    /// Note the line the scan has reached. `line` excludes its `\n`. Under
230    /// [`CodeRules::CommonMark`], a span opened on an ATX heading or a line
231    /// starting with `|` stays on that line.
232    pub fn line(&mut self, line: &[u8]) {
233        self.one_line = self.rules == CodeRules::CommonMark && is_one_line_block(line);
234    }
235
236    /// The backtick run that starts at `start`: its length, and where the
237    /// span it opens closes (the start of the closing run), if it does.
238    /// `start` is a backtick. The run is the backticks from `start` on, which
239    /// under [`CodeRules::SkillExport`] may be the rest of a closing run.
240    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    /// The close, or the end of the block when there is none.
261    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            // A longer run closes a shorter span too.
304            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    /// Whether a run at or after `from` closes a span opened by `length`
317    /// backticks.
318    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
337/// Whether `line` starts a block that a span cannot reach into.
338fn 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
349/// An ATX heading or a line starting with `|`: a block that is one line long.
350fn 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
355/// `#{1,6}` then a space, a tab or the end of the line. `line` starts after
356/// any indent.
357fn 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
362/// An ATX heading, a line starting with `|`, a setext underline, a thematic
363/// break, a block quote or a list item.
364fn 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
383/// Three or more of one of `-`, `*` or `_`, with only spaces or tabs between.
384fn 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
401/// The index of the `\n` ending the line that holds `from`, or the end of
402/// input.
403fn 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
410/// Blank out fenced and inline code so links and tags inside it are not
411/// harvested. [`strip_code_with`] under [`CodeRules::CommonMark`].
412///
413/// # Examples
414///
415/// ```
416/// use markdown_link::strip_code;
417///
418/// let text = "a `[[x]]` b\n```\n[[y]]\n```\n[[z]]";
419/// let blanked = strip_code(text);
420/// assert!(!blanked.contains("[[x]]"));
421/// assert!(!blanked.contains("[[y]]"));
422/// assert!(blanked.contains("[[z]]"));
423/// assert_eq!(blanked.chars().count(), text.chars().count());
424/// ```
425pub fn strip_code(text: &str) -> String {
426    strip_code_with(text, CodeRules::CommonMark)
427}
428
429/// Blank out fenced and inline code under `rules`.
430///
431/// Every character of code, fence marker lines included, becomes a space, and
432/// every `\n` stays. The output has the same number of **characters** as the
433/// input, so a char index into it is a char index into the input. It is not a
434/// byte index: a multi-byte character in code becomes a one-byte space.
435///
436/// # Examples
437///
438/// ```
439/// use markdown_link::code::{CodeRules, strip_code_with};
440///
441/// let blanked = strip_code_with("`é` x", CodeRules::CommonMark);
442/// assert_eq!(blanked, "    x"); // 3 chars of code, now 3 one-byte spaces
443/// ```
444pub 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    /// [`Spans::close`] for a span opened by the one backtick at the start of
496    /// `bytes`.
497    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        // A CHAR count, not a byte count: `é` inside a span becomes one space.
522        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    /// A quadratic pass takes tens of seconds on these inputs in a debug build;
628    /// a linear one takes well under a second, even on a loaded machine. The bound is
629    /// that wide on purpose: it catches the shape of the cost, not a slowdown.
630    #[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}