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