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("%FF"), "%FF"); // not UTF-8, kept as written
751/// ```
752pub fn percent_decode(s: &str) -> String {
753    let b = s.as_bytes();
754    let mut out: Vec<u8> = Vec::with_capacity(b.len());
755    let mut i = 0;
756    while i < b.len() {
757        if b[i] == b'%' && i + 2 < b.len() {
758            let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok();
759            if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
760                out.push(v);
761                i += 3;
762                continue;
763            }
764        }
765        out.push(b[i]);
766        i += 1;
767    }
768    // Undecodable bytes keep the raw form rather than becoming U+FFFD, which
769    // would turn a resolvable link into a permanent ghost node.
770    String::from_utf8(out).unwrap_or_else(|_| s.to_string())
771}
772
773/// Why [`LinkIndex::resolve_strict`] did not resolve a target.
774#[derive(Debug, Clone, PartialEq, Eq)]
775pub enum ResolveError {
776    /// No file answers to the target.
777    NotFound,
778    /// Several files answer to it and the link names none of them outright.
779    /// Every candidate, shortest first.
780    Ambiguous(Vec<String>),
781}
782
783impl std::fmt::Display for ResolveError {
784    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785        match self {
786            ResolveError::NotFound => f.write_str("no file matches the link"),
787            ResolveError::Ambiguous(candidates) => {
788                write!(
789                    f,
790                    "the link matches several files: {}",
791                    candidates.join(", ")
792                )
793            }
794        }
795    }
796}
797
798impl std::error::Error for ResolveError {}
799
800/// A suffix index: every file is reachable by any suffix of its path.
801///
802/// # Examples
803///
804/// ```
805/// use markdown_link::LinkIndex;
806///
807/// let index = LinkIndex::build(&["notes/a.md".to_string(), "deep/er/b.md".to_string()]);
808/// assert_eq!(index.resolve("a"), Some("notes/a.md"));
809/// assert_eq!(index.resolve("er/b"), Some("deep/er/b.md"));
810/// assert_eq!(index.resolve("missing"), None);
811/// ```
812#[derive(Debug, Clone, Default)]
813pub struct LinkIndex {
814    by_key: HashMap<String, String>,
815    /// Candidate lists, kept ONLY for the keys more than one file answers to.
816    ///
817    /// Nearly every key has exactly one candidate, and paying a `Vec` for every
818    /// suffix of every path in a large vault is not free. `by_key` holds the
819    /// source-independent winner, which is all [`LinkIndex::resolve`] needs;
820    /// this holds the whole field, and only a resolution that knows where the
821    /// link was written ever looks at it.
822    ///
823    /// Shortest-first, like the build order that fills it.
824    ambiguous: HashMap<String, Vec<String>>,
825    /// How many times a path claimed a key that a different path already
826    /// held while the index was built.
827    pub collisions: usize,
828}
829
830/// Pick the winner from a shortest-first candidate field, as written in `from`.
831///
832/// **The one place the ambiguity rule lives.** [`LinkIndex`] keeps a single
833/// winner per key and reaches for this only when a name is contested. A tool
834/// that keeps whole candidate fields of its own, such as a rename planner that
835/// must answer "is this bare name still unambiguous after the move", can call
836/// it too. Two shapes of storage, and exactly one rule deciding which file a
837/// contested name means — because a reader seeing one file while a rename
838/// rewrites another is the failure that costs.
839///
840/// `field` must be ordered shortest-first, which is what makes the first
841/// in-folder hit also the shortest one and lets both rules fall out of a single
842/// pass.
843///
844/// # Examples
845///
846/// ```
847/// use markdown_link::prefer_in_folder;
848///
849/// let field = vec!["b/a.md".to_string(), "notes/a.md".to_string()];
850/// // Written in `notes/`: the candidate in the same folder wins.
851/// assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes/a.md"));
852/// // Written at the root: the shortest path wins.
853/// assert_eq!(prefer_in_folder(&field, "x.md"), Some("b/a.md"));
854/// assert_eq!(prefer_in_folder(&[], "x.md"), None);
855/// // `notes/` does not match the sibling folder `notes2/`.
856/// let field = vec!["notes2/a.md".to_string(), "z/notes/a.md".to_string()];
857/// assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes2/a.md"));
858/// ```
859pub fn prefer_in_folder<'a>(field: &'a [String], from: &str) -> Option<&'a str> {
860    let first = field.first()?;
861    if field.len() == 1 {
862        return Some(first);
863    }
864    let folder = source_folder(from);
865    // Match whole folder names: `notes/` must not match `notes2/`.
866    let prefix = format!("{folder}/");
867    if !folder.is_empty()
868        && let Some(hit) = field.iter().find(|p| p.to_lowercase().starts_with(&prefix))
869    {
870        return Some(hit);
871    }
872    Some(first)
873}
874
875/// `target` joined onto `from`'s folder when it starts with `./` or `../`,
876/// with the dot segments collapsed. `None` for any other target, and for one
877/// that climbs above the vault root.
878///
879/// Public so a caller such as a rename planner can resolve relative links
880/// exactly as [`LinkIndex::resolve_from`] does.
881///
882/// # Examples
883///
884/// ```
885/// use markdown_link::join_relative;
886///
887/// assert_eq!(join_relative("../x", "a/b/c.md").as_deref(), Some("a/x"));
888/// assert_eq!(join_relative("./y", "a/b.md").as_deref(), Some("a/y"));
889/// assert_eq!(join_relative("plain", "a/b.md"), None); // not relative
890/// assert_eq!(join_relative("../../x", "a/b.md"), None); // above the root
891/// ```
892pub fn join_relative(target: &str, from: &str) -> Option<String> {
893    let t = target.trim().replace('\\', "/");
894    if !(t.starts_with("./") || t.starts_with("../")) {
895        return None;
896    }
897    let from = from.trim().replace('\\', "/");
898    let mut segs: Vec<&str> = match from.rfind('/') {
899        Some(at) => from[..at].split('/').filter(|s| !s.is_empty()).collect(),
900        None => Vec::new(),
901    };
902    for seg in t.split('/') {
903        match seg {
904            "" | "." => {}
905            ".." => {
906                segs.pop()?;
907            }
908            _ => segs.push(seg),
909        }
910    }
911    Some(segs.join("/"))
912}
913
914/// The folder a vault-relative path sits in, lowercased, or `""` at the root.
915fn source_folder(from: &str) -> String {
916    let normalized = from.trim().replace('\\', "/");
917    match normalized.rfind('/') {
918        Some(at) => normalized[..at].to_lowercase(),
919        None => String::new(),
920    }
921}
922
923/// Normalise a link target to an index key.
924///
925/// This is the crate's **only** place a `.md` extension is stripped, running
926/// on both path suffixes at build time and queries at resolve time. Stripping
927/// elsewhere strips a second `.md` extension, doubling it.
928///
929/// # Examples
930///
931/// ```
932/// use markdown_link::normalize_target;
933///
934/// assert_eq!(normalize_target("./Notes//A%20B.MD"), "notes/a b");
935/// assert_eq!(normalize_target("  dir\\file  "), "dir/file");
936/// ```
937pub fn normalize_target(raw: &str) -> String {
938    let mut t = raw.trim().replace('\\', "/");
939    while let Some(rest) = t.strip_prefix("./") {
940        t = rest.to_string();
941    }
942    while t.contains("//") {
943        t = t.replace("//", "/");
944    }
945    t = percent_decode(&t);
946    strip_md_ext(&t).to_lowercase()
947}
948
949impl LinkIndex {
950    /// Build from vault-relative paths (with extensions).
951    ///
952    /// Shortest path first, measured in CHARACTERS (not depth), then deterministic
953    /// collation. Character length is the observed tie-break:
954    /// `x/y/tie.md` (10 chars, 3 segments) beats `length-versus-depth-long-folder/tie.md`
955    /// (38 chars, 2 segments).
956    ///
957    /// # Examples
958    ///
959    /// ```
960    /// use markdown_link::LinkIndex;
961    ///
962    /// let index = LinkIndex::build(&["x/y/tie.md".to_string(), "long-folder/tie.md".to_string()]);
963    /// // The shorter path wins the bare name.
964    /// assert_eq!(index.resolve("tie"), Some("x/y/tie.md"));
965    /// assert_eq!(index.resolve("long-folder/tie"), Some("long-folder/tie.md"));
966    /// assert!(index.collisions > 0);
967    /// ```
968    pub fn build(paths: &[String]) -> LinkIndex {
969        let mut sorted: Vec<&String> = paths.iter().collect();
970        sorted.sort_by(|a, b| {
971            a.chars()
972                .count()
973                .cmp(&b.chars().count())
974                .then_with(|| slug_collate::compare_paths(a, b))
975        });
976
977        let mut by_key: HashMap<String, String> = HashMap::new();
978        let mut ambiguous: HashMap<String, Vec<String>> = HashMap::new();
979        let mut collisions = 0usize;
980        for path in sorted {
981            // Suffixes are taken from the path as written, extension included.
982            // `normalize_target` is what drops a trailing `.md`, and it does so
983            // on the query too, so stripping here as well would take a second
984            // one off `note.md.md`. It would also have to slice bytes off a
985            // `&str` — the char-boundary hazard `strip_md_ext` guards against.
986            for suffix in suffixes(path) {
987                let key = normalize_target(&suffix);
988                match by_key.get(&key).cloned() {
989                    Some(existing) => {
990                        if existing != *path {
991                            collisions += 1;
992                            // The winner leads the field, so index 0 is both the
993                            // source-independent answer and the shortest.
994                            let field = ambiguous.entry(key).or_insert_with(|| vec![existing]);
995                            if !field.iter().any(|p| p == path) {
996                                field.push(path.clone());
997                            }
998                        }
999                    }
1000                    None => {
1001                        by_key.insert(key, path.clone());
1002                    }
1003                }
1004            }
1005        }
1006        LinkIndex {
1007            by_key,
1008            ambiguous,
1009            collisions,
1010        }
1011    }
1012
1013    /// Resolve one target to a vault-relative path, or `None`.
1014    ///
1015    /// Source-independent, and therefore the same answer everywhere in the
1016    /// vault. Equivalent to [`LinkIndex::resolve_from`] called from the vault
1017    /// root, where the folder preference cannot apply.
1018    ///
1019    /// # Examples
1020    ///
1021    /// ```
1022    /// use markdown_link::LinkIndex;
1023    ///
1024    /// let index = LinkIndex::build(&["notes/glutes.md".to_string()]);
1025    /// assert_eq!(index.resolve("glutes"), Some("notes/glutes.md"));
1026    /// // A pathed miss falls back to its basename.
1027    /// assert_eq!(index.resolve("muscle-groups/glutes"), Some("notes/glutes.md"));
1028    /// assert_eq!(index.resolve("nothing"), None);
1029    /// ```
1030    pub fn resolve(&self, target: &str) -> Option<&str> {
1031        self.resolve_from(target, "")
1032    }
1033
1034    /// Resolve one target as it was written in `from`, a vault-relative path.
1035    ///
1036    /// Differs from [`LinkIndex::resolve`] in two cases, the only ones where
1037    /// where the link sits can change the answer. A `./` or `../` target names
1038    /// the file at that path from the linking note's folder (see
1039    /// [`join_relative`]). An AMBIGUOUS name — several files answer to it —
1040    /// takes two observed behaviours, in order: a candidate inside the linking
1041    /// note's own folder beats one outside it, and otherwise the shorter full
1042    /// path wins.
1043    ///
1044    /// The folder test is a plain string prefix, not a segment-aligned one,
1045    /// which is what the application does: a note in `notes/` also prefers a
1046    /// candidate under `notes-archive/`. Small, real, and deliberately
1047    /// matched — a reader seeing one file while a rename rewrites a different
1048    /// one is worse than either rule alone.
1049    ///
1050    /// # Examples
1051    ///
1052    /// ```
1053    /// use markdown_link::LinkIndex;
1054    ///
1055    /// let index = LinkIndex::build(&["b/a.md".to_string(), "notes/a.md".to_string(), "notes/x.md".to_string()]);
1056    /// // Ambiguous name: the candidate in the linking note's folder wins.
1057    /// assert_eq!(index.resolve_from("a", "notes/x.md"), Some("notes/a.md"));
1058    /// assert_eq!(index.resolve_from("a", "other.md"), Some("b/a.md"));
1059    /// // A `./` target is relative to the linking note.
1060    /// assert_eq!(index.resolve_from("./a", "notes/x.md"), Some("notes/a.md"));
1061    /// ```
1062    pub fn resolve_from(&self, target: &str, from: &str) -> Option<&str> {
1063        // `./x` and `../x` name a file relative to the linking note's folder,
1064        // the way the resolver treats them. A miss, or a path that climbs out of the
1065        // vault, falls through to the vault-wide lookup below.
1066        if let Some(joined) = join_relative(target, from) {
1067            let key = normalize_target(&joined);
1068            if let Some(field) = self.field(&key) {
1069                // The field holds every path this key is a SUFFIX of; the
1070                // joined path names one exactly.
1071                if let Some(hit) = field.iter().find(|p| normalize_target(p) == key) {
1072                    return Some(hit);
1073                }
1074            }
1075        }
1076        let key = normalize_target(target);
1077        let field = match self.field(&key) {
1078            Some(field) => field,
1079            None => {
1080                // Pathed miss -> basename fallback. See the module docs.
1081                let slash = key.rfind('/')?;
1082                if slash == 0 {
1083                    return None;
1084                }
1085                self.field(&key[slash + 1..])?
1086            }
1087        };
1088        prefer_in_folder(field, from)
1089    }
1090
1091    /// Every path `target`, written in `from`, could mean, shortest first:
1092    /// the field [`LinkIndex::resolve_from`] chooses from. Empty when nothing
1093    /// answers to it.
1094    ///
1095    /// A `./` or `../` target that names a file gives that one file. A pathed
1096    /// target that misses gives the field of its last segment, the same
1097    /// fallback `resolve_from` takes.
1098    ///
1099    /// # Examples
1100    ///
1101    /// ```
1102    /// use markdown_link::LinkIndex;
1103    ///
1104    /// let index = LinkIndex::build(&["b/a.md".to_string(), "notes/a.md".to_string()]);
1105    /// assert_eq!(index.candidates("a", "x.md"), ["b/a.md", "notes/a.md"]);
1106    /// assert_eq!(index.candidates("./a", "notes/x.md"), ["notes/a.md"]);
1107    /// assert!(index.candidates("nothing", "x.md").is_empty());
1108    /// ```
1109    pub fn candidates(&self, target: &str, from: &str) -> &[String] {
1110        if let Some(joined) = join_relative(target, from) {
1111            let key = normalize_target(&joined);
1112            if let Some(field) = self.field(&key)
1113                && let Some(hit) = field.iter().find(|p| normalize_target(p) == key)
1114            {
1115                return std::slice::from_ref(hit);
1116            }
1117        }
1118        let key = normalize_target(target);
1119        if let Some(field) = self.field(&key) {
1120            return field;
1121        }
1122        match key.rfind('/') {
1123            Some(slash) if slash > 0 => self.field(&key[slash + 1..]).unwrap_or(&[]),
1124            _ => &[],
1125        }
1126    }
1127
1128    /// Resolve `target`, written in `from`, refusing to guess.
1129    ///
1130    /// Where [`LinkIndex::resolve_from`] picks among several candidates (same
1131    /// folder first, then the shortest path), this takes one only when the
1132    /// link names it outright:
1133    ///
1134    /// 1. a `./` or `../` target that names a file;
1135    /// 2. the only candidate;
1136    /// 3. the candidate whose whole vault-relative path is the target;
1137    /// 4. the candidate at the target's path from the linking note's folder.
1138    ///
1139    /// Anything else with more than one candidate is
1140    /// [`ResolveError::Ambiguous`], with every candidate, shortest first. An
1141    /// empty target (a link to the note it is written in) is
1142    /// [`ResolveError::NotFound`]; the caller knows which note that is.
1143    ///
1144    /// # Examples
1145    ///
1146    /// ```
1147    /// use markdown_link::{LinkIndex, ResolveError};
1148    ///
1149    /// let index = LinkIndex::build(&["b/a.md".to_string(), "notes/a.md".to_string()]);
1150    /// assert_eq!(
1151    ///     index.resolve_strict("a", "x.md"),
1152    ///     Err(ResolveError::Ambiguous(vec!["b/a.md".into(), "notes/a.md".into()]))
1153    /// );
1154    /// assert_eq!(index.resolve_strict("notes/a", "x.md"), Ok("notes/a.md"));
1155    /// // `a` from inside `notes/` is `notes/a.md` by its path from there.
1156    /// assert_eq!(index.resolve_strict("a", "notes/x.md"), Ok("notes/a.md"));
1157    /// assert_eq!(index.resolve_strict("zzz", "x.md"), Err(ResolveError::NotFound));
1158    /// ```
1159    pub fn resolve_strict(&self, target: &str, from: &str) -> Result<&str, ResolveError> {
1160        let field = self.candidates(target, from);
1161        match field {
1162            [] => return Err(ResolveError::NotFound),
1163            [only] => return Ok(only),
1164            _ => {}
1165        }
1166        let key = normalize_target(target);
1167        if let Some(hit) = field.iter().find(|p| normalize_target(p) == key) {
1168            return Ok(hit);
1169        }
1170        let folder = source_folder(from);
1171        if !folder.is_empty() {
1172            let joined = format!("{folder}/{key}");
1173            if let Some(hit) = field.iter().find(|p| normalize_target(p) == joined) {
1174                return Ok(hit);
1175            }
1176        }
1177        Err(ResolveError::Ambiguous(field.to_vec()))
1178    }
1179
1180    /// Every path a key answers to, shortest first.
1181    ///
1182    /// One borrowed entry for the overwhelmingly common unique case, and the
1183    /// stored field only where more than one file competes.
1184    fn field(&self, key: &str) -> Option<&[String]> {
1185        if let Some(field) = self.ambiguous.get(key) {
1186            return Some(field);
1187        }
1188        self.by_key.get(key).map(std::slice::from_ref)
1189    }
1190
1191    /// The number of distinct keys (normalised path suffixes) in the index.
1192    /// This is not the number of files.
1193    ///
1194    /// # Examples
1195    ///
1196    /// ```
1197    /// use markdown_link::LinkIndex;
1198    ///
1199    /// let index = LinkIndex::build(&["a/b.md".to_string()]);
1200    /// assert_eq!(index.len(), 2); // "b" and "a/b"
1201    /// ```
1202    pub fn len(&self) -> usize {
1203        self.by_key.len()
1204    }
1205
1206    /// Whether the index holds no keys.
1207    ///
1208    /// # Examples
1209    ///
1210    /// ```
1211    /// use markdown_link::LinkIndex;
1212    ///
1213    /// assert!(LinkIndex::build(&[]).is_empty());
1214    /// assert!(!LinkIndex::build(&["a.md".to_string()]).is_empty());
1215    /// ```
1216    pub fn is_empty(&self) -> bool {
1217        self.by_key.is_empty()
1218    }
1219}
1220
1221/// `a/b/c` -> `["c", "b/c", "a/b/c"]`.
1222///
1223/// Public for the same reason [`strip_md_ext`] and [`percent_decode`] are, and
1224/// for one specific kind of caller: one that needs the *candidate set* behind
1225/// a link target, which [`LinkIndex`] deliberately does not keep (it stores
1226/// one winner per key, which is all resolution needs and all a whole-vault
1227/// cache should pay for). A rename planner, for example, can build its own
1228/// key → candidates multimap to answer "is this bare name still unambiguous
1229/// after the move". It must key that map **exactly** the way
1230/// `LinkIndex::build` does or the two would disagree about what a link points
1231/// at. Sharing this function and [`normalize_target`] makes that impossible.
1232///
1233/// # Examples
1234///
1235/// ```
1236/// use markdown_link::suffixes;
1237///
1238/// assert_eq!(suffixes("a/b/c"), ["c", "b/c", "a/b/c"]);
1239/// assert_eq!(suffixes("c"), ["c"]);
1240/// ```
1241pub fn suffixes(path_no_ext: &str) -> Vec<String> {
1242    let segs: Vec<&str> = path_no_ext.split('/').collect();
1243    (0..segs.len()).rev().map(|i| segs[i..].join("/")).collect()
1244}
1245
1246// Unit struct that carries the README as doc-tests.
1247#[cfg(doctest)]
1248#[doc = include_str!("../README.md")]
1249struct ReadmeDoctests;
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254
1255    #[test]
1256    fn prefer_in_folder_matches_whole_folder_names() {
1257        let field = vec!["notes2/a.md".to_string(), "notes/a.md".to_string()];
1258        assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes/a.md"));
1259        assert_eq!(prefer_in_folder(&field, "Notes/x.md"), Some("notes/a.md"));
1260        let only_sibling = vec!["z/a.md".to_string(), "notes2/a.md".to_string()];
1261        assert_eq!(
1262            prefer_in_folder(&only_sibling, "notes/x.md"),
1263            Some("z/a.md")
1264        );
1265    }
1266
1267    #[test]
1268    fn parses_wikilinks_embeds_headings_and_aliases() {
1269        let links = body_links("See [[Target]] and ![[img.png]] and [[a#h|Alias]].");
1270        assert_eq!(
1271            links,
1272            vec![
1273                RawLink {
1274                    target: "Target".into(),
1275                    kind: LinkKind::Wikilink,
1276                    field: None
1277                },
1278                RawLink {
1279                    target: "img.png".into(),
1280                    kind: LinkKind::Embed,
1281                    field: None
1282                },
1283                RawLink {
1284                    target: "a".into(),
1285                    kind: LinkKind::Wikilink,
1286                    field: None
1287                },
1288            ]
1289        );
1290    }
1291
1292    #[test]
1293    fn body_links_come_back_in_the_order_they_appear() {
1294        let targets: Vec<_> = body_links("[a](x.md) [[y]] [b](z.md)")
1295            .into_iter()
1296            .map(|l| l.target)
1297            .collect();
1298        assert_eq!(targets, vec!["x", "y", "z"]);
1299    }
1300
1301    #[test]
1302    fn links_inside_code_are_not_harvested() {
1303        let body = "real [[Yes]]\n\n```\nfake [[No]]\n```\n\ninline `[[AlsoNo]]` done\n";
1304        let targets: Vec<_> = body_links(body).into_iter().map(|l| l.target).collect();
1305        assert_eq!(targets, vec!["Yes"]);
1306    }
1307
1308    #[test]
1309    fn relative_markdown_links_count_absolute_urls_do_not() {
1310        let links = body_links("[a](Notes/x.md) [b](https://example.com/y.md) [c](<sp ace.md>)");
1311        let targets: Vec<_> = links.into_iter().map(|l| l.target).collect();
1312        assert_eq!(targets, vec!["Notes/x"]);
1313    }
1314
1315    #[test]
1316    fn percent_encoded_markdown_links_are_decoded() {
1317        let links = body_links("[a](Notes/My%20Note.md)");
1318        assert_eq!(links[0].target, "Notes/My Note");
1319    }
1320
1321    #[test]
1322    fn target_range_matches_the_parsed_target() {
1323        for inner in [
1324            "Note",
1325            " Note |a",
1326            "Note\\|a",
1327            "Note \\|a",
1328            "Note#H\\|a",
1329            "Note\\\\|a",
1330            "Note\\",
1331            "a#b|c#d",
1332        ] {
1333            let range = target_range(inner).expect(inner);
1334            assert_eq!(
1335                &inner[range],
1336                parse_inner(inner, false).unwrap().target,
1337                "{inner}"
1338            );
1339        }
1340        assert_eq!(target_range("Note\\|a"), Some(0..4));
1341        assert_eq!(target_range("\\|a"), None);
1342        assert_eq!(target_range("#H"), None);
1343    }
1344
1345    #[test]
1346    fn empty_targets_are_dropped() {
1347        assert!(body_links("[[]] [[ ]] [[|alias]]").is_empty());
1348    }
1349
1350    #[test]
1351    fn frontmatter_links_are_tagged_with_their_key() {
1352        let fm =
1353            yaml_splice::parse::parse_mapping("rel: '[[Other]]'\nlist:\n  - '[[Two]]'\n").unwrap();
1354        let links = frontmatter_links(&fm);
1355        assert_eq!(links.len(), 2);
1356        assert_eq!(links[0].field.as_deref(), Some("rel"));
1357        assert_eq!(links[1].target, "Two");
1358        assert_eq!(links[1].field.as_deref(), Some("list"));
1359    }
1360
1361    #[test]
1362    fn frontmatter_paths_keep_values_with_an_extension() {
1363        let fm = yaml_splice::parse::parse_mapping(
1364            "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",
1365        )
1366        .unwrap();
1367        let links = frontmatter_paths(&fm);
1368        let got: Vec<(&str, &str)> = links
1369            .iter()
1370            .map(|l| (l.field.as_deref().unwrap(), l.target.as_str()))
1371            .collect();
1372        assert_eq!(got, vec![("cover", "img/a.png"), ("files", "docs/b.pdf")]);
1373        assert!(links.iter().all(|l| l.kind == LinkKind::Frontmatter));
1374    }
1375
1376    #[test]
1377    fn the_path_pre_test_matches_what_the_parse_keeps() {
1378        assert!(may_hold_frontmatter_paths("cover: img/a.png\n"));
1379        assert!(may_hold_frontmatter_paths("files:\n  - \"docs/b.pdf\"\n"));
1380        assert!(may_hold_frontmatter_paths("tags: [a, b.jpg]\n"));
1381        assert!(may_hold_frontmatter_paths("files: [a.png, notes]\n"));
1382        assert!(may_hold_frontmatter_paths("cover: img/a.png  # hero\n"));
1383        assert!(may_hold_frontmatter_paths("m: {cover: a.png, n: 1}\n"));
1384        assert!(!may_hold_frontmatter_paths("title: Hello\nn: 3\nv: 1.2\n"));
1385    }
1386
1387    #[test]
1388    fn display_text_replaces_links_with_their_alias_or_target() {
1389        assert_eq!(
1390            display_text("See [[Target|the thing]] and ![[img.png]] and [[Plain]]."),
1391            "See the thing and img.png and Plain."
1392        );
1393    }
1394
1395    #[test]
1396    fn suffix_index_resolves_every_path_suffix() {
1397        let idx = LinkIndex::build(&["notes/muscles/brachialis.md".to_string()]);
1398        for t in [
1399            "brachialis",
1400            "Muscles/brachialis",
1401            "notes/muscles/brachialis",
1402        ] {
1403            assert_eq!(
1404                idx.resolve(t),
1405                Some("notes/muscles/brachialis.md"),
1406                "for {t}"
1407            );
1408        }
1409    }
1410
1411    #[test]
1412    fn resolution_is_case_insensitive_and_extension_tolerant() {
1413        let idx = LinkIndex::build(&["Notes/Alpha.md".to_string(), "_att/photo.jpeg".to_string()]);
1414        assert_eq!(idx.resolve("alpha"), Some("Notes/Alpha.md"));
1415        assert_eq!(idx.resolve("ALPHA.md"), Some("Notes/Alpha.md"));
1416        assert_eq!(idx.resolve("photo.jpeg"), Some("_att/photo.jpeg"));
1417        // Attachments are addressed WITH their extension; the bare stem is
1418        // not a key.
1419        assert_eq!(idx.resolve("photo"), None);
1420    }
1421
1422    #[test]
1423    fn shortest_path_wins_a_collision() {
1424        let idx = LinkIndex::build(&[
1425            "Deep/Nested/Folder/note.md".to_string(),
1426            "note.md".to_string(),
1427        ]);
1428        assert_eq!(idx.resolve("note"), Some("note.md"));
1429        assert!(idx.collisions > 0);
1430        // The deep one is still reachable by a longer suffix.
1431        assert_eq!(
1432            idx.resolve("Folder/note"),
1433            Some("Deep/Nested/Folder/note.md")
1434        );
1435    }
1436
1437    #[test]
1438    fn a_pathed_miss_falls_back_to_the_basename() {
1439        // The collection-alias case: `muscle-groups/` is not a real folder.
1440        let idx = LinkIndex::build(&["notes/groups/glutes.md".to_string()]);
1441        assert_eq!(
1442            idx.resolve("muscle-groups/glutes"),
1443            Some("notes/groups/glutes.md")
1444        );
1445    }
1446
1447    #[test]
1448    fn an_unresolvable_target_is_none_not_a_panic() {
1449        let idx = LinkIndex::build(&["a.md".to_string()]);
1450        assert_eq!(idx.resolve("nothing/here"), None);
1451        assert_eq!(idx.resolve(""), None);
1452    }
1453
1454    #[test]
1455    fn backslashes_and_dot_slash_normalise() {
1456        let idx = LinkIndex::build(&["Notes/Sub/x.md".to_string()]);
1457        assert_eq!(idx.resolve("Notes\\Sub\\x"), Some("Notes/Sub/x.md"));
1458        assert_eq!(idx.resolve("./Notes/Sub/x.md"), Some("Notes/Sub/x.md"));
1459    }
1460
1461    #[test]
1462    fn unicode_targets_resolve() {
1463        let idx = LinkIndex::build(&["Заметки/Привет.md".to_string()]);
1464        assert_eq!(idx.resolve("привет"), Some("Заметки/Привет.md"));
1465    }
1466
1467    fn strict_index() -> LinkIndex {
1468        LinkIndex::build(&[
1469            "a.md".to_string(),
1470            "x/a.md".to_string(),
1471            "y/b.md".to_string(),
1472            "z/b.md".to_string(),
1473            "photo.jpg".to_string(),
1474        ])
1475    }
1476
1477    #[test]
1478    fn resolve_strict_takes_the_exact_path_over_a_deeper_namesake() {
1479        assert_eq!(strict_index().resolve_strict("a", "q/n.md"), Ok("a.md"));
1480        assert_eq!(strict_index().resolve_strict("x/a", "q/n.md"), Ok("x/a.md"));
1481    }
1482
1483    #[test]
1484    fn resolve_strict_refuses_a_contested_name_with_every_candidate() {
1485        assert_eq!(
1486            strict_index().resolve_strict("b", "n.md"),
1487            Err(ResolveError::Ambiguous(vec![
1488                "y/b.md".to_string(),
1489                "z/b.md".to_string()
1490            ]))
1491        );
1492        // A pathed miss falls back to the basename, strictly too.
1493        assert!(matches!(
1494            strict_index().resolve_strict("other/b", "n.md"),
1495            Err(ResolveError::Ambiguous(_))
1496        ));
1497        // Written next to one of them, the name is that one.
1498        assert_eq!(strict_index().resolve_strict("b", "z/n.md"), Ok("z/b.md"));
1499        assert_eq!(
1500            strict_index().resolve_strict("../y/b", "z/n.md"),
1501            Ok("y/b.md")
1502        );
1503    }
1504
1505    #[test]
1506    fn resolve_strict_never_reads_a_bare_name_as_an_attachment() {
1507        assert_eq!(
1508            strict_index().resolve_strict("photo", "n.md"),
1509            Err(ResolveError::NotFound)
1510        );
1511        assert_eq!(
1512            strict_index().resolve_strict("photo.jpg", "n.md"),
1513            Ok("photo.jpg")
1514        );
1515        assert_eq!(
1516            strict_index().resolve_strict("", "n.md"),
1517            Err(ResolveError::NotFound)
1518        );
1519    }
1520
1521    #[test]
1522    fn candidates_agree_with_resolve_from() {
1523        let index = strict_index();
1524        for (target, from) in [
1525            ("b", "n.md"),
1526            ("b", "z/n.md"),
1527            ("a", "x/n.md"),
1528            ("x", "n.md"),
1529        ] {
1530            assert_eq!(
1531                index.resolve_from(target, from),
1532                prefer_in_folder(index.candidates(target, from), from)
1533            );
1534        }
1535    }
1536}