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