Skip to main content

markdown_link/
spanned.rs

1//! The body link scanner: every link in a body, with its byte span.
2//!
3//! [`crate::body_links`] is this scanner with the spans dropped. The rules for
4//! a markdown link are delegated ([`crate::is_vault_link_url`],
5//! [`crate::md_link_target`]) rather than restated: two spellings of "is this
6//! a link" is how two scanners start disagreeing about what the vault
7//! contains.
8
9use crate::{is_vault_link_url, md_link_target, parse_inner, strip_code};
10
11/// What syntax produced a [`BodyLink`].
12///
13/// Not the same three as [`crate::LinkKind`]: this scanner reads bodies only,
14/// so there is no `Frontmatter`, and it harvests relative markdown links, which
15/// the bracket dialect has no variant for.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum BodyLinkKind {
18    /// A `[[target]]` link.
19    Wikilink,
20    /// A `![[target]]` embed.
21    Embed,
22    /// A relative markdown link, `[text](path)`.
23    Markdown,
24}
25
26/// One outgoing reference found in a body, with its span, before resolution.
27///
28/// Separate from a resolved link because extraction and resolution have
29/// different lifetimes: a note's links change when the note is edited, and what
30/// they *resolve to* changes when any other note in the vault is created or
31/// renamed. A caller that caches the first and recomputes the second needs
32/// them to be two types.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct BodyLink {
35    /// Which syntax produced the link.
36    pub kind: BodyLinkKind,
37    /// The reference as written between the delimiters, with any code inside it
38    /// blanked to spaces.
39    pub raw: String,
40    /// What resolution runs against. A wikilink's `raw` minus `#heading` /
41    /// `^block` / `|alias` and the `\` escaping the pipe; a markdown link's URL
42    /// percent-decoded and minus a trailing `.md`.
43    pub target: String,
44    /// A wikilink's or embed's `|alias`: the text after the first `|` outside
45    /// code, read from the body so code inside it is kept. `None` when there
46    /// is none or it is empty, and for a markdown link.
47    pub alias: Option<String>,
48    /// Byte offset within the **body** where the whole link syntax starts. An
49    /// embed's leading `!` is inside it.
50    pub byte_start: usize,
51    /// Byte offset within the **body** just past the end of the link syntax.
52    pub byte_end: usize,
53}
54
55/// Every wikilink, embed and relative markdown link in a body, in encounter
56/// order, **with byte spans**.
57///
58/// Code is blanked before matching, `[[` never nests, and a markdown link
59/// counts only when `is_vault_link_url` accepts its URL and it carries no
60/// scheme. The span is what an editor decoration, a hover preview and a
61/// rename's link rewrite need: which bytes to touch.
62///
63/// # Why the offsets need a char table
64///
65/// [`strip_code`] preserves the **character** count of its input, not the byte
66/// count — a multi-byte char inside a code span comes back as a one-byte space.
67/// So a position found in the stripped text is a valid char index into the
68/// original and never a valid byte index, and the two tables below are what
69/// convert. Scanning the original directly is not an option: that is how
70/// `[[example]]` inside a fenced block becomes a link.
71///
72/// # Examples
73///
74/// ```
75/// use markdown_link::{BodyLinkKind, body_links_spanned};
76///
77/// let body = "é ![[img.png|300]] and [[Note#h|alias]]";
78/// let links = body_links_spanned(body);
79/// assert_eq!(links.len(), 2);
80/// assert_eq!(links[0].kind, BodyLinkKind::Embed);
81/// assert_eq!(links[0].target, "img.png");
82/// assert_eq!(&body[links[0].byte_start..links[0].byte_end], "![[img.png|300]]");
83/// assert_eq!(links[1].alias.as_deref(), Some("alias"));
84/// assert_eq!(&body[links[1].byte_start..links[1].byte_end], "[[Note#h|alias]]");
85/// ```
86pub fn body_links_spanned(body: &str) -> Vec<BodyLink> {
87    let stripped = strip_code(body);
88    let chars: Vec<char> = stripped.chars().collect();
89    // char index -> byte offset, in the ORIGINAL body, plus an end sentinel so
90    // an exclusive range that runs to the end of the body is representable.
91    let mut at: Vec<usize> = body.char_indices().map(|(i, _)| i).collect();
92    at.push(body.len());
93    if chars.len() + 1 != at.len() {
94        // `strip_code` must preserve the char count. If it ever does not,
95        // reporting no links beats reporting spans that index the wrong bytes
96        // and would splice mid-character.
97        return Vec::new();
98    }
99
100    let mut out = Vec::new();
101    let mut scan = UrlScan::default();
102    let mut i = 0usize;
103    while i < chars.len() {
104        if chars[i] == '[' && chars.get(i + 1) == Some(&'[') {
105            let embed = i > 0 && chars[i - 1] == '!';
106            let mut j = i + 2;
107            let mut inner = String::new();
108            let mut closed = false;
109            while j + 1 < chars.len() {
110                if chars[j] == ']' && chars[j + 1] == ']' {
111                    closed = true;
112                    break;
113                }
114                // `[[` never nests, and `[`/`]` inside would be a malformed
115                // link — bail rather than swallow the rest of the note.
116                if chars[j] == '[' || chars[j] == ']' {
117                    break;
118                }
119                inner.push(chars[j]);
120                j += 1;
121            }
122            if closed && !inner.is_empty() {
123                if let Some(link) = parse_inner(&inner, embed) {
124                    // Split at the pipe `parse_inner` used, found in the
125                    // stripped chars so a `|` in code is not one, then read
126                    // the alias from the body so code inside it is kept.
127                    let alias = chars[i + 2..j]
128                        .iter()
129                        .position(|&c| c == '|')
130                        .map(|p| body[at[i + 3 + p]..at[j]].trim())
131                        .filter(|alias| !alias.is_empty())
132                        .map(str::to_string);
133                    out.push(BodyLink {
134                        kind: if embed {
135                            BodyLinkKind::Embed
136                        } else {
137                            BodyLinkKind::Wikilink
138                        },
139                        raw: inner,
140                        target: link.target,
141                        alias,
142                        // An embed's `!` is part of the syntax a rename has to
143                        // rewrite, so it is inside the span.
144                        byte_start: at[if embed { i - 1 } else { i }],
145                        byte_end: at[j + 2],
146                    });
147                }
148                i = j + 2;
149                continue;
150            }
151        }
152        if chars[i] == '['
153            && let Some(link) = spanned_markdown_link(&chars, i, &at, &mut scan)
154        {
155            i = link.1;
156            out.push(link.0);
157            continue;
158        }
159        i += 1;
160    }
161    out
162}
163
164/// `[text](relative/note.md)` starting at `start`, and the char index just
165/// past it. `None` when this is not one.
166///
167/// The shape is the regex `\[[^\]]*\]\(<?([^)>\s]+?)>?\)`, walked by hand so
168/// positions index the original body. [`is_vault_link_url`] decides "is this a
169/// reference" and [`md_link_target`] "what does it point at". A URL containing
170/// whitespace is not a link: `[c](<sp ace.md>)` matches nothing. The URL is
171/// read in place and built only for a link, so a body of failed attempts
172/// stays linear.
173fn spanned_markdown_link(
174    chars: &[char],
175    start: usize,
176    at: &[usize],
177    scan: &mut UrlScan,
178) -> Option<(BodyLink, usize)> {
179    let mut j = start + 1;
180    while j < chars.len() && chars[j] != ']' && chars[j] != '[' {
181        j += 1;
182    }
183    if chars.get(j) != Some(&']') || chars.get(j + 1) != Some(&'(') {
184        return None;
185    }
186    let angled = chars.get(j + 2) == Some(&'<');
187    let url_start = if angled { j + 3 } else { j + 2 };
188    let url_end = scan.url_end(chars, url_start);
189    let mut end = url_end;
190    if angled {
191        if chars.get(end) != Some(&'>') {
192            return None;
193        }
194        end += 1;
195    }
196    if chars.get(end) != Some(&')') {
197        return None;
198    }
199    end += 1;
200    if !scan.is_vault_link(chars, url_start, url_end) || has_scheme(&chars[url_start..url_end]) {
201        // Not a link this dialect harvests. The caller advances ONE character
202        // rather than skipping past the `[text](…)` it just walked, so a match
203        // may start at an inner `[`: `[a[b](x.md)` is a link.
204        return None;
205    }
206    let url: String = chars[url_start..url_end].iter().collect();
207    Some((
208        BodyLink {
209            kind: BodyLinkKind::Markdown,
210            target: md_link_target(&url),
211            alias: None,
212            raw: url,
213            byte_start: at[start],
214            byte_end: at[end],
215        },
216        end,
217    ))
218}
219
220/// What earlier markdown link attempts learned about the URLs they read. Each
221/// attempt starts later than the one before, so remembering the last answer
222/// of each kind keeps a body of failed attempts linear.
223#[derive(Debug, Default)]
224struct UrlScan {
225    /// `(from, stop)`: the first `)`, `>` or whitespace at or after `from` is
226    /// at `stop`.
227    stop: Option<(usize, usize)>,
228    tail: Option<UrlTail>,
229}
230
231/// URLs ending at `end`: the last `.` or `/` at or after `from`, and
232/// [`is_vault_link_url`] of each tail asked so far, by its start.
233#[derive(Debug)]
234struct UrlTail {
235    end: usize,
236    from: usize,
237    mark: Option<usize>,
238    answers: Vec<(usize, bool)>,
239}
240
241impl UrlScan {
242    /// Where a URL starting at `from` ends: the first `)`, `>` or whitespace.
243    fn url_end(&mut self, chars: &[char], from: usize) -> usize {
244        if let Some((start, stop)) = self.stop
245            && (start..=stop).contains(&from)
246        {
247            return stop;
248        }
249        let mut k = from;
250        while k < chars.len() && !matches!(chars[k], ')' | '>') && !chars[k].is_whitespace() {
251            k += 1;
252        }
253        self.stop = Some((from, k));
254        k
255    }
256
257    /// [`is_vault_link_url`] of `chars[from..end]`. That answer depends only
258    /// on whether the URL ends in `.md` after at least one character and on
259    /// the extension of its last segment. So the URL from the character before
260    /// its last `.` gives the same answer, and that tail is shared by every
261    /// attempt ending at `end`.
262    fn is_vault_link(&mut self, chars: &[char], from: usize, end: usize) -> bool {
263        let tail = match &mut self.tail {
264            Some(tail) if tail.end == end && tail.from <= from => tail,
265            slot => slot.insert(UrlTail {
266                end,
267                from,
268                mark: chars[from..end]
269                    .iter()
270                    .rposition(|c| matches!(c, '.' | '/'))
271                    .map(|i| from + i),
272                answers: Vec::new(),
273            }),
274        };
275        let Some(mark) = tail.mark.filter(|&mark| mark >= from && chars[mark] == '.') else {
276            return false;
277        };
278        let tail_start = from.max(mark.saturating_sub(1));
279        if let Some(&(_, answer)) = tail.answers.iter().find(|(at, _)| *at == tail_start) {
280            return answer;
281        }
282        let answer = is_vault_link_url(&chars[tail_start..end].iter().collect::<String>());
283        tail.answers.push((tail_start, answer));
284        answer
285    }
286}
287
288/// `http:`, `mailto:`, an app-specific scheme — anything with a URI scheme is external.
289fn has_scheme(url: &[char]) -> bool {
290    let mut chars = url.iter();
291    match chars.next() {
292        Some(c) if c.is_ascii_alphabetic() => {}
293        _ => return false,
294    }
295    for &c in chars {
296        if c == ':' {
297            return true;
298        }
299        if !(c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') {
300            return false;
301        }
302    }
303    false
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    /// A quadratic URL scan takes tens of seconds on the largest of these in a
311    /// debug build; a linear one takes well under a second, even on a loaded
312    /// machine. The bound is that wide on purpose: it catches the shape of the
313    /// cost, not a slowdown.
314    #[test]
315    fn the_url_scan_is_linear_in_failed_link_attempts() {
316        for n in [25_000, 50_000, 100_000] {
317            for body in [
318                "[](a".repeat(n),
319                format!("{})", "[](a".repeat(n)),
320                format!("{}.md)", "[](a:".repeat(n)),
321                format!("{}.x)", "[](a/".repeat(n)),
322            ] {
323                let started = std::time::Instant::now();
324                assert!(body_links_spanned(&body).is_empty());
325                let elapsed = started.elapsed();
326                assert!(
327                    elapsed < std::time::Duration::from_secs(10),
328                    "{} KB took {elapsed:?}",
329                    body.len() / 1000
330                );
331            }
332        }
333    }
334}