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}