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}