markdown_link/lib.rs
1//! Parsing and resolution of Markdown and wiki-style links.
2//!
3//! Finds `[[wikilinks]]`, `![[embeds]]` and relative markdown links in a note
4//! body or its frontmatter, and resolves a link target to a file path with
5//! [`LinkIndex`], using a path-suffix index.
6//!
7//! ```
8//! use markdown_link::{LinkIndex, body_links};
9//!
10//! let links = body_links("See [[Target#Heading|alias]].");
11//! assert_eq!(links[0].target, "Target");
12//!
13//! let index = LinkIndex::build(&["notes/Target.md".to_string()]);
14//! assert_eq!(index.resolve("Target"), Some("notes/Target.md"));
15//! ```
16//!
17//! Two details that look arbitrary and are not:
18//!
19//! - **Code is stripped before matching.** A fenced block full of
20//! `[[example]]` would otherwise manufacture links that do not exist, and
21//! the link graph would grow edges nobody wrote.
22//! - **A pathed miss falls back to its basename.** Notes often write typed
23//! links like `[[muscle-groups/glutes]]` whose prefix is a *collection
24//! alias*, not a real folder. Dropping the prefix on a miss is what makes
25//! those resolve; without it the graph is full of ghost nodes for links
26//! that a human would call obviously correct.
27
28#![forbid(unsafe_code)]
29#![warn(missing_docs)]
30
31use std::collections::HashMap;
32use std::ops::Range;
33
34pub mod code;
35mod spanned;
36pub use code::strip_code;
37pub use spanned::*;
38
39/// What kind of reference produced a [`RawLink`].
40///
41/// [`BodyLinkKind`] is the span-carrying scanner's parallel enum: no
42/// `Frontmatter`, and a `Markdown` variant this one folds into `Wikilink`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LinkKind {
45 /// A `[[target]]` link, or a relative markdown link.
46 Wikilink,
47 /// A `![[target]]` embed.
48 Embed,
49 /// Found inside a frontmatter value rather than the body.
50 Frontmatter,
51}
52
53/// One reference found in a note, before it is resolved.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct RawLink {
56 /// What resolution runs against. A wikilink's text minus `#heading`,
57 /// `|alias` and the `\` escaping the pipe; a markdown link's URL
58 /// percent-decoded and minus a trailing `.md`.
59 pub target: String,
60 /// Which syntax produced the reference.
61 pub kind: LinkKind,
62 /// The frontmatter key a `Frontmatter` link came from.
63 pub field: Option<String>,
64}
65
66/// A parsed `[[target#heading|alias]]`.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct WikiLink {
69 /// The link target, trimmed, minus any `#heading` and `|alias`.
70 pub target: String,
71 /// The text after the first `#` in the target part, trimmed.
72 pub heading: Option<String>,
73 /// The text after the first `|`, trimmed.
74 pub alias: Option<String>,
75 /// Whether the link was written as an embed, `![[...]]`.
76 pub embed: bool,
77}
78
79/// Parse a single wikilink body (the text between the brackets).
80///
81/// A `\` right before the first `|` is the escape a GFM table cell needs
82/// (`[[Note\|alias]]`) and is dropped from the target or heading.
83///
84/// # Examples
85///
86/// ```
87/// use markdown_link::{WikiLink, parse_inner};
88///
89/// let link = parse_inner("Note#Intro|Read me", false).unwrap();
90/// assert_eq!(
91/// link,
92/// WikiLink {
93/// target: "Note".into(),
94/// heading: Some("Intro".into()),
95/// alias: Some("Read me".into()),
96/// embed: false,
97/// }
98/// );
99///
100/// // The `\` that escapes the pipe in a table cell is dropped.
101/// assert_eq!(parse_inner("Note\\|alias", false).unwrap().target, "Note");
102/// // A link with no target is not a link.
103/// assert!(parse_inner(" #only-heading", false).is_none());
104/// ```
105pub fn parse_inner(inner: &str, embed: bool) -> Option<WikiLink> {
106 let target = target_range(inner)?;
107 let (before_alias, alias) = split_alias(inner);
108 Some(WikiLink {
109 target: inner[target].to_string(),
110 heading: before_alias
111 .split_once('#')
112 .map(|(_, h)| h.trim().to_string()),
113 alias: alias.map(|a| a.trim().to_string()),
114 embed,
115 })
116}
117
118/// Byte range of [`parse_inner`]'s target within `inner`, for a caller that
119/// rewrites the target in place and must leave the rest of the link as written.
120///
121/// # Examples
122///
123/// ```
124/// use markdown_link::target_range;
125///
126/// let inner = " Note #h|alias";
127/// let range = target_range(inner).unwrap();
128/// assert_eq!(&inner[range], "Note");
129/// assert_eq!(target_range("#h"), None);
130/// ```
131pub fn target_range(inner: &str) -> Option<Range<usize>> {
132 let (before_alias, _) = split_alias(inner);
133 let raw = before_alias
134 .split_once('#')
135 .map_or(before_alias, |(t, _)| t);
136 let target = raw.trim();
137 if target.is_empty() {
138 return None;
139 }
140 let lead = raw.len() - raw.trim_start().len();
141 Some(lead..lead + target.len())
142}
143
144/// `inner` split at the first `|`, minus the `\` that escapes it.
145fn split_alias(inner: &str) -> (&str, Option<&str>) {
146 match inner.split_once('|') {
147 Some((t, a)) => (t.strip_suffix('\\').unwrap_or(t), Some(a)),
148 None => (inner, None),
149 }
150}
151
152/// Every reference in one piece of markdown, in the order it appears, with
153/// any `#heading` and `|alias` stripped.
154///
155/// Built on [`body_links`] rather than a bracket scan of its own: it blanks
156/// code first, so a link shown in an example is not harvested, it reads embeds
157/// and relative markdown links too, and it drops a malformed `[[` instead of
158/// swallowing the rest of the note.
159///
160/// Callers that only need the targets, such as a build script or a link
161/// checker, share one scanner with [`body_links`], so they cannot disagree
162/// about what a note links to.
163///
164/// # Examples
165///
166/// ```
167/// use markdown_link::wikilink_targets;
168///
169/// let body = "See [[A#h|alias]], ![[img.png]] and [b](c.md). `[[in code]]` is skipped.";
170/// assert_eq!(wikilink_targets(body), ["A", "img.png", "c"]);
171/// ```
172pub fn wikilink_targets(body: &str) -> Vec<String> {
173 body_links(body)
174 .into_iter()
175 .map(|link| link.target)
176 .collect()
177}
178
179/// Every wikilink, embed and relative markdown link in a note body, in the
180/// order they appear.
181///
182/// [`body_links_spanned`] without the spans: one scanner finds links, so the
183/// two cannot disagree. A markdown link is reported as [`LinkKind::Wikilink`].
184///
185/// # Examples
186///
187/// ```
188/// use markdown_link::{LinkKind, body_links};
189///
190/// let links = body_links("[[A]] ![[b.png]] [c](d.md)");
191/// assert_eq!(links.len(), 3);
192/// assert_eq!((links[0].target.as_str(), links[0].kind), ("A", LinkKind::Wikilink));
193/// assert_eq!((links[1].target.as_str(), links[1].kind), ("b.png", LinkKind::Embed));
194/// assert_eq!((links[2].target.as_str(), links[2].kind), ("d", LinkKind::Wikilink));
195/// assert_eq!(links[0].field, None);
196/// ```
197pub fn body_links(body: &str) -> Vec<RawLink> {
198 body_links_spanned(body)
199 .into_iter()
200 .map(|link| RawLink {
201 target: link.target,
202 kind: match link.kind {
203 BodyLinkKind::Embed => LinkKind::Embed,
204 BodyLinkKind::Wikilink | BodyLinkKind::Markdown => LinkKind::Wikilink,
205 },
206 field: None,
207 })
208 .collect()
209}
210
211/// Every non-markdown extension that counts as a vault ATTACHMENT (lowercase,
212/// no leading dot).
213///
214/// This answers "is this file an attachment", not "can it be displayed" —
215/// `tiff` and `heic` belong here and in a search index, but no browser
216/// reliably paints them. Treating any relative markdown link as a
217/// reference would be broader still, but a target no tool can do anything with
218/// earns no place in the link graph, and the allowlist is what keeps
219/// `[see](README)` from becoming an edge.
220///
221/// Matched case-insensitively, unlike the `.md` rule below.
222///
223/// # Examples
224///
225/// ```
226/// use markdown_link::ATTACHMENT_EXTENSIONS;
227///
228/// assert!(ATTACHMENT_EXTENSIONS.contains(&"png"));
229/// assert!(ATTACHMENT_EXTENSIONS.contains(&"pdf"));
230/// assert!(!ATTACHMENT_EXTENSIONS.contains(&"md"));
231/// ```
232// Must match the TypeScript list. packages/ts/markdown-link/tests/attachment-extensions.test.ts
233// fails if this differs from it. Update both together.
234pub const ATTACHMENT_EXTENSIONS: &[&str] = &[
235 "png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "bmp", "ico", "tiff", "heic", "mp4",
236 "webm", "ogv", "mov", "mkv", "mp3", "wav", "m4a", "3gp", "flac", "ogg", "oga", "opus", "pdf",
237 "base", "canvas",
238];
239
240/// Whether a markdown link's URL is one this dialect harvests: a note, or a
241/// vault attachment.
242///
243/// The `.md` half carries two quirks: the extension is **case-sensitive**
244/// (`[a](x.MD)` is not a link here, however much it looks like one) and at
245/// least one character must precede it (`[a](.md)` is not either). Widening
246/// that is a separate decision from adding attachments, and it is not taken
247/// here.
248///
249/// Public for the same reason [`strip_md_ext`] is: a second spelling of "is
250/// this a vault reference" is how two scanners start disagreeing about what
251/// the vault contains.
252///
253/// # Examples
254///
255/// ```
256/// use markdown_link::is_vault_link_url;
257///
258/// assert!(is_vault_link_url("notes/a.md"));
259/// assert!(is_vault_link_url("img/photo.PNG"));
260/// assert!(!is_vault_link_url("a.MD")); // `.md` is case-sensitive
261/// assert!(!is_vault_link_url(".md")); // needs a stem
262/// assert!(!is_vault_link_url("README")); // no extension
263/// ```
264pub fn is_vault_link_url(url: &str) -> bool {
265 if url.len() > 3 && url.ends_with(".md") {
266 return true;
267 }
268 let last = url.rsplit('/').next().unwrap_or(url);
269 match last.rfind('.') {
270 // A leading dot is a hidden file, not an extension, and a trailing dot
271 // is no extension at all.
272 Some(0) | None => false,
273 Some(at) => {
274 let ext = last[at + 1..].to_lowercase();
275 ATTACHMENT_EXTENSIONS.contains(&ext.as_str())
276 }
277 }
278}
279
280/// The resolution target of a markdown link's URL: percent-decoded, minus its
281/// trailing `.md`.
282///
283/// Public alongside [`is_vault_link_url`], and pinned rather than tidied. The
284/// `trim_end_matches` pair strips **repeatedly**, so `a.md.md` targets `a`
285/// rather than `a.md`. That is the long-standing behaviour, so indexes built
286/// with it stay valid. [`strip_md_ext`] is the single-strip, case-insensitive
287/// fallback for the mixed-case spellings the pair does not catch.
288///
289/// # Examples
290///
291/// ```
292/// use markdown_link::md_link_target;
293///
294/// assert_eq!(md_link_target("My%20Note.md"), "My Note");
295/// assert_eq!(md_link_target("a.md.md"), "a");
296/// assert_eq!(md_link_target("a.Md"), "a");
297/// assert_eq!(md_link_target("img.png"), "img.png");
298/// ```
299pub fn md_link_target(url: &str) -> String {
300 let decoded = percent_decode(url);
301 let trimmed = decoded.trim_end_matches(".md").trim_end_matches(".MD");
302 if trimmed.len() == decoded.len() {
303 strip_md_ext(&decoded)
304 } else {
305 trimmed.to_string()
306 }
307}
308
309/// Drop a trailing `.md`, case-insensitively.
310///
311/// Byte-length arithmetic on a `&str` needs a char-boundary check first: note
312/// names can hold Cyrillic or CJK text, and `&s[s.len() - 3..]` panics
313/// rather than mismatching when the last three bytes land mid-character.
314///
315/// Public because a caller that parses links itself has to normalise a markdown
316/// link's URL the same way [`body_links`] does, and a second copy of this is
317/// exactly how two scanners start disagreeing about what a target is.
318///
319/// # Examples
320///
321/// ```
322/// use markdown_link::strip_md_ext;
323///
324/// assert_eq!(strip_md_ext("Note.MD"), "Note");
325/// assert_eq!(strip_md_ext("Note"), "Note");
326/// assert_eq!(strip_md_ext("a.md.md"), "a.md"); // one strip only
327/// assert_eq!(strip_md_ext("заметка"), "заметка"); // multi-byte, no panic
328/// ```
329pub fn strip_md_ext(s: &str) -> String {
330 if s.len() >= 3
331 && s.is_char_boundary(s.len() - 3)
332 && s[s.len() - 3..].eq_ignore_ascii_case(".md")
333 {
334 s[..s.len() - 3].to_string()
335 } else {
336 s.to_string()
337 }
338}
339
340/// Wikilinks hiding in frontmatter values, tagged with their top-level key.
341///
342/// # Examples
343///
344/// ```
345/// use markdown_link::{LinkKind, frontmatter_links};
346///
347/// let fm = yaml_splice::parse::parse_mapping("rel: '[[Other]]'\nlist:\n - '[[Two]]'\n").unwrap();
348/// let links = frontmatter_links(&fm);
349/// assert_eq!(links.len(), 2);
350/// assert_eq!(links[0].target, "Other");
351/// assert_eq!(links[0].kind, LinkKind::Frontmatter);
352/// assert_eq!(links[0].field.as_deref(), Some("rel"));
353/// assert_eq!(links[1].field.as_deref(), Some("list"));
354/// ```
355pub fn frontmatter_links(fm: &yaml_splice::value::Mapping) -> Vec<RawLink> {
356 use yaml_splice::value::Value;
357 fn visit(v: &Value, field: &str, out: &mut Vec<RawLink>) {
358 match v {
359 Value::Str(s) => {
360 for l in body_links(s) {
361 if matches!(l.kind, LinkKind::Wikilink | LinkKind::Embed) {
362 out.push(RawLink {
363 target: l.target,
364 kind: LinkKind::Frontmatter,
365 field: Some(field.to_string()),
366 });
367 }
368 }
369 }
370 Value::Seq(items) => items.iter().for_each(|i| visit(i, field, out)),
371 Value::Map(m) => m.iter().for_each(|(_, i)| visit(i, field, out)),
372 _ => {}
373 }
374 }
375 let mut out = Vec::new();
376 for (key, value) in fm.iter() {
377 visit(value, key, &mut out);
378 }
379 out
380}
381
382/// Frontmatter string values that name a file by path (`cover: img/a.png`),
383/// tagged with their top-level key. A value counts when its last segment has
384/// an extension; a URL, a wikilink or text with a line break does not.
385/// Whether the path exists is the resolver's question.
386///
387/// # Examples
388///
389/// ```
390/// use markdown_link::frontmatter_paths;
391///
392/// let fm = yaml_splice::parse::parse_mapping(
393/// "cover: img/a.png\nsite: https://x.org/a.png\nname: plain\n",
394/// )
395/// .unwrap();
396/// let paths = frontmatter_paths(&fm);
397/// assert_eq!(paths.len(), 1);
398/// assert_eq!(paths[0].target, "img/a.png");
399/// assert_eq!(paths[0].field.as_deref(), Some("cover"));
400/// ```
401pub fn frontmatter_paths(fm: &yaml_splice::value::Mapping) -> Vec<RawLink> {
402 use yaml_splice::value::Value;
403 fn visit(v: &Value, field: &str, out: &mut Vec<RawLink>) {
404 match v {
405 Value::Str(s) => {
406 if let Some(path) = as_path(s) {
407 out.push(RawLink {
408 target: path.to_string(),
409 kind: LinkKind::Frontmatter,
410 field: Some(field.to_string()),
411 });
412 }
413 }
414 Value::Seq(items) => items.iter().for_each(|i| visit(i, field, out)),
415 Value::Map(m) => m.iter().for_each(|(_, i)| visit(i, field, out)),
416 _ => {}
417 }
418 }
419 let mut out = Vec::new();
420 for (key, value) in fm.iter() {
421 visit(value, key, &mut out);
422 }
423 out
424}
425
426/// `s` trimmed, when it reads as a file path: see [`frontmatter_paths`].
427fn as_path(s: &str) -> Option<&str> {
428 let s = s.trim();
429 if s.is_empty() || s.contains("[[") || s.contains("://") || s.contains('\n') {
430 return None;
431 }
432 has_file_extension(s).then_some(s)
433}
434
435/// Whether the last `/` segment of `s` ends in `.ext`: one to eight ASCII
436/// letters or digits, at least one a letter, after a non-empty stem.
437fn has_file_extension(s: &str) -> bool {
438 let name = s.rsplit('/').next().unwrap_or(s);
439 let Some((stem, ext)) = name.rsplit_once('.') else {
440 return false;
441 };
442 !stem.is_empty()
443 && (1..=8).contains(&ext.len())
444 && ext.bytes().all(|b| b.is_ascii_alphanumeric())
445 && ext.bytes().any(|b| b.is_ascii_alphabetic())
446}
447
448/// A cheap test of raw frontmatter text: whether any token on any line could
449/// be a path [`frontmatter_paths`] keeps. False means it would find none, so
450/// a caller can skip the YAML parse. It errs toward true: a false positive
451/// costs a parse, a false negative a missed link.
452///
453/// # Examples
454///
455/// ```
456/// use markdown_link::may_hold_frontmatter_paths;
457///
458/// assert!(may_hold_frontmatter_paths("cover: img/a.png"));
459/// assert!(!may_hold_frontmatter_paths("title: Hello world"));
460/// ```
461pub fn may_hold_frontmatter_paths(yaml: &str) -> bool {
462 yaml.split([
463 '\n', '\r', ' ', '\t', ':', '[', ']', '{', '}', ',', '"', '\'', '#',
464 ])
465 .any(has_file_extension)
466}
467
468/// Replace each wikilink and embed with what a reader sees for it, for
469/// snippets and search: its alias, or else its target. An embed's trailing
470/// `|300` or `|300x200` is a size, not an alias, so
471/// `![[img.png|300]]` shows `img.png`. An empty alias shows the target.
472/// Code is not skipped.
473///
474/// # Examples
475///
476/// ```
477/// use markdown_link::display_text;
478///
479/// assert_eq!(display_text("See [[Note#h|the note]]."), "See the note.");
480/// assert_eq!(display_text("[[Note]] and ![[img.png|300]]"), "Note and img.png");
481/// assert_eq!(display_text("[[Note|]]"), "Note"); // empty alias shows the target
482/// ```
483pub fn display_text(text: &str) -> String {
484 let mut out = String::with_capacity(text.len());
485 let chars: Vec<char> = text.chars().collect();
486 let mut i = 0usize;
487 while i < chars.len() {
488 if chars[i] == '!'
489 && chars.get(i + 1) == Some(&'[')
490 && chars.get(i + 2) == Some(&'[')
491 && let Some((inner, next)) = take_link(&chars, i + 1)
492 {
493 if let Some(l) = parse_inner(&inner, true) {
494 out.push_str(shown(&l));
495 }
496 i = next;
497 continue;
498 }
499 if chars[i] == '['
500 && chars.get(i + 1) == Some(&'[')
501 && let Some((inner, next)) = take_link(&chars, i)
502 {
503 if let Some(l) = parse_inner(&inner, false) {
504 out.push_str(shown(&l));
505 }
506 i = next;
507 continue;
508 }
509 out.push(chars[i]);
510 i += 1;
511 }
512 out
513}
514
515/// What a reader sees for one link. See [`display_text`].
516fn shown(link: &WikiLink) -> &str {
517 let alias = link.alias.as_deref().map(|alias| {
518 if !link.embed {
519 return alias;
520 }
521 let (rest, last) = alias.rsplit_once('|').unwrap_or(("", alias));
522 if is_size(last.trim()) {
523 rest.trim()
524 } else {
525 alias
526 }
527 });
528 alias.filter(|a| !a.is_empty()).unwrap_or(&link.target)
529}
530
531/// `300` or `300x200`: an embed's width, or width and height.
532fn is_size(s: &str) -> bool {
533 let digits = |p: &str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit());
534 match s.split_once('x') {
535 Some((w, h)) => digits(w) && digits(h),
536 None => digits(s),
537 }
538}
539
540/// A typed reference, `[[type/slug]]` or `[[type/slug|Alias]]`: a
541/// frontmatter value naming one note of a known type, such as
542/// `location: "[[location/home]]"`.
543///
544/// # Examples
545///
546/// ```
547/// use markdown_link::parse_typed_ref;
548///
549/// let typed = parse_typed_ref("[[location/home|Home]]").unwrap();
550/// assert_eq!(typed.kind, "location");
551/// assert_eq!(typed.slug, "home");
552/// assert_eq!(typed.alias.as_deref(), Some("Home"));
553/// ```
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct TypedRef {
556 /// The text before the first `/`, such as `location`.
557 pub kind: String,
558 /// The rest of the target, such as `home` or `2026/spring`.
559 pub slug: String,
560 /// The text after the first `|`, as written.
561 pub alias: Option<String>,
562}
563
564/// Parse a whole value as a typed reference, or `None`.
565///
566/// Shape only: the target is not resolved. The value is exactly `[[...]]`
567/// with no `[`, `]` or line break inside. The target ends at the first `|`,
568/// minus a `\` that escapes it, as in [`parse_inner`]. Its type and each
569/// `/`-separated slug segment are non-empty and hold no whitespace.
570///
571/// # Examples
572///
573/// ```
574/// use markdown_link::parse_typed_ref;
575///
576/// assert_eq!(parse_typed_ref("[[a/b/c]]").unwrap().slug, "b/c");
577/// assert!(parse_typed_ref("[[no-slash]]").is_none());
578/// assert!(parse_typed_ref("[[a/b]] trailing").is_none());
579/// assert!(parse_typed_ref("[[a/has space]]").is_none());
580/// ```
581pub fn parse_typed_ref(value: &str) -> Option<TypedRef> {
582 let inner = value.strip_prefix("[[")?.strip_suffix("]]")?;
583 if inner.contains(['[', ']', '\n', '\r']) {
584 return None;
585 }
586 let (target, alias) = split_alias(inner);
587 let (kind, slug) = target.split_once('/')?;
588 let word = |s: &str| !s.is_empty() && !s.chars().any(char::is_whitespace);
589 if !word(kind) || !slug.split('/').all(word) {
590 return None;
591 }
592 Some(TypedRef {
593 kind: kind.to_string(),
594 slug: slug.to_string(),
595 alias: alias.map(str::to_string),
596 })
597}
598
599fn take_link(chars: &[char], start: usize) -> Option<(String, usize)> {
600 let mut j = start + 2;
601 let mut inner = String::new();
602 while j + 1 < chars.len() {
603 if chars[j] == ']' && chars[j + 1] == ']' {
604 return Some((inner, j + 2));
605 }
606 if chars[j] == '[' || chars[j] == ']' {
607 return None;
608 }
609 inner.push(chars[j]);
610 j += 1;
611 }
612 None
613}
614
615/// Percent-decode a markdown link URL. Public for the same reason
616/// [`strip_md_ext`] is.
617///
618/// # Examples
619///
620/// ```
621/// use markdown_link::percent_decode;
622///
623/// assert_eq!(percent_decode("My%20Note"), "My Note");
624/// assert_eq!(percent_decode("%zz"), "%zz"); // not hex, kept as written
625/// assert_eq!(percent_decode("%FF"), "%FF"); // not UTF-8, kept as written
626/// ```
627pub fn percent_decode(s: &str) -> String {
628 let b = s.as_bytes();
629 let mut out: Vec<u8> = Vec::with_capacity(b.len());
630 let mut i = 0;
631 while i < b.len() {
632 if b[i] == b'%' && i + 2 < b.len() {
633 let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok();
634 if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
635 out.push(v);
636 i += 3;
637 continue;
638 }
639 }
640 out.push(b[i]);
641 i += 1;
642 }
643 // Undecodable bytes keep the raw form rather than becoming U+FFFD, which
644 // would turn a resolvable link into a permanent ghost node.
645 String::from_utf8(out).unwrap_or_else(|_| s.to_string())
646}
647
648/// A suffix index: every file is reachable by any suffix of its path.
649///
650/// # Examples
651///
652/// ```
653/// use markdown_link::LinkIndex;
654///
655/// let index = LinkIndex::build(&["notes/a.md".to_string(), "deep/er/b.md".to_string()]);
656/// assert_eq!(index.resolve("a"), Some("notes/a.md"));
657/// assert_eq!(index.resolve("er/b"), Some("deep/er/b.md"));
658/// assert_eq!(index.resolve("missing"), None);
659/// ```
660#[derive(Debug, Clone, Default)]
661pub struct LinkIndex {
662 by_key: HashMap<String, String>,
663 /// Candidate lists, kept ONLY for the keys more than one file answers to.
664 ///
665 /// Nearly every key has exactly one candidate, and paying a `Vec` for every
666 /// suffix of every path in a large vault is not free. `by_key` holds the
667 /// source-independent winner, which is all [`LinkIndex::resolve`] needs;
668 /// this holds the whole field, and only a resolution that knows where the
669 /// link was written ever looks at it.
670 ///
671 /// Shortest-first, like the build order that fills it.
672 ambiguous: HashMap<String, Vec<String>>,
673 /// How many times a path claimed a key that a different path already
674 /// held while the index was built.
675 pub collisions: usize,
676}
677
678/// Pick the winner from a shortest-first candidate field, as written in `from`.
679///
680/// **The one place the ambiguity rule lives.** [`LinkIndex`] keeps a single
681/// winner per key and reaches for this only when a name is contested. A tool
682/// that keeps whole candidate fields of its own, such as a rename planner that
683/// must answer "is this bare name still unambiguous after the move", can call
684/// it too. Two shapes of storage, and exactly one rule deciding which file a
685/// contested name means — because a reader seeing one file while a rename
686/// rewrites another is the failure that costs.
687///
688/// `field` must be ordered shortest-first, which is what makes the first
689/// in-folder hit also the shortest one and lets both rules fall out of a single
690/// pass.
691///
692/// # Examples
693///
694/// ```
695/// use markdown_link::prefer_in_folder;
696///
697/// let field = vec!["b/a.md".to_string(), "notes/a.md".to_string()];
698/// // Written in `notes/`: the candidate in the same folder wins.
699/// assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes/a.md"));
700/// // Written at the root: the shortest path wins.
701/// assert_eq!(prefer_in_folder(&field, "x.md"), Some("b/a.md"));
702/// assert_eq!(prefer_in_folder(&[], "x.md"), None);
703/// // `notes/` does not match the sibling folder `notes2/`.
704/// let field = vec!["notes2/a.md".to_string(), "z/notes/a.md".to_string()];
705/// assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes2/a.md"));
706/// ```
707pub fn prefer_in_folder<'a>(field: &'a [String], from: &str) -> Option<&'a str> {
708 let first = field.first()?;
709 if field.len() == 1 {
710 return Some(first);
711 }
712 let folder = source_folder(from);
713 // Match whole folder names: `notes/` must not match `notes2/`.
714 let prefix = format!("{folder}/");
715 if !folder.is_empty()
716 && let Some(hit) = field.iter().find(|p| p.to_lowercase().starts_with(&prefix))
717 {
718 return Some(hit);
719 }
720 Some(first)
721}
722
723/// `target` joined onto `from`'s folder when it starts with `./` or `../`,
724/// with the dot segments collapsed. `None` for any other target, and for one
725/// that climbs above the vault root.
726///
727/// Public so a caller such as a rename planner can resolve relative links
728/// exactly as [`LinkIndex::resolve_from`] does.
729///
730/// # Examples
731///
732/// ```
733/// use markdown_link::join_relative;
734///
735/// assert_eq!(join_relative("../x", "a/b/c.md").as_deref(), Some("a/x"));
736/// assert_eq!(join_relative("./y", "a/b.md").as_deref(), Some("a/y"));
737/// assert_eq!(join_relative("plain", "a/b.md"), None); // not relative
738/// assert_eq!(join_relative("../../x", "a/b.md"), None); // above the root
739/// ```
740pub fn join_relative(target: &str, from: &str) -> Option<String> {
741 let t = target.trim().replace('\\', "/");
742 if !(t.starts_with("./") || t.starts_with("../")) {
743 return None;
744 }
745 let from = from.trim().replace('\\', "/");
746 let mut segs: Vec<&str> = match from.rfind('/') {
747 Some(at) => from[..at].split('/').filter(|s| !s.is_empty()).collect(),
748 None => Vec::new(),
749 };
750 for seg in t.split('/') {
751 match seg {
752 "" | "." => {}
753 ".." => {
754 segs.pop()?;
755 }
756 _ => segs.push(seg),
757 }
758 }
759 Some(segs.join("/"))
760}
761
762/// The folder a vault-relative path sits in, lowercased, or `""` at the root.
763fn source_folder(from: &str) -> String {
764 let normalized = from.trim().replace('\\', "/");
765 match normalized.rfind('/') {
766 Some(at) => normalized[..at].to_lowercase(),
767 None => String::new(),
768 }
769}
770
771/// Normalise a link target to an index key.
772///
773/// This is the crate's **only** place a `.md` extension is stripped, running
774/// on both path suffixes at build time and queries at resolve time. Stripping
775/// elsewhere strips a second `.md` extension, doubling it.
776///
777/// # Examples
778///
779/// ```
780/// use markdown_link::normalize_target;
781///
782/// assert_eq!(normalize_target("./Notes//A%20B.MD"), "notes/a b");
783/// assert_eq!(normalize_target(" dir\\file "), "dir/file");
784/// ```
785pub fn normalize_target(raw: &str) -> String {
786 let mut t = raw.trim().replace('\\', "/");
787 while let Some(rest) = t.strip_prefix("./") {
788 t = rest.to_string();
789 }
790 while t.contains("//") {
791 t = t.replace("//", "/");
792 }
793 t = percent_decode(&t);
794 strip_md_ext(&t).to_lowercase()
795}
796
797impl LinkIndex {
798 /// Build from vault-relative paths (with extensions).
799 ///
800 /// Shortest path first, measured in CHARACTERS (not depth), then deterministic
801 /// collation. Character length is the observed tie-break:
802 /// `x/y/tie.md` (10 chars, 3 segments) beats `length-versus-depth-long-folder/tie.md`
803 /// (38 chars, 2 segments).
804 ///
805 /// # Examples
806 ///
807 /// ```
808 /// use markdown_link::LinkIndex;
809 ///
810 /// let index = LinkIndex::build(&["x/y/tie.md".to_string(), "long-folder/tie.md".to_string()]);
811 /// // The shorter path wins the bare name.
812 /// assert_eq!(index.resolve("tie"), Some("x/y/tie.md"));
813 /// assert_eq!(index.resolve("long-folder/tie"), Some("long-folder/tie.md"));
814 /// assert!(index.collisions > 0);
815 /// ```
816 pub fn build(paths: &[String]) -> LinkIndex {
817 let mut sorted: Vec<&String> = paths.iter().collect();
818 sorted.sort_by(|a, b| {
819 a.chars()
820 .count()
821 .cmp(&b.chars().count())
822 .then_with(|| slug_collate::compare_paths(a, b))
823 });
824
825 let mut by_key: HashMap<String, String> = HashMap::new();
826 let mut ambiguous: HashMap<String, Vec<String>> = HashMap::new();
827 let mut collisions = 0usize;
828 for path in sorted {
829 // Suffixes are taken from the path as written, extension included.
830 // `normalize_target` is what drops a trailing `.md`, and it does so
831 // on the query too, so stripping here as well would take a second
832 // one off `note.md.md`. It would also have to slice bytes off a
833 // `&str` — the char-boundary hazard `strip_md_ext` guards against.
834 for suffix in suffixes(path) {
835 let key = normalize_target(&suffix);
836 match by_key.get(&key).cloned() {
837 Some(existing) => {
838 if existing != *path {
839 collisions += 1;
840 // The winner leads the field, so index 0 is both the
841 // source-independent answer and the shortest.
842 let field = ambiguous.entry(key).or_insert_with(|| vec![existing]);
843 if !field.iter().any(|p| p == path) {
844 field.push(path.clone());
845 }
846 }
847 }
848 None => {
849 by_key.insert(key, path.clone());
850 }
851 }
852 }
853 }
854 LinkIndex {
855 by_key,
856 ambiguous,
857 collisions,
858 }
859 }
860
861 /// Resolve one target to a vault-relative path, or `None`.
862 ///
863 /// Source-independent, and therefore the same answer everywhere in the
864 /// vault. Equivalent to [`LinkIndex::resolve_from`] called from the vault
865 /// root, where the folder preference cannot apply.
866 ///
867 /// # Examples
868 ///
869 /// ```
870 /// use markdown_link::LinkIndex;
871 ///
872 /// let index = LinkIndex::build(&["notes/glutes.md".to_string()]);
873 /// assert_eq!(index.resolve("glutes"), Some("notes/glutes.md"));
874 /// // A pathed miss falls back to its basename.
875 /// assert_eq!(index.resolve("muscle-groups/glutes"), Some("notes/glutes.md"));
876 /// assert_eq!(index.resolve("nothing"), None);
877 /// ```
878 pub fn resolve(&self, target: &str) -> Option<&str> {
879 self.resolve_from(target, "")
880 }
881
882 /// Resolve one target as it was written in `from`, a vault-relative path.
883 ///
884 /// Differs from [`LinkIndex::resolve`] in two cases, the only ones where
885 /// where the link sits can change the answer. A `./` or `../` target names
886 /// the file at that path from the linking note's folder (see
887 /// [`join_relative`]). An AMBIGUOUS name — several files answer to it —
888 /// takes two observed behaviours, in order: a candidate inside the linking
889 /// note's own folder beats one outside it, and otherwise the shorter full
890 /// path wins.
891 ///
892 /// The folder test is a plain string prefix, not a segment-aligned one,
893 /// which is what the application does: a note in `notes/` also prefers a
894 /// candidate under `notes-archive/`. Small, real, and deliberately
895 /// matched — a reader seeing one file while a rename rewrites a different
896 /// one is worse than either rule alone.
897 ///
898 /// # Examples
899 ///
900 /// ```
901 /// use markdown_link::LinkIndex;
902 ///
903 /// let index = LinkIndex::build(&["b/a.md".to_string(), "notes/a.md".to_string(), "notes/x.md".to_string()]);
904 /// // Ambiguous name: the candidate in the linking note's folder wins.
905 /// assert_eq!(index.resolve_from("a", "notes/x.md"), Some("notes/a.md"));
906 /// assert_eq!(index.resolve_from("a", "other.md"), Some("b/a.md"));
907 /// // A `./` target is relative to the linking note.
908 /// assert_eq!(index.resolve_from("./a", "notes/x.md"), Some("notes/a.md"));
909 /// ```
910 pub fn resolve_from(&self, target: &str, from: &str) -> Option<&str> {
911 // `./x` and `../x` name a file relative to the linking note's folder,
912 // the way the resolver treats them. A miss, or a path that climbs out of the
913 // vault, falls through to the vault-wide lookup below.
914 if let Some(joined) = join_relative(target, from) {
915 let key = normalize_target(&joined);
916 if let Some(field) = self.candidates(&key) {
917 // The field holds every path this key is a SUFFIX of; the
918 // joined path names one exactly.
919 if let Some(hit) = field.iter().find(|p| normalize_target(p) == key) {
920 return Some(hit);
921 }
922 }
923 }
924 let key = normalize_target(target);
925 let field = match self.candidates(&key) {
926 Some(field) => field,
927 None => {
928 // Pathed miss -> basename fallback. See the module docs.
929 let slash = key.rfind('/')?;
930 if slash == 0 {
931 return None;
932 }
933 self.candidates(&key[slash + 1..])?
934 }
935 };
936 prefer_in_folder(field, from)
937 }
938
939 /// Every path a key answers to, shortest first.
940 ///
941 /// One borrowed entry for the overwhelmingly common unique case, and the
942 /// stored field only where more than one file competes.
943 fn candidates(&self, key: &str) -> Option<&[String]> {
944 if let Some(field) = self.ambiguous.get(key) {
945 return Some(field);
946 }
947 self.by_key.get(key).map(std::slice::from_ref)
948 }
949
950 /// The number of distinct keys (normalised path suffixes) in the index.
951 /// This is not the number of files.
952 ///
953 /// # Examples
954 ///
955 /// ```
956 /// use markdown_link::LinkIndex;
957 ///
958 /// let index = LinkIndex::build(&["a/b.md".to_string()]);
959 /// assert_eq!(index.len(), 2); // "b" and "a/b"
960 /// ```
961 pub fn len(&self) -> usize {
962 self.by_key.len()
963 }
964
965 /// Whether the index holds no keys.
966 ///
967 /// # Examples
968 ///
969 /// ```
970 /// use markdown_link::LinkIndex;
971 ///
972 /// assert!(LinkIndex::build(&[]).is_empty());
973 /// assert!(!LinkIndex::build(&["a.md".to_string()]).is_empty());
974 /// ```
975 pub fn is_empty(&self) -> bool {
976 self.by_key.is_empty()
977 }
978}
979
980/// `a/b/c` -> `["c", "b/c", "a/b/c"]`.
981///
982/// Public for the same reason [`strip_md_ext`] and [`percent_decode`] are, and
983/// for one specific kind of caller: one that needs the *candidate set* behind
984/// a link target, which [`LinkIndex`] deliberately does not keep (it stores
985/// one winner per key, which is all resolution needs and all a whole-vault
986/// cache should pay for). A rename planner, for example, can build its own
987/// key → candidates multimap to answer "is this bare name still unambiguous
988/// after the move". It must key that map **exactly** the way
989/// `LinkIndex::build` does or the two would disagree about what a link points
990/// at. Sharing this function and [`normalize_target`] makes that impossible.
991///
992/// # Examples
993///
994/// ```
995/// use markdown_link::suffixes;
996///
997/// assert_eq!(suffixes("a/b/c"), ["c", "b/c", "a/b/c"]);
998/// assert_eq!(suffixes("c"), ["c"]);
999/// ```
1000pub fn suffixes(path_no_ext: &str) -> Vec<String> {
1001 let segs: Vec<&str> = path_no_ext.split('/').collect();
1002 (0..segs.len()).rev().map(|i| segs[i..].join("/")).collect()
1003}
1004
1005// Unit struct that carries the README as doc-tests.
1006#[cfg(doctest)]
1007#[doc = include_str!("../README.md")]
1008struct ReadmeDoctests;
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 #[test]
1015 fn prefer_in_folder_matches_whole_folder_names() {
1016 let field = vec!["notes2/a.md".to_string(), "notes/a.md".to_string()];
1017 assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes/a.md"));
1018 assert_eq!(prefer_in_folder(&field, "Notes/x.md"), Some("notes/a.md"));
1019 let only_sibling = vec!["z/a.md".to_string(), "notes2/a.md".to_string()];
1020 assert_eq!(
1021 prefer_in_folder(&only_sibling, "notes/x.md"),
1022 Some("z/a.md")
1023 );
1024 }
1025
1026 #[test]
1027 fn parses_wikilinks_embeds_headings_and_aliases() {
1028 let links = body_links("See [[Target]] and ![[img.png]] and [[a#h|Alias]].");
1029 assert_eq!(
1030 links,
1031 vec![
1032 RawLink {
1033 target: "Target".into(),
1034 kind: LinkKind::Wikilink,
1035 field: None
1036 },
1037 RawLink {
1038 target: "img.png".into(),
1039 kind: LinkKind::Embed,
1040 field: None
1041 },
1042 RawLink {
1043 target: "a".into(),
1044 kind: LinkKind::Wikilink,
1045 field: None
1046 },
1047 ]
1048 );
1049 }
1050
1051 #[test]
1052 fn body_links_come_back_in_the_order_they_appear() {
1053 let targets: Vec<_> = body_links("[a](x.md) [[y]] [b](z.md)")
1054 .into_iter()
1055 .map(|l| l.target)
1056 .collect();
1057 assert_eq!(targets, vec!["x", "y", "z"]);
1058 }
1059
1060 #[test]
1061 fn links_inside_code_are_not_harvested() {
1062 let body = "real [[Yes]]\n\n```\nfake [[No]]\n```\n\ninline `[[AlsoNo]]` done\n";
1063 let targets: Vec<_> = body_links(body).into_iter().map(|l| l.target).collect();
1064 assert_eq!(targets, vec!["Yes"]);
1065 }
1066
1067 #[test]
1068 fn relative_markdown_links_count_absolute_urls_do_not() {
1069 let links = body_links("[a](Notes/x.md) [b](https://example.com/y.md) [c](<sp ace.md>)");
1070 let targets: Vec<_> = links.into_iter().map(|l| l.target).collect();
1071 assert_eq!(targets, vec!["Notes/x"]);
1072 }
1073
1074 #[test]
1075 fn percent_encoded_markdown_links_are_decoded() {
1076 let links = body_links("[a](Notes/My%20Note.md)");
1077 assert_eq!(links[0].target, "Notes/My Note");
1078 }
1079
1080 #[test]
1081 fn target_range_matches_the_parsed_target() {
1082 for inner in [
1083 "Note",
1084 " Note |a",
1085 "Note\\|a",
1086 "Note \\|a",
1087 "Note#H\\|a",
1088 "Note\\\\|a",
1089 "Note\\",
1090 "a#b|c#d",
1091 ] {
1092 let range = target_range(inner).expect(inner);
1093 assert_eq!(
1094 &inner[range],
1095 parse_inner(inner, false).unwrap().target,
1096 "{inner}"
1097 );
1098 }
1099 assert_eq!(target_range("Note\\|a"), Some(0..4));
1100 assert_eq!(target_range("\\|a"), None);
1101 assert_eq!(target_range("#H"), None);
1102 }
1103
1104 #[test]
1105 fn empty_targets_are_dropped() {
1106 assert!(body_links("[[]] [[ ]] [[|alias]]").is_empty());
1107 }
1108
1109 #[test]
1110 fn frontmatter_links_are_tagged_with_their_key() {
1111 let fm =
1112 yaml_splice::parse::parse_mapping("rel: '[[Other]]'\nlist:\n - '[[Two]]'\n").unwrap();
1113 let links = frontmatter_links(&fm);
1114 assert_eq!(links.len(), 2);
1115 assert_eq!(links[0].field.as_deref(), Some("rel"));
1116 assert_eq!(links[1].target, "Two");
1117 assert_eq!(links[1].field.as_deref(), Some("list"));
1118 }
1119
1120 #[test]
1121 fn frontmatter_paths_keep_values_with_an_extension() {
1122 let fm = yaml_splice::parse::parse_mapping(
1123 "cover: img/a.png\nfiles:\n - docs/b.pdf\n - plain words\ntitle: v1.2\nurl: https://x.org/c.png\nup: '[[Note]]'\nn: 3\n",
1124 )
1125 .unwrap();
1126 let links = frontmatter_paths(&fm);
1127 let got: Vec<(&str, &str)> = links
1128 .iter()
1129 .map(|l| (l.field.as_deref().unwrap(), l.target.as_str()))
1130 .collect();
1131 assert_eq!(got, vec![("cover", "img/a.png"), ("files", "docs/b.pdf")]);
1132 assert!(links.iter().all(|l| l.kind == LinkKind::Frontmatter));
1133 }
1134
1135 #[test]
1136 fn the_path_pre_test_matches_what_the_parse_keeps() {
1137 assert!(may_hold_frontmatter_paths("cover: img/a.png\n"));
1138 assert!(may_hold_frontmatter_paths("files:\n - \"docs/b.pdf\"\n"));
1139 assert!(may_hold_frontmatter_paths("tags: [a, b.jpg]\n"));
1140 assert!(may_hold_frontmatter_paths("files: [a.png, notes]\n"));
1141 assert!(may_hold_frontmatter_paths("cover: img/a.png # hero\n"));
1142 assert!(may_hold_frontmatter_paths("m: {cover: a.png, n: 1}\n"));
1143 assert!(!may_hold_frontmatter_paths("title: Hello\nn: 3\nv: 1.2\n"));
1144 }
1145
1146 #[test]
1147 fn display_text_replaces_links_with_their_alias_or_target() {
1148 assert_eq!(
1149 display_text("See [[Target|the thing]] and ![[img.png]] and [[Plain]]."),
1150 "See the thing and img.png and Plain."
1151 );
1152 }
1153
1154 #[test]
1155 fn suffix_index_resolves_every_path_suffix() {
1156 let idx = LinkIndex::build(&["notes/muscles/brachialis.md".to_string()]);
1157 for t in [
1158 "brachialis",
1159 "Muscles/brachialis",
1160 "notes/muscles/brachialis",
1161 ] {
1162 assert_eq!(
1163 idx.resolve(t),
1164 Some("notes/muscles/brachialis.md"),
1165 "for {t}"
1166 );
1167 }
1168 }
1169
1170 #[test]
1171 fn resolution_is_case_insensitive_and_extension_tolerant() {
1172 let idx = LinkIndex::build(&["Notes/Alpha.md".to_string(), "_att/photo.jpeg".to_string()]);
1173 assert_eq!(idx.resolve("alpha"), Some("Notes/Alpha.md"));
1174 assert_eq!(idx.resolve("ALPHA.md"), Some("Notes/Alpha.md"));
1175 assert_eq!(idx.resolve("photo.jpeg"), Some("_att/photo.jpeg"));
1176 // Attachments are addressed WITH their extension; the bare stem is
1177 // not a key.
1178 assert_eq!(idx.resolve("photo"), None);
1179 }
1180
1181 #[test]
1182 fn shortest_path_wins_a_collision() {
1183 let idx = LinkIndex::build(&[
1184 "Deep/Nested/Folder/note.md".to_string(),
1185 "note.md".to_string(),
1186 ]);
1187 assert_eq!(idx.resolve("note"), Some("note.md"));
1188 assert!(idx.collisions > 0);
1189 // The deep one is still reachable by a longer suffix.
1190 assert_eq!(
1191 idx.resolve("Folder/note"),
1192 Some("Deep/Nested/Folder/note.md")
1193 );
1194 }
1195
1196 #[test]
1197 fn a_pathed_miss_falls_back_to_the_basename() {
1198 // The collection-alias case: `muscle-groups/` is not a real folder.
1199 let idx = LinkIndex::build(&["notes/groups/glutes.md".to_string()]);
1200 assert_eq!(
1201 idx.resolve("muscle-groups/glutes"),
1202 Some("notes/groups/glutes.md")
1203 );
1204 }
1205
1206 #[test]
1207 fn an_unresolvable_target_is_none_not_a_panic() {
1208 let idx = LinkIndex::build(&["a.md".to_string()]);
1209 assert_eq!(idx.resolve("nothing/here"), None);
1210 assert_eq!(idx.resolve(""), None);
1211 }
1212
1213 #[test]
1214 fn backslashes_and_dot_slash_normalise() {
1215 let idx = LinkIndex::build(&["Notes/Sub/x.md".to_string()]);
1216 assert_eq!(idx.resolve("Notes\\Sub\\x"), Some("Notes/Sub/x.md"));
1217 assert_eq!(idx.resolve("./Notes/Sub/x.md"), Some("Notes/Sub/x.md"));
1218 }
1219
1220 #[test]
1221 fn unicode_targets_resolve() {
1222 let idx = LinkIndex::build(&["Заметки/Привет.md".to_string()]);
1223 assert_eq!(idx.resolve("привет"), Some("Заметки/Привет.md"));
1224 }
1225}