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 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}