Skip to main content

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}