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/// // `notes/` does not match the sibling folder `notes2/`.
703/// let field = vec!["notes2/a.md".to_string(), "z/notes/a.md".to_string()];
704/// assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes2/a.md"));
705/// ```
706pub fn prefer_in_folder<'a>(field: &'a [String], from: &str) -> Option<&'a str> {
707 let first = field.first()?;
708 if field.len() == 1 {
709 return Some(first);
710 }
711 let folder = source_folder(from);
712 // Match whole folder names: `notes/` must not match `notes2/`.
713 let prefix = format!("{folder}/");
714 if !folder.is_empty()
715 && let Some(hit) = field.iter().find(|p| p.to_lowercase().starts_with(&prefix))
716 {
717 return Some(hit);
718 }
719 Some(first)
720}
721
722/// `target` joined onto `from`'s folder when it starts with `./` or `../`,
723/// with the dot segments collapsed. `None` for any other target, and for one
724/// that climbs above the vault root.
725///
726/// Public so a caller such as a rename planner can resolve relative links
727/// exactly as [`LinkIndex::resolve_from`] does.
728///
729/// # Examples
730///
731/// ```
732/// use markdown_link::join_relative;
733///
734/// assert_eq!(join_relative("../x", "a/b/c.md").as_deref(), Some("a/x"));
735/// assert_eq!(join_relative("./y", "a/b.md").as_deref(), Some("a/y"));
736/// assert_eq!(join_relative("plain", "a/b.md"), None); // not relative
737/// assert_eq!(join_relative("../../x", "a/b.md"), None); // above the root
738/// ```
739pub fn join_relative(target: &str, from: &str) -> Option<String> {
740 let t = target.trim().replace('\\', "/");
741 if !(t.starts_with("./") || t.starts_with("../")) {
742 return None;
743 }
744 let from = from.trim().replace('\\', "/");
745 let mut segs: Vec<&str> = match from.rfind('/') {
746 Some(at) => from[..at].split('/').filter(|s| !s.is_empty()).collect(),
747 None => Vec::new(),
748 };
749 for seg in t.split('/') {
750 match seg {
751 "" | "." => {}
752 ".." => {
753 segs.pop()?;
754 }
755 _ => segs.push(seg),
756 }
757 }
758 Some(segs.join("/"))
759}
760
761/// The folder a vault-relative path sits in, lowercased, or `""` at the root.
762fn source_folder(from: &str) -> String {
763 let normalized = from.trim().replace('\\', "/");
764 match normalized.rfind('/') {
765 Some(at) => normalized[..at].to_lowercase(),
766 None => String::new(),
767 }
768}
769
770/// Normalise a link target to an index key.
771///
772/// This is the crate's **only** place a `.md` extension is stripped, running
773/// on both path suffixes at build time and queries at resolve time. Stripping
774/// elsewhere strips a second `.md` extension, doubling it.
775///
776/// # Examples
777///
778/// ```
779/// use markdown_link::normalize_target;
780///
781/// assert_eq!(normalize_target("./Notes//A%20B.MD"), "notes/a b");
782/// assert_eq!(normalize_target(" dir\\file "), "dir/file");
783/// ```
784pub fn normalize_target(raw: &str) -> String {
785 let mut t = raw.trim().replace('\\', "/");
786 while let Some(rest) = t.strip_prefix("./") {
787 t = rest.to_string();
788 }
789 while t.contains("//") {
790 t = t.replace("//", "/");
791 }
792 t = percent_decode(&t);
793 strip_md_ext(&t).to_lowercase()
794}
795
796impl LinkIndex {
797 /// Build from vault-relative paths (with extensions).
798 ///
799 /// Shortest path first, measured in CHARACTERS (not depth), then deterministic
800 /// collation. Character length is the observed tie-break:
801 /// `x/y/tie.md` (10 chars, 3 segments) beats `length-versus-depth-long-folder/tie.md`
802 /// (38 chars, 2 segments).
803 ///
804 /// # Examples
805 ///
806 /// ```
807 /// use markdown_link::LinkIndex;
808 ///
809 /// let index = LinkIndex::build(&["x/y/tie.md".to_string(), "long-folder/tie.md".to_string()]);
810 /// // The shorter path wins the bare name.
811 /// assert_eq!(index.resolve("tie"), Some("x/y/tie.md"));
812 /// assert_eq!(index.resolve("long-folder/tie"), Some("long-folder/tie.md"));
813 /// assert!(index.collisions > 0);
814 /// ```
815 pub fn build(paths: &[String]) -> LinkIndex {
816 let mut sorted: Vec<&String> = paths.iter().collect();
817 sorted.sort_by(|a, b| {
818 a.chars()
819 .count()
820 .cmp(&b.chars().count())
821 .then_with(|| slug_collate::compare_paths(a, b))
822 });
823
824 let mut by_key: HashMap<String, String> = HashMap::new();
825 let mut ambiguous: HashMap<String, Vec<String>> = HashMap::new();
826 let mut collisions = 0usize;
827 for path in sorted {
828 // Suffixes are taken from the path as written, extension included.
829 // `normalize_target` is what drops a trailing `.md`, and it does so
830 // on the query too, so stripping here as well would take a second
831 // one off `note.md.md`. It would also have to slice bytes off a
832 // `&str` — the char-boundary hazard `strip_md_ext` guards against.
833 for suffix in suffixes(path) {
834 let key = normalize_target(&suffix);
835 match by_key.get(&key).cloned() {
836 Some(existing) => {
837 if existing != *path {
838 collisions += 1;
839 // The winner leads the field, so index 0 is both the
840 // source-independent answer and the shortest.
841 let field = ambiguous.entry(key).or_insert_with(|| vec![existing]);
842 if !field.iter().any(|p| p == path) {
843 field.push(path.clone());
844 }
845 }
846 }
847 None => {
848 by_key.insert(key, path.clone());
849 }
850 }
851 }
852 }
853 LinkIndex {
854 by_key,
855 ambiguous,
856 collisions,
857 }
858 }
859
860 /// Resolve one target to a vault-relative path, or `None`.
861 ///
862 /// Source-independent, and therefore the same answer everywhere in the
863 /// vault. Equivalent to [`LinkIndex::resolve_from`] called from the vault
864 /// root, where the folder preference cannot apply.
865 ///
866 /// # Examples
867 ///
868 /// ```
869 /// use markdown_link::LinkIndex;
870 ///
871 /// let index = LinkIndex::build(&["notes/glutes.md".to_string()]);
872 /// assert_eq!(index.resolve("glutes"), Some("notes/glutes.md"));
873 /// // A pathed miss falls back to its basename.
874 /// assert_eq!(index.resolve("muscle-groups/glutes"), Some("notes/glutes.md"));
875 /// assert_eq!(index.resolve("nothing"), None);
876 /// ```
877 pub fn resolve(&self, target: &str) -> Option<&str> {
878 self.resolve_from(target, "")
879 }
880
881 /// Resolve one target as it was written in `from`, a vault-relative path.
882 ///
883 /// Differs from [`LinkIndex::resolve`] in two cases, the only ones where
884 /// where the link sits can change the answer. A `./` or `../` target names
885 /// the file at that path from the linking note's folder (see
886 /// [`join_relative`]). An AMBIGUOUS name — several files answer to it —
887 /// takes two observed behaviours, in order: a candidate inside the linking
888 /// note's own folder beats one outside it, and otherwise the shorter full
889 /// path wins.
890 ///
891 /// The folder test is a plain string prefix, not a segment-aligned one,
892 /// which is what the application does: a note in `notes/` also prefers a
893 /// candidate under `notes-archive/`. Small, real, and deliberately
894 /// matched — a reader seeing one file while a rename rewrites a different
895 /// one is worse than either rule alone.
896 ///
897 /// # Examples
898 ///
899 /// ```
900 /// use markdown_link::LinkIndex;
901 ///
902 /// let index = LinkIndex::build(&["b/a.md".to_string(), "notes/a.md".to_string(), "notes/x.md".to_string()]);
903 /// // Ambiguous name: the candidate in the linking note's folder wins.
904 /// assert_eq!(index.resolve_from("a", "notes/x.md"), Some("notes/a.md"));
905 /// assert_eq!(index.resolve_from("a", "other.md"), Some("b/a.md"));
906 /// // A `./` target is relative to the linking note.
907 /// assert_eq!(index.resolve_from("./a", "notes/x.md"), Some("notes/a.md"));
908 /// ```
909 pub fn resolve_from(&self, target: &str, from: &str) -> Option<&str> {
910 // `./x` and `../x` name a file relative to the linking note's folder,
911 // the way the resolver treats them. A miss, or a path that climbs out of the
912 // vault, falls through to the vault-wide lookup below.
913 if let Some(joined) = join_relative(target, from) {
914 let key = normalize_target(&joined);
915 if let Some(field) = self.candidates(&key) {
916 // The field holds every path this key is a SUFFIX of; the
917 // joined path names one exactly.
918 if let Some(hit) = field.iter().find(|p| normalize_target(p) == key) {
919 return Some(hit);
920 }
921 }
922 }
923 let key = normalize_target(target);
924 let field = match self.candidates(&key) {
925 Some(field) => field,
926 None => {
927 // Pathed miss -> basename fallback. See the module docs.
928 let slash = key.rfind('/')?;
929 if slash == 0 {
930 return None;
931 }
932 self.candidates(&key[slash + 1..])?
933 }
934 };
935 prefer_in_folder(field, from)
936 }
937
938 /// Every path a key answers to, shortest first.
939 ///
940 /// One borrowed entry for the overwhelmingly common unique case, and the
941 /// stored field only where more than one file competes.
942 fn candidates(&self, key: &str) -> Option<&[String]> {
943 if let Some(field) = self.ambiguous.get(key) {
944 return Some(field);
945 }
946 self.by_key.get(key).map(std::slice::from_ref)
947 }
948
949 /// The number of distinct keys (normalised path suffixes) in the index.
950 /// This is not the number of files.
951 ///
952 /// # Examples
953 ///
954 /// ```
955 /// use markdown_link::LinkIndex;
956 ///
957 /// let index = LinkIndex::build(&["a/b.md".to_string()]);
958 /// assert_eq!(index.len(), 2); // "b" and "a/b"
959 /// ```
960 pub fn len(&self) -> usize {
961 self.by_key.len()
962 }
963
964 /// Whether the index holds no keys.
965 ///
966 /// # Examples
967 ///
968 /// ```
969 /// use markdown_link::LinkIndex;
970 ///
971 /// assert!(LinkIndex::build(&[]).is_empty());
972 /// assert!(!LinkIndex::build(&["a.md".to_string()]).is_empty());
973 /// ```
974 pub fn is_empty(&self) -> bool {
975 self.by_key.is_empty()
976 }
977}
978
979/// `a/b/c` -> `["c", "b/c", "a/b/c"]`.
980///
981/// Public for the same reason [`strip_md_ext`] and [`percent_decode`] are, and
982/// for one specific kind of caller: one that needs the *candidate set* behind
983/// a link target, which [`LinkIndex`] deliberately does not keep (it stores
984/// one winner per key, which is all resolution needs and all a whole-vault
985/// cache should pay for). A rename planner, for example, can build its own
986/// key → candidates multimap to answer "is this bare name still unambiguous
987/// after the move". It must key that map **exactly** the way
988/// `LinkIndex::build` does or the two would disagree about what a link points
989/// at. Sharing this function and [`normalize_target`] makes that impossible.
990///
991/// # Examples
992///
993/// ```
994/// use markdown_link::suffixes;
995///
996/// assert_eq!(suffixes("a/b/c"), ["c", "b/c", "a/b/c"]);
997/// assert_eq!(suffixes("c"), ["c"]);
998/// ```
999pub fn suffixes(path_no_ext: &str) -> Vec<String> {
1000 let segs: Vec<&str> = path_no_ext.split('/').collect();
1001 (0..segs.len()).rev().map(|i| segs[i..].join("/")).collect()
1002}
1003
1004// Unit struct that carries the README as doc-tests.
1005#[cfg(doctest)]
1006#[doc = include_str!("../README.md")]
1007struct ReadmeDoctests;
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1014 fn prefer_in_folder_matches_whole_folder_names() {
1015 let field = vec!["notes2/a.md".to_string(), "notes/a.md".to_string()];
1016 assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes/a.md"));
1017 assert_eq!(prefer_in_folder(&field, "Notes/x.md"), Some("notes/a.md"));
1018 let only_sibling = vec!["z/a.md".to_string(), "notes2/a.md".to_string()];
1019 assert_eq!(
1020 prefer_in_folder(&only_sibling, "notes/x.md"),
1021 Some("z/a.md")
1022 );
1023 }
1024
1025 #[test]
1026 fn parses_wikilinks_embeds_headings_and_aliases() {
1027 let links = body_links("See [[Target]] and ![[img.png]] and [[a#h|Alias]].");
1028 assert_eq!(
1029 links,
1030 vec![
1031 RawLink {
1032 target: "Target".into(),
1033 kind: LinkKind::Wikilink,
1034 field: None
1035 },
1036 RawLink {
1037 target: "img.png".into(),
1038 kind: LinkKind::Embed,
1039 field: None
1040 },
1041 RawLink {
1042 target: "a".into(),
1043 kind: LinkKind::Wikilink,
1044 field: None
1045 },
1046 ]
1047 );
1048 }
1049
1050 #[test]
1051 fn body_links_come_back_in_the_order_they_appear() {
1052 let targets: Vec<_> = body_links("[a](x.md) [[y]] [b](z.md)")
1053 .into_iter()
1054 .map(|l| l.target)
1055 .collect();
1056 assert_eq!(targets, vec!["x", "y", "z"]);
1057 }
1058
1059 #[test]
1060 fn links_inside_code_are_not_harvested() {
1061 let body = "real [[Yes]]\n\n```\nfake [[No]]\n```\n\ninline `[[AlsoNo]]` done\n";
1062 let targets: Vec<_> = body_links(body).into_iter().map(|l| l.target).collect();
1063 assert_eq!(targets, vec!["Yes"]);
1064 }
1065
1066 #[test]
1067 fn relative_markdown_links_count_absolute_urls_do_not() {
1068 let links = body_links("[a](Notes/x.md) [b](https://example.com/y.md) [c](<sp ace.md>)");
1069 let targets: Vec<_> = links.into_iter().map(|l| l.target).collect();
1070 assert_eq!(targets, vec!["Notes/x"]);
1071 }
1072
1073 #[test]
1074 fn percent_encoded_markdown_links_are_decoded() {
1075 let links = body_links("[a](Notes/My%20Note.md)");
1076 assert_eq!(links[0].target, "Notes/My Note");
1077 }
1078
1079 #[test]
1080 fn target_range_matches_the_parsed_target() {
1081 for inner in [
1082 "Note",
1083 " Note |a",
1084 "Note\\|a",
1085 "Note \\|a",
1086 "Note#H\\|a",
1087 "Note\\\\|a",
1088 "Note\\",
1089 "a#b|c#d",
1090 ] {
1091 let range = target_range(inner).expect(inner);
1092 assert_eq!(
1093 &inner[range],
1094 parse_inner(inner, false).unwrap().target,
1095 "{inner}"
1096 );
1097 }
1098 assert_eq!(target_range("Note\\|a"), Some(0..4));
1099 assert_eq!(target_range("\\|a"), None);
1100 assert_eq!(target_range("#H"), None);
1101 }
1102
1103 #[test]
1104 fn empty_targets_are_dropped() {
1105 assert!(body_links("[[]] [[ ]] [[|alias]]").is_empty());
1106 }
1107
1108 #[test]
1109 fn frontmatter_links_are_tagged_with_their_key() {
1110 let fm =
1111 yaml_splice::parse::parse_mapping("rel: '[[Other]]'\nlist:\n - '[[Two]]'\n").unwrap();
1112 let links = frontmatter_links(&fm);
1113 assert_eq!(links.len(), 2);
1114 assert_eq!(links[0].field.as_deref(), Some("rel"));
1115 assert_eq!(links[1].target, "Two");
1116 assert_eq!(links[1].field.as_deref(), Some("list"));
1117 }
1118
1119 #[test]
1120 fn frontmatter_paths_keep_values_with_an_extension() {
1121 let fm = yaml_splice::parse::parse_mapping(
1122 "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",
1123 )
1124 .unwrap();
1125 let links = frontmatter_paths(&fm);
1126 let got: Vec<(&str, &str)> = links
1127 .iter()
1128 .map(|l| (l.field.as_deref().unwrap(), l.target.as_str()))
1129 .collect();
1130 assert_eq!(got, vec![("cover", "img/a.png"), ("files", "docs/b.pdf")]);
1131 assert!(links.iter().all(|l| l.kind == LinkKind::Frontmatter));
1132 }
1133
1134 #[test]
1135 fn the_path_pre_test_matches_what_the_parse_keeps() {
1136 assert!(may_hold_frontmatter_paths("cover: img/a.png\n"));
1137 assert!(may_hold_frontmatter_paths("files:\n - \"docs/b.pdf\"\n"));
1138 assert!(may_hold_frontmatter_paths("tags: [a, b.jpg]\n"));
1139 assert!(may_hold_frontmatter_paths("files: [a.png, notes]\n"));
1140 assert!(may_hold_frontmatter_paths("cover: img/a.png # hero\n"));
1141 assert!(may_hold_frontmatter_paths("m: {cover: a.png, n: 1}\n"));
1142 assert!(!may_hold_frontmatter_paths("title: Hello\nn: 3\nv: 1.2\n"));
1143 }
1144
1145 #[test]
1146 fn display_text_replaces_links_with_their_alias_or_target() {
1147 assert_eq!(
1148 display_text("See [[Target|the thing]] and ![[img.png]] and [[Plain]]."),
1149 "See the thing and img.png and Plain."
1150 );
1151 }
1152
1153 #[test]
1154 fn suffix_index_resolves_every_path_suffix() {
1155 let idx = LinkIndex::build(&["notes/muscles/brachialis.md".to_string()]);
1156 for t in [
1157 "brachialis",
1158 "Muscles/brachialis",
1159 "notes/muscles/brachialis",
1160 ] {
1161 assert_eq!(
1162 idx.resolve(t),
1163 Some("notes/muscles/brachialis.md"),
1164 "for {t}"
1165 );
1166 }
1167 }
1168
1169 #[test]
1170 fn resolution_is_case_insensitive_and_extension_tolerant() {
1171 let idx = LinkIndex::build(&["Notes/Alpha.md".to_string(), "_att/photo.jpeg".to_string()]);
1172 assert_eq!(idx.resolve("alpha"), Some("Notes/Alpha.md"));
1173 assert_eq!(idx.resolve("ALPHA.md"), Some("Notes/Alpha.md"));
1174 assert_eq!(idx.resolve("photo.jpeg"), Some("_att/photo.jpeg"));
1175 // Attachments are addressed WITH their extension; the bare stem is
1176 // not a key.
1177 assert_eq!(idx.resolve("photo"), None);
1178 }
1179
1180 #[test]
1181 fn shortest_path_wins_a_collision() {
1182 let idx = LinkIndex::build(&[
1183 "Deep/Nested/Folder/note.md".to_string(),
1184 "note.md".to_string(),
1185 ]);
1186 assert_eq!(idx.resolve("note"), Some("note.md"));
1187 assert!(idx.collisions > 0);
1188 // The deep one is still reachable by a longer suffix.
1189 assert_eq!(
1190 idx.resolve("Folder/note"),
1191 Some("Deep/Nested/Folder/note.md")
1192 );
1193 }
1194
1195 #[test]
1196 fn a_pathed_miss_falls_back_to_the_basename() {
1197 // The collection-alias case: `muscle-groups/` is not a real folder.
1198 let idx = LinkIndex::build(&["notes/groups/glutes.md".to_string()]);
1199 assert_eq!(
1200 idx.resolve("muscle-groups/glutes"),
1201 Some("notes/groups/glutes.md")
1202 );
1203 }
1204
1205 #[test]
1206 fn an_unresolvable_target_is_none_not_a_panic() {
1207 let idx = LinkIndex::build(&["a.md".to_string()]);
1208 assert_eq!(idx.resolve("nothing/here"), None);
1209 assert_eq!(idx.resolve(""), None);
1210 }
1211
1212 #[test]
1213 fn backslashes_and_dot_slash_normalise() {
1214 let idx = LinkIndex::build(&["Notes/Sub/x.md".to_string()]);
1215 assert_eq!(idx.resolve("Notes\\Sub\\x"), Some("Notes/Sub/x.md"));
1216 assert_eq!(idx.resolve("./Notes/Sub/x.md"), Some("Notes/Sub/x.md"));
1217 }
1218
1219 #[test]
1220 fn unicode_targets_resolve() {
1221 let idx = LinkIndex::build(&["Заметки/Привет.md".to_string()]);
1222 assert_eq!(idx.resolve("привет"), Some("Заметки/Привет.md"));
1223 }
1224}