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