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}