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}