prov_graph/link.rs
1//! Link text — the raw strings a relation field holds, the wikilinks embedded
2//! in body prose, and the path arithmetic around them.
3//!
4//! A link target as written in metadata is either a bare path or a
5//! markdown-style labeled link (`[Design](docs/design.md)`), and its path may be
6//! **relative** to the document (`notes/a.md`), **workspace-absolute** from the
7//! root (`/Blog/Blog.md`), or wrapped in Markdown **angle brackets** when it
8//! contains spaces (`</Creative Writing/index.md>`, `[Notes](</My Notes/x.md>)`).
9//! This is prov's *link-syntax layer* — the analogue of `fig`'s format
10//! layer: it recognizes the conventions a real workspace mixes and round-trips
11//! them on write (spaces re-acquire their brackets). A [`Wikilink`] is the
12//! body-text counterpart (`[[notes/a.md]]`, `[[colophon:ajp7eq|My file]]`).
13//! Everything here is *lexical*: no filesystem
14//! access, no symlink resolution, and no markdown-structure awareness (a `[[…]]`
15//! inside a code span is still scanned) — resolution and code-fence discipline
16//! belong to the traversal and validation layers, which can report what they
17//! find.
18
19use std::ops::Range;
20use std::path::{Component, Path, PathBuf};
21
22/// A parsed link string: an optional human label and the target it points at.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Link {
25 /// The display label, when written as `[label](target)` or `[[target|label]]`.
26 pub label: Option<String>,
27 /// The target exactly as written (a relative path, an `id:<id>` handle, or a
28 /// URL for overlay relations that point off-workspace).
29 pub target: String,
30 /// `true` when the scalar was written as an Obsidian wikilink
31 /// (`[[target]]` / `[[target|label]]`) rather than a markdown link or bare
32 /// target — preserved so [`render`](Link::render) round-trips the wrapper.
33 pub wikilink: bool,
34}
35
36impl Link {
37 /// Parse a raw link string. `[label](target)` yields both parts; anything
38 /// else is a bare target with no label. A target wrapped in Markdown angle
39 /// brackets (`<…>`, used when it contains spaces) is unwrapped *only* when
40 /// it appears as the URL portion of a successfully parsed `[label](<target>)`
41 /// — never when it wraps a bare, unlabeled value, which stays byte-literal
42 /// (diaryx reads a bare `<…>` as a literal path, angle brackets and all: see
43 /// [`parse_path_only`](Link::parse_path_only) and the `link_style` render
44 /// tests). A `[[target]]` / `[[target|label]]` Obsidian wikilink scalar is
45 /// also recognized here; use [`parse_path_only`](Link::parse_path_only) when
46 /// the caller's value is a frontmatter *path* field that must not
47 /// reinterpret a literal `[[…]]` string as a wikilink.
48 pub fn parse(raw: &str) -> Self {
49 Self::parse_impl(raw, true)
50 }
51
52 /// [`parse`](Link::parse), but never treats `"[[target]]"` as an Obsidian
53 /// wikilink — such a value is left exactly as written (a bare literal, or,
54 /// if it happens to also match `[label](target)`, a markdown link). This is
55 /// the opt-out a frontmatter *path* field needs: diaryx's own path-value
56 /// parser has no wikilink convention at all, so a workspace that stores a
57 /// literal `"[[…]]"`-shaped string in a path property (unusual, but legal
58 /// input data) must round-trip it untouched rather than have `parse`
59 /// silently reinterpret it as a link. Every other rule of `parse` —
60 /// `[label](target)` splitting, balanced parens, angle-bracket unwrapping of
61 /// a parsed URL — applies unchanged.
62 pub fn parse_path_only(raw: &str) -> Self {
63 Self::parse_impl(raw, false)
64 }
65
66 /// Shared implementation behind [`parse`](Link::parse) and
67 /// [`parse_path_only`](Link::parse_path_only); `wikilink` gates the
68 /// `[[target]]` recognition branch.
69 fn parse_impl(raw: &str, wikilink: bool) -> Self {
70 let raw = raw.trim();
71 // A wikilink scalar — `[[target]]` / `[[target|label]]` — the Obsidian
72 // wrapper permitted in metadata as well as body prose. Skipped entirely
73 // by `parse_path_only`.
74 if wikilink && let Some(inner) = raw.strip_prefix("[[").and_then(|r| r.strip_suffix("]]")) {
75 let (target, label) = match inner.split_once('|') {
76 Some((target, label)) => (target.trim(), Some(label.trim().to_string())),
77 None => (inner.trim(), None),
78 };
79 return Self {
80 label,
81 target: target.to_string(),
82 wikilink: true,
83 };
84 }
85 if let Some((label, target)) = split_markdown_link(raw) {
86 return Self {
87 label: Some(label),
88 target,
89 wikilink: false,
90 };
91 }
92 // Bare value: stored byte-literal, angle brackets and all. Unlike the
93 // markdown-link branch above, there is no URL position here to unwrap —
94 // a bare `<…>`-shaped string is data, not delimiters (see `parse`'s doc
95 // comment and the C2 regression tests).
96 Self {
97 label: None,
98 target: raw.to_string(),
99 wikilink: false,
100 }
101 }
102
103 /// Render back to a writable link string. A labeled link keeps its label and
104 /// wraps the URL in Markdown angle brackets when it holds a space or paren
105 /// (so `]` / `)` in the path cannot break parsing); a bare target is emitted
106 /// verbatim — brackets belong *inside* `[label](…)`, never around a bare
107 /// value (matching diaryx, which reads a bare `<…>` as a literal path).
108 pub fn render(&self) -> String {
109 match (&self.label, self.wikilink) {
110 (Some(label), true) => format!("[[{}|{label}]]", self.target),
111 (None, true) => format!("[[{}]]", self.target),
112 (Some(label), false) => format!("[{label}]({})", emit_target(&self.target)),
113 (None, false) => self.target.clone(),
114 }
115 }
116
117 /// This link with a different target, keeping the label and wrapper. The
118 /// rename path uses this so `[Design](old.md)` becomes `[Design](new.md)`,
119 /// never a bare `new.md`.
120 pub fn with_target(&self, target: impl Into<String>) -> Self {
121 Self {
122 label: self.label.clone(),
123 target: target.into(),
124 wikilink: self.wikilink,
125 }
126 }
127
128 /// This link with a different display label, keeping the target and wrapper.
129 /// The retitle path uses this so `[Old Title](id:abc)` becomes
130 /// `[New Title](id:abc)` when the target is renamed — the label follows the
131 /// title while the (id or path) target stays exactly as written.
132 pub fn with_label(&self, label: impl Into<String>) -> Self {
133 Self {
134 label: Some(label.into()),
135 target: self.target.clone(),
136 wikilink: self.wikilink,
137 }
138 }
139
140 /// `true` when the target points off-workspace (a URL or mail address)
141 /// rather than at a file — such links are never resolved against the
142 /// filesystem or rewritten by moves.
143 pub fn is_external(&self) -> bool {
144 self.target.contains("://") || self.target.starts_with("mailto:")
145 }
146
147 /// The **sub-document locator** this target carries — the text after a `#`,
148 /// naming a place *inside* a document rather than a document.
149 ///
150 /// A locator is carried, never resolved. prov strips it before resolving the
151 /// target and re-attaches it on rewrite, which is the same contract §4 gives
152 /// an external URL: recognized by syntax, never validated. What it *means*
153 /// is the workspace's business — a verse number in a chapter, a heading
154 /// slug, a line range — so a locator naming nothing is not a `check`
155 /// finding. That is the price of not teaching prov every document format's
156 /// internal address space.
157 ///
158 /// `None` for an external target (a URL's fragment belongs to the URL) and
159 /// for a target that is *only* a locator (`#3`), which stays byte-literal —
160 /// see [`split_locator`].
161 pub fn locator(&self) -> Option<&str> {
162 if self.is_external() {
163 return None;
164 }
165 split_locator(&self.target).1
166 }
167
168 /// This link's target with any [`locator`](Self::locator) removed — the part
169 /// that names a *document*, and so the only part that resolves.
170 pub fn addressed_target(&self) -> &str {
171 if self.is_external() {
172 return &self.target;
173 }
174 split_locator(&self.target).0
175 }
176
177 /// This link with its document part replaced, **preserving the locator**.
178 ///
179 /// The rewrite passes (rename, re-relativize, restyle) use this rather than
180 /// [`with_target`](Self::with_target), which sets the target verbatim: a
181 /// move changes where a document lives, never which part of it was pointed
182 /// at.
183 pub fn with_path(&self, path: impl Into<String>) -> Self {
184 self.with_target(join_locator(path, self.locator()))
185 }
186
187 /// What this link's `id:`-scheme target names — local, foreign, or
188 /// malformed. `None` when the target carries no id scheme at all (a path,
189 /// an alias, a URL).
190 ///
191 /// Any [`locator`](Self::locator) is stripped first, so `id:abc1234#2-3`
192 /// names the same document as `id:abc1234`.
193 pub fn id_ref(&self) -> Option<IdRef> {
194 strip_id_scheme(self.addressed_target()).map(parse_id_body)
195 }
196
197 /// The stable ID this link names, when the target uses the `id:<id>`
198 /// scheme (or the legacy `colophon:<id>` spelling) — the
199 /// location-independent alternative to a relative path. Such targets
200 /// resolve through the workspace's ID registry, never against the
201 /// filesystem, and are deliberately *not* rewritten by moves: staying valid
202 /// across moves is their entire point.
203 ///
204 /// **Local ids only.** A cross-workspace `id:<workspace>/<id>` yields
205 /// `None`, because the registry this would be resolved against is not the
206 /// one that issued the id — see [`id_ref`](Self::id_ref). Callers asking
207 /// "must a move leave this alone?" want [`is_path_target`](Self::is_path_target),
208 /// which covers every id form.
209 pub fn id_target(&self) -> Option<crate::identity::Id> {
210 match self.id_ref() {
211 Some(IdRef::Local(id)) => Some(id),
212 _ => None,
213 }
214 }
215
216 /// The workspace and id this link names, when it is a cross-workspace
217 /// reference (`id:<workspace>/<id>`).
218 pub fn foreign_target(&self) -> Option<(String, crate::identity::Id)> {
219 match self.id_ref() {
220 Some(IdRef::Foreign { workspace, id }) => Some((workspace, id)),
221 _ => None,
222 }
223 }
224
225 /// Whether this link's target is a **path** — the only kind that says where
226 /// its target lives, and so the only kind a move may rewrite.
227 ///
228 /// False for an external URL and for every `id:` reference alike (local,
229 /// foreign, and malformed): none of them encodes a location, so
230 /// re-relativizing one could only damage it. This is the predicate the
231 /// rename, re-relativize and restyle passes filter on.
232 pub fn is_path_target(&self) -> bool {
233 !self.is_external() && self.id_ref().is_none()
234 }
235}
236
237/// Try to split `raw` as a Markdown link `[label](target)` (or, when the URL
238/// holds a space or paren, `[label](<target>)`). Returns the label and the
239/// unwrapped target, or `None` when `raw` doesn't have this shape.
240///
241/// Ports diaryx_core's `link_parser::try_parse_markdown_link` byte-for-byte
242/// (see module doc comment) rather than re-deriving it, because its two
243/// corrected behaviors are exactly what C2/C3 need and diaryx's test suite is
244/// the ground truth for their edge cases:
245/// - The label is whatever sits between the first `[` and the first `]`
246/// immediately followed by `(` — *not* whatever precedes the last `)` in the
247/// whole string, so trailing prose after the link (`"[Title](/a.md) note"`)
248/// never gets swept into the target.
249/// - The target's closing paren is found by depth-counting (see
250/// [`find_closing_paren`]), so a target containing its own parens
251/// (`/file (1).md`, even nested) still closes at the right `)`; any text
252/// after that `)` is deliberately never inspected — tolerated, not merely
253/// permitted.
254/// - Angle brackets are only unwrapped here, on the URL of a link that already
255/// parsed as `[label](…)` — never on a bare value (that's C2; see
256/// [`Link::parse`]'s doc comment and `parse_impl`'s bare-value branch).
257fn split_markdown_link(raw: &str) -> Option<(String, String)> {
258 if !raw.starts_with('[') {
259 return None;
260 }
261 let close_bracket = raw.find(']')?;
262 if !raw[close_bracket..].starts_with("](") {
263 return None;
264 }
265 let label = raw[1..close_bracket].to_string();
266 let after = &raw[close_bracket + 2..];
267 let target = if let Some(inner) = after.strip_prefix('<') {
268 // `](<target>)`: the closing `>` must be immediately followed by `)` —
269 // otherwise this isn't really an angle-bracketed URL and the whole
270 // markdown-link parse fails (falls through to the bare branch).
271 let close_angle = inner.find('>')?;
272 if inner.get(close_angle + 1..close_angle + 2) != Some(")") {
273 return None;
274 }
275 inner[..close_angle].to_string()
276 } else {
277 let close_paren = find_closing_paren(after)?;
278 after[..close_paren].to_string()
279 };
280 Some((label, target))
281}
282
283/// Find the byte offset of the `)` that balances the *implicit* open paren at
284/// the start of a Markdown link URL — i.e. the first `)` encountered at
285/// nesting depth zero, treating every `(` as opening one more level. A target
286/// with no closing paren at all (an unterminated link) yields `None`.
287///
288/// This is the crux of the C3 fix: the old code demanded the link be the very
289/// end of the input (`raw.strip_suffix(')')` on the whole trimmed string), so
290/// `"[Title](/a.md) trailing junk"` fell through to a bare target holding the
291/// entire string. Scanning for the *matching* close paren instead — and never
292/// examining what follows it — makes the split correct both for a target
293/// containing its own balanced parens (`/file (1).md`, `/file (a (b)).md`) and
294/// for trailing prose after the link.
295fn find_closing_paren(s: &str) -> Option<usize> {
296 let mut depth = 0u32;
297 for (i, c) in s.char_indices() {
298 match c {
299 '(' => depth += 1,
300 ')' => {
301 if depth == 0 {
302 return Some(i);
303 }
304 depth -= 1;
305 }
306 _ => {}
307 }
308 }
309 None
310}
311
312/// The writable spelling of a Markdown-link URL: wrapped in angle brackets when
313/// it holds a space or parenthesis (which would otherwise break `[label](url)`),
314/// bare otherwise. For URLs *inside* `[label](…)` only.
315fn emit_target(target: &str) -> String {
316 if target.contains([' ', '(', ')']) {
317 format!("<{target}>")
318 } else {
319 target.to_string()
320 }
321}
322
323/// The write style for links a workspace authors — prov's analogue of
324/// diaryx's `LinkFormat`, and read from the same place: the `link_format` key in
325/// the root document's frontmatter (a fact declared *in* the workspace, not an
326/// app-private config). Every link prov writes (autofix today; create/rename
327/// in time) uses this, so a repair never introduces a foreign style.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
329pub enum LinkStyle {
330 /// `[Title](/path/file.md)` — workspace-absolute Markdown link. diaryx's
331 /// default, and the most portable/self-documenting.
332 #[default]
333 MarkdownRoot,
334 /// `[Title](../path/file.md)` — relative Markdown link.
335 MarkdownRelative,
336 /// `/path/file.md` — bare workspace-absolute path.
337 PlainRoot,
338 /// `../path/file.md` — bare relative path.
339 PlainRelative,
340}
341
342impl LinkStyle {
343 /// This style's notation (bracketed Markdown vs bare path) and path
344 /// resolution — the two orthogonal axes the config `references.notation` /
345 /// `references.path_style` keys expose (see [`Notation`] / [`PathStyle`]).
346 /// `LinkStyle` is the fused internal carrier; these split it back out.
347 pub fn axes(self) -> (Notation, PathStyle) {
348 match self {
349 Self::MarkdownRoot => (Notation::Markdown, PathStyle::Root),
350 Self::MarkdownRelative => (Notation::Markdown, PathStyle::Relative),
351 Self::PlainRoot => (Notation::Bare, PathStyle::Root),
352 Self::PlainRelative => (Notation::Bare, PathStyle::Relative),
353 }
354 }
355
356 /// The fused [`LinkStyle`] for a bracketed-vs-bare notation and a path
357 /// resolution. `Wikilink` has no bare/bracketed distinction, so it maps
358 /// through the Markdown family (only the path-text shape matters for it).
359 pub fn from_axes(notation: Notation, path_style: PathStyle) -> Self {
360 match (notation, path_style) {
361 (Notation::Markdown | Notation::Wikilink, PathStyle::Root) => Self::MarkdownRoot,
362 (Notation::Markdown | Notation::Wikilink, PathStyle::Relative) => {
363 Self::MarkdownRelative
364 }
365 (Notation::Bare, PathStyle::Root) => Self::PlainRoot,
366 (Notation::Bare, PathStyle::Relative) => Self::PlainRelative,
367 }
368 }
369}
370
371/// The syntactic form a reference is written in — the config-facing notation
372/// axis (`references.notation`), orthogonal to [`PathStyle`]. This is the clean
373/// split of what the internal [`Wrapper`] + `plain_`/`markdown_` [`LinkStyle`]
374/// prefix fused: `Bare` is a path with no brackets, `Markdown` is `[Title](…)`,
375/// `Wikilink` is `[[…]]`.
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
377pub enum Notation {
378 /// `[Title](target)`.
379 #[default]
380 Markdown,
381 /// `[[target]]` / `[[target|Title]]`.
382 Wikilink,
383 /// A bare `target`, no brackets — what the old `plain_*` link formats wrote.
384 Bare,
385}
386
387impl Notation {
388 /// Parse the `references.notation` config spelling; unknown → `None`.
389 pub fn from_config_str(value: &str) -> Option<Self> {
390 match value {
391 "markdown" => Some(Self::Markdown),
392 "wikilink" => Some(Self::Wikilink),
393 "bare" => Some(Self::Bare),
394 _ => None,
395 }
396 }
397
398 /// The `references.notation` config spelling.
399 pub fn as_config_str(self) -> &'static str {
400 match self {
401 Self::Markdown => "markdown",
402 Self::Wikilink => "wikilink",
403 Self::Bare => "bare",
404 }
405 }
406
407 /// The internal [`Wrapper`] this notation renders through. `Markdown` and
408 /// `Bare` share the Markdown wrapper (the bracket-vs-bare choice lives in the
409 /// path style); `Wikilink` is its own wrapper.
410 pub fn wrapper(self) -> Wrapper {
411 match self {
412 Self::Wikilink => Wrapper::Wikilink,
413 Self::Markdown | Self::Bare => Wrapper::Markdown,
414 }
415 }
416
417 /// Recover the notation from a fused [`Wrapper`] + [`LinkStyle`] — the inverse
418 /// direction, for serializing an internal style back to config.
419 pub fn from_wrapper(wrapper: Wrapper, style: LinkStyle) -> Self {
420 match wrapper {
421 Wrapper::Wikilink => Self::Wikilink,
422 Wrapper::Markdown => style.axes().0,
423 }
424 }
425}
426
427/// The path-resolution a reference uses for a path target — the config-facing
428/// `references.path_style` axis, orthogonal to [`Notation`]. Applies to path
429/// targets only (id/alias ignore it).
430///
431/// Two resolutions, and deliberately not three. A `canonical` style once emitted
432/// a *workspace*-relative path with no leading slash (`path/file.md`), which
433/// [`resolve`] reads as *directory*-relative — so a canonical link resolved to
434/// what it named only from a document at the workspace root, and silently
435/// misresolved everywhere else. The ambiguity of a bare path is settled by
436/// committing to one meaning rather than by tagging it: **bare is
437/// directory-relative**, and a workspace-relative reference is spelled
438/// [`Root`](Self::Root), with the slash that says so. `prov convert <root>
439/// link_format markdown_root -r` restyles a workspace that used the old value.
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
441pub enum PathStyle {
442 /// Workspace-absolute: `/path/file.md`.
443 #[default]
444 Root,
445 /// Relative to the referring document: `../file.md`.
446 Relative,
447}
448
449impl PathStyle {
450 /// Parse the `references.path_style` config spelling; unknown → `None`.
451 pub fn from_config_str(value: &str) -> Option<Self> {
452 match value {
453 "root" => Some(Self::Root),
454 "relative" => Some(Self::Relative),
455 _ => None,
456 }
457 }
458
459 /// The `references.path_style` config spelling.
460 pub fn as_config_str(self) -> &'static str {
461 match self {
462 Self::Root => "root",
463 Self::Relative => "relative",
464 }
465 }
466}
467
468/// Format a link to `target` (a workspace-relative canonical path) as written in
469/// the document at `from`, in `style`, with `title` (used only by the Markdown
470/// styles). This is what keeps an authored link native to the workspace.
471pub fn format_link(style: LinkStyle, from: &Path, target: &Path, title: &str) -> String {
472 let canonical = target.to_string_lossy();
473 match style {
474 LinkStyle::MarkdownRoot => {
475 format!("[{title}]({})", emit_target(&format!("/{canonical}")))
476 }
477 LinkStyle::MarkdownRelative => {
478 let rel = relative(from.parent().unwrap_or(Path::new("")), target);
479 format!("[{title}]({})", emit_target(&rel))
480 }
481 LinkStyle::PlainRoot => format!("/{canonical}"),
482 LinkStyle::PlainRelative => relative(from.parent().unwrap_or(Path::new("")), target),
483 }
484}
485
486/// A human title generated from a path's file stem: `_`/`-` become spaces and
487/// each word is capitalized (`utility_index.md` → `Utility Index`). The fallback
488/// when a target document declares no `title`.
489pub fn path_to_title(path: &Path) -> String {
490 let stem = path
491 .file_stem()
492 .and_then(|s| s.to_str())
493 .unwrap_or_default();
494 stem.split(['_', '-', ' '])
495 .filter(|w| !w.is_empty())
496 .map(|word| {
497 let mut chars = word.chars();
498 match chars.next() {
499 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
500 None => String::new(),
501 }
502 })
503 .collect::<Vec<_>>()
504 .join(" ")
505}
506
507/// Turn a human title into a filesystem-friendly filename stem — the readable
508/// slug prov derives when a document is created by title (`prov new "My
509/// Great Note"` → `my-great-note`). The rough inverse of
510/// [`path_to_title`]: lowercase, runs of whitespace and separators
511/// (space / `-` / `_` / `/`) collapsed to a single `-`, and any other
512/// punctuation dropped. Unicode letters and digits are kept, so a non-ASCII
513/// title still yields a legible name. A title with no slug-able characters (pure
514/// punctuation) falls back to `"untitled"` so the result is always a valid stem.
515///
516/// The point is prov's legibility contract (DESIGN §1): a title-first
517/// authoring flow that still leaves *readable paths* in the tree and in
518/// path-addressed links — unlike an opaque `note-3.md`.
519pub fn slug(title: &str) -> String {
520 let mut out = String::with_capacity(title.len());
521 let mut pending_dash = false;
522 for ch in title.chars() {
523 if ch.is_alphanumeric() {
524 if pending_dash {
525 out.push('-');
526 pending_dash = false;
527 }
528 out.extend(ch.to_lowercase());
529 } else if ch.is_whitespace() || matches!(ch, '-' | '_' | '/') {
530 // Defer the separator: only emitted if a kept character follows, so
531 // leading, trailing, and repeated separators never reach `out`.
532 pending_dash = !out.is_empty();
533 }
534 // Any other character (punctuation, symbols) is dropped.
535 }
536 if out.is_empty() {
537 "untitled".to_string()
538 } else {
539 out
540 }
541}
542
543/// The character that begins a [sub-document locator](Link::locator) on a link
544/// target.
545pub const LOCATOR_SEPARATOR: char = '#';
546
547/// Split a target into the part naming a document and its locator.
548///
549/// Splits at the **first** separator, so a locator may itself contain one. Two
550/// targets deliberately have no locator:
551///
552/// - one with no `#` at all — the ordinary case;
553/// - one whose `#` is the *first* character (`#3`). That is a same-document
554/// reference, and reading it as a locator on an empty path would resolve the
555/// link to the containing *directory* — a silent retarget where doing nothing
556/// is correct.
557///
558/// Purely syntactic: callers that must not split an external URL's fragment
559/// guard with [`Link::is_external`] first, as [`Link::locator`] does.
560///
561/// The cost of the convention is a document whose *filename* contains `#`,
562/// which can no longer be linked by path. That is the same trade every URL and
563/// Markdown implementation makes, and the character is rare in filenames where
564/// a locator is not.
565pub fn split_locator(target: &str) -> (&str, Option<&str>) {
566 match target.split_once(LOCATOR_SEPARATOR) {
567 Some((doc, locator)) if !doc.is_empty() => (doc, Some(locator)),
568 _ => (target, None),
569 }
570}
571
572/// Re-attach a locator to a document target — the inverse of [`split_locator`].
573/// `None` returns the target unchanged, so this is safe on a target that never
574/// had one.
575pub fn join_locator(target: impl Into<String>, locator: Option<&str>) -> String {
576 let mut target = target.into();
577 if let Some(locator) = locator {
578 target.push(LOCATOR_SEPARATOR);
579 target.push_str(locator);
580 }
581 target
582}
583
584/// The target scheme marking a link-by-ID: `id:<id>`.
585pub const ID_SCHEME: &str = "id:";
586
587/// The legacy scheme (`colophon:<id>`), still recognized on read so existing
588/// workspaces keep resolving. New links are authored with [`ID_SCHEME`].
589pub const LEGACY_ID_SCHEME: &str = "colophon:";
590
591/// Strip the ID scheme from a target, accepting the current `id:` spelling or
592/// the legacy `colophon:` one. `None` when the target names no ID.
593pub fn strip_id_scheme(target: &str) -> Option<&str> {
594 target
595 .strip_prefix(ID_SCHEME)
596 .or_else(|| target.strip_prefix(LEGACY_ID_SCHEME))
597}
598
599/// Render an ID as a link target (`id:<id>`).
600pub fn id_target(id: &crate::identity::Id) -> String {
601 format!("{ID_SCHEME}{id}")
602}
603
604/// The character separating the workspace qualifier from the id in a
605/// cross-workspace reference (`id:<workspace>/<id>`).
606pub const WORKSPACE_SEPARATOR: char = '/';
607
608/// Render a cross-workspace reference as a link target (`id:<workspace>/<id>`).
609pub fn foreign_id_target(workspace: &str, id: &crate::identity::Id) -> String {
610 format!("{ID_SCHEME}{workspace}{WORKSPACE_SEPARATOR}{id}")
611}
612
613/// Whether `name` is a usable workspace self-name.
614///
615/// The constraint comes entirely from where the name is *written*: it is the
616/// qualifier in an `id:<workspace>/<id>` target, so it may not contain the
617/// [`WORKSPACE_SEPARATOR`] that divides it from the id, the `:` that ends the
618/// scheme, or whitespace (a target is a single scalar; a space would make it
619/// two). Anything else is the user's business — this is a name for humans to
620/// *choose*. `prov_identity::mint_workspace_id` (reached by `prov id
621/// --workspace`) can mint an opaque one for an owner with no naming authority
622/// to lean on, but only when asked: a minted name satisfies this predicate like
623/// any other, and nothing here can tell the two apart.
624///
625/// It lives beside the grammar it is a constraint on rather than in
626/// `prov-config` with the [`workspace_id`] key, because it is not a policy
627/// choice — every clause above is dictated by how [`IdRef`] parses, and a config
628/// layer that spelled its own version of this could drift from the parser that
629/// decides what a reference actually means.
630///
631/// Deliberately *not* checked: uniqueness across workspaces. Nothing here can
632/// see another workspace, so a collision is undetectable from inside; it is the
633/// resolving host's problem, and the host is the only thing with the evidence to
634/// notice ([`crate::peer`]). A minted name buys its uniqueness with width
635/// instead — the only currency available to something that cannot check.
636///
637/// [`workspace_id`]: crate::graph::ReadSettings::workspace_id
638pub fn is_valid_workspace_id(name: &str) -> bool {
639 !name.is_empty()
640 && !name
641 .chars()
642 .any(|c| c == WORKSPACE_SEPARATOR || c == ':' || c.is_whitespace())
643}
644
645/// What an `id:`-scheme target names.
646///
647/// The scheme carries three distinguishable things, and keeping them apart is
648/// the whole point: a reference prov can resolve, one it deliberately cannot,
649/// and one that is broken. See `docs/reference-styles.md`.
650#[derive(Debug, Clone, PartialEq, Eq)]
651pub enum IdRef {
652 /// `id:<id>` — a document in *this* workspace, resolved through the registry.
653 Local(crate::identity::Id),
654 /// `id:<workspace>/<id>` — a document in the workspace named `workspace`.
655 ///
656 /// prov resolves this only when `workspace` is the reading workspace's own
657 /// [`workspace_id`](crate::graph::ReadSettings::workspace_id), in which case it
658 /// *is* local and is treated as such. Any other name is somewhere prov
659 /// cannot see: the library holds no map from a workspace name to a location
660 /// (that is a fact about a device, not about an archive), so the reference is
661 /// carried, never rewritten, and never reported broken.
662 Foreign {
663 /// The workspace qualifier, exactly as written.
664 workspace: String,
665 /// The id, exactly as written. **Not** check-verified: the foreign
666 /// workspace owns its id space and need not be a prov workspace at all
667 /// (a diaryx ARK blade is a different length and alphabet), so applying
668 /// prov's check character here would reject valid references.
669 id: crate::identity::Id,
670 },
671 /// The `id:` scheme with a body that is no reference at all — an empty half
672 /// (`id:`, `id:/x`, `id:ws/`) or more than one separator (`id:a/b/c`).
673 ///
674 /// Deliberately its own case rather than falling through to a path: the
675 /// author wrote `id:`, so silently resolving the text as a filename would
676 /// turn a typo into a dangling path and hide what actually went wrong.
677 Malformed,
678}
679
680/// Parse the body of an `id:`-scheme target (the text after the scheme).
681fn parse_id_body(body: &str) -> IdRef {
682 let Some((workspace, id)) = body.split_once(WORKSPACE_SEPARATOR) else {
683 return if body.is_empty() {
684 IdRef::Malformed
685 } else {
686 IdRef::Local(crate::identity::Id(body.to_string()))
687 };
688 };
689 if workspace.is_empty() || id.is_empty() || id.contains(WORKSPACE_SEPARATOR) {
690 return IdRef::Malformed;
691 }
692 IdRef::Foreign {
693 workspace: workspace.to_string(),
694 id: crate::identity::Id(id.to_string()),
695 }
696}
697
698/// The syntactic wrapper a reference is written in — the first of the two style
699/// axes (see `docs/reference-styles.md`).
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
701pub enum Wrapper {
702 /// The diaryx/markdown family: `[Title](target)` or a bare target, with the
703 /// exact path rendering governed by [`ReferenceStyle::path_style`].
704 #[default]
705 Markdown,
706 /// The Obsidian wikilink: `[[target]]` / `[[target|label]]`.
707 Wikilink,
708}
709
710impl Wrapper {
711 /// Parse the `reference_wrapper` config spelling; unknown → `None`.
712 pub fn from_config_str(value: &str) -> Option<Self> {
713 match value {
714 "markdown" => Some(Self::Markdown),
715 "wikilink" => Some(Self::Wikilink),
716 _ => None,
717 }
718 }
719
720 /// The `reference_wrapper` config spelling.
721 pub fn as_config_str(self) -> &'static str {
722 match self {
723 Self::Markdown => "markdown",
724 Self::Wikilink => "wikilink",
725 }
726 }
727}
728
729/// What a reference addresses its target *by* — the second style axis.
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
731pub enum Addressing {
732 /// By path — rewritten on every move; rendering follows
733 /// [`ReferenceStyle::path_style`].
734 #[default]
735 Path,
736 /// By durable `id:<id>` handle — move-stable; authoring one registers the
737 /// target (the link-by-id trigger).
738 Id,
739 /// By the target's title/name, resolved nominally through the title index —
740 /// readable but not move/rename-safe, and never registers. Implies
741 /// [`Wrapper::Wikilink`].
742 Alias,
743}
744
745impl Addressing {
746 /// Parse the `reference_target` config spelling; unknown → `None`.
747 pub fn from_config_str(value: &str) -> Option<Self> {
748 match value {
749 "path" => Some(Self::Path),
750 "id" => Some(Self::Id),
751 "alias" => Some(Self::Alias),
752 _ => None,
753 }
754 }
755
756 /// The `reference_target` config spelling.
757 pub fn as_config_str(self) -> &'static str {
758 match self {
759 Self::Path => "path",
760 Self::Id => "id",
761 Self::Alias => "alias",
762 }
763 }
764}
765
766/// How a durable reference is spelled: a [`Wrapper`], an [`Addressing`], whether
767/// an `id` link carries a title label, and the path rendering used when
768/// addressing by path. This is the per-workspace default *and* the per-relation
769/// override (see [`crate::relation::Relation::style`]).
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
771pub struct ReferenceStyle {
772 /// The syntactic wrapper.
773 pub wrapper: Wrapper,
774 /// What the reference addresses its target by.
775 pub addressing: Addressing,
776 /// Whether an `id` wikilink carries a `|Title` label (a maintained cache of
777 /// the target's title). Ignored for markdown (its `[Title]` is intrinsic)
778 /// and for `alias` (the target string *is* the title).
779 pub label: bool,
780 /// Path rendering for [`Addressing::Path`] — ignored otherwise.
781 pub path_style: LinkStyle,
782}
783
784impl Default for ReferenceStyle {
785 /// Markdown path links in the default [`LinkStyle`] — the pre-existing
786 /// behavior, so an unconfigured workspace is unchanged.
787 fn default() -> Self {
788 Self {
789 wrapper: Wrapper::Markdown,
790 addressing: Addressing::Path,
791 label: false,
792 path_style: LinkStyle::default(),
793 }
794 }
795}
796
797impl ReferenceStyle {
798 /// Normalize impossible combinations: `alias` has no markdown spelling (there
799 /// is no locator to put in `[Title](…)`), so a markdown+alias request becomes
800 /// wikilink+alias.
801 pub fn normalized(mut self) -> Self {
802 if self.addressing == Addressing::Alias {
803 self.wrapper = Wrapper::Wikilink;
804 }
805 self
806 }
807
808 /// Whether authoring in this style is a *link-by-id* — the trigger that
809 /// registers the target. Only `id` addressing registers.
810 pub fn registers(self) -> bool {
811 self.addressing == Addressing::Id
812 }
813}
814
815/// Render a durable reference from the document at `from` to `to` (titled
816/// `title`) in `style`. `id` must be `Some` when the style addresses by id
817/// (the caller registers the target first); it is ignored otherwise. Returns the
818/// exact scalar to store in metadata (a wikilink scalar keeps its `[[…]]` — the
819/// metadata writer is responsible for any format-level quoting).
820pub fn format_reference(
821 style: ReferenceStyle,
822 from: &Path,
823 to: &Path,
824 id: Option<&crate::identity::Id>,
825 title: &str,
826) -> String {
827 let style = style.normalized();
828 match style.addressing {
829 // No id available (identity off / does not register on link) degrades to
830 // a path link, mirroring the pre-existing `authored_target` fallback.
831 Addressing::Id => match id {
832 Some(id) => wrap(style.wrapper, &id_target(id), title, style.label),
833 None => format_path(
834 Wrapper::Markdown,
835 style.path_style,
836 from,
837 to,
838 title,
839 style.label,
840 ),
841 },
842 Addressing::Alias => wrap_alias(title),
843 Addressing::Path => format_path(
844 style.wrapper,
845 style.path_style,
846 from,
847 to,
848 title,
849 style.label,
850 ),
851 }
852}
853
854/// Render a path reference in `wrapper` at `path_style`.
855fn format_path(
856 wrapper: Wrapper,
857 path_style: LinkStyle,
858 from: &Path,
859 to: &Path,
860 title: &str,
861 label: bool,
862) -> String {
863 match wrapper {
864 // Preserve the exact markdown/plain behavior (labeled vs bare) that
865 // `LinkStyle` already encodes — the `label` axis does not apply here.
866 Wrapper::Markdown => format_link(path_style, from, to, title),
867 Wrapper::Wikilink => wrap(
868 Wrapper::Wikilink,
869 &path_text(path_style, from, to),
870 title,
871 label,
872 ),
873 }
874}
875
876/// The bare path *text* a path reference points at, in the shape `path_style`
877/// selects: workspace-absolute (`/canonical`), relative, or canonical.
878pub fn path_text(path_style: LinkStyle, from: &Path, to: &Path) -> String {
879 match path_style {
880 LinkStyle::MarkdownRoot | LinkStyle::PlainRoot => format!("/{}", to.to_string_lossy()),
881 LinkStyle::MarkdownRelative | LinkStyle::PlainRelative => {
882 relative(from.parent().unwrap_or(Path::new("")), to)
883 }
884 }
885}
886
887/// Wrap a resolved `target` (already scheme-/path-formatted) in `wrapper`,
888/// attaching `title` as a label when `with_label`. A markdown reference without
889/// a label is emitted bare (`id:xxx`) — the diaryx-shaped id link.
890fn wrap(wrapper: Wrapper, target: &str, title: &str, with_label: bool) -> String {
891 match (wrapper, with_label) {
892 (Wrapper::Wikilink, true) => format!("[[{target}|{title}]]"),
893 (Wrapper::Wikilink, false) => format!("[[{target}]]"),
894 (Wrapper::Markdown, true) => format!("[{title}]({})", emit_target(target)),
895 (Wrapper::Markdown, false) => emit_target(target),
896 }
897}
898
899/// An alias reference: the title itself, as a bare-name wikilink.
900fn wrap_alias(title: &str) -> String {
901 format!("[[{title}]]")
902}
903
904/// A wikilink embedded in a document's body: `[[target]]` or, with an Obsidian
905/// pipe label, `[[target|label]]`.
906///
907/// This is the body-text sibling of a metadata [`Link`]. The `target` is either
908/// a path (`[[notes/a.md]]`, the identity-free Diaryx-style link that moves
909/// rewrite) or a `colophon:<id>` reference (`[[colophon:ajp7eq]]`, the
910/// location-independent Obsidian-style link that moves leave alone — the
911/// registry update is its maintenance). Which one a workspace mints is a policy
912/// choice; discovering the span is not, so the scanner is neutral between them.
913///
914/// [`span`](Wikilink::span) is the byte range of the whole `[[…]]` construct in
915/// the source body — exactly what a rewrite replaces, so a path retarget can
916/// splice a new target back in without re-parsing the prose around it.
917#[derive(Debug, Clone, PartialEq, Eq)]
918pub struct Wikilink {
919 /// The target as written between `[[` and the `|` (or the closing `]]`),
920 /// trimmed of surrounding whitespace.
921 pub target: String,
922 /// The display label after `|`, when written `[[target|label]]`.
923 pub label: Option<String>,
924 /// Byte range of the entire `[[…]]` span within the scanned body.
925 pub span: Range<usize>,
926}
927
928impl Wikilink {
929 /// Build a wikilink from the raw inner text (between `[[` and `]]`) and its
930 /// span. Splits an Obsidian `target|label` on the first `|`; an empty target
931 /// (e.g. `[[]]` or `[[ | x ]]`) is not a link and yields `None`.
932 fn from_inner(inner: &str, span: Range<usize>) -> Option<Self> {
933 let (target, label) = match inner.split_once('|') {
934 Some((target, label)) => (target.trim(), Some(label.trim().to_string())),
935 None => (inner.trim(), None),
936 };
937 if target.is_empty() {
938 return None;
939 }
940 Some(Self {
941 target: target.to_string(),
942 label,
943 span,
944 })
945 }
946
947 /// Render back to `[[target]]` / `[[target|label]]`. Surrounding whitespace
948 /// inside the brackets is not preserved — the rendered form is canonical.
949 pub fn render(&self) -> String {
950 match &self.label {
951 Some(label) => format!("[[{}|{label}]]", self.target),
952 None => format!("[[{}]]", self.target),
953 }
954 }
955
956 /// This wikilink with a different target, keeping the label — the move path
957 /// uses it to rewrite a *path* target while leaving the display text intact.
958 /// (ID targets are never rewritten; that is the whole point of using one.)
959 pub fn with_target(&self, target: impl Into<String>) -> Self {
960 Self {
961 target: target.into(),
962 label: self.label.clone(),
963 span: self.span.clone(),
964 }
965 }
966
967 /// The stable ID this wikilink names, when its target uses the `id:<id>`
968 /// scheme (or the legacy `colophon:<id>` spelling) — `None` for a plain path
969 /// target. Mirrors [`Link::id_target`], including its locator handling:
970 /// `[[id:abc1234#2]]` names the document `abc1234`.
971 pub fn id_target(&self) -> Option<crate::identity::Id> {
972 strip_id_scheme(split_locator(&self.target).0).map(|id| crate::identity::Id(id.to_string()))
973 }
974}
975
976/// Scan body prose for every `[[…]]` wikilink, in source order, each carrying
977/// its byte span. Purely lexical: unclosed `[[` is ignored, the first following
978/// `]]` closes the span, and no markdown structure (code spans, escapes) is
979/// interpreted — a higher layer decides whether a match in a code fence counts.
980pub fn parse_wikilinks(body: &str) -> Vec<Wikilink> {
981 let mut out = Vec::new();
982 let mut base = 0; // byte offset of `rest` within `body`
983 let mut rest = body;
984 while let Some(open_rel) = rest.find("[[") {
985 let open = base + open_rel;
986 let after_open = open + 2;
987 let Some(close_rel) = body[after_open..].find("]]") else {
988 break; // no closing delimiter anywhere ahead — nothing more to find
989 };
990 let close = after_open + close_rel;
991 if let Some(link) = Wikilink::from_inner(&body[after_open..close], open..close + 2) {
992 out.push(link);
993 }
994 base = close + 2;
995 rest = &body[base..];
996 }
997 out
998}
999
1000/// Keep only the wikilinks in `links` whose span does not overlap any of
1001/// `code_spans` — the code-awareness DESIGN §8 asks for: a `[[…]]` that is
1002/// really code (inside a fenced/inline code span) must never be treated as a
1003/// link.
1004///
1005/// **Caveat:** this only helps for a `links` list in which every real
1006/// wikilink was already found as its own match. It cannot rescue a real
1007/// `[[…]]` that [`parse_wikilinks`]' greedy "next `]]` wins" scan has already
1008/// merged into one bogus match together with an unrelated `[[` earlier in
1009/// the same code span — by the time that happens, the real link was never
1010/// emitted as a separate [`Wikilink`] to keep. [`scan_wikilinks`] avoids the
1011/// problem at the source (it never lets a lexical scan cross a code span in
1012/// the first place) and is what `census`/`check`/rename actually use; reach
1013/// for this function only when you already have a trustworthy `Vec<Wikilink>`
1014/// (e.g. from a segment [`scan_wikilinks`] itself produced) and just need the
1015/// range check.
1016pub fn exclude_code_spans(links: Vec<Wikilink>, code_spans: &[Range<usize>]) -> Vec<Wikilink> {
1017 links
1018 .into_iter()
1019 .filter(|link| {
1020 !code_spans
1021 .iter()
1022 .any(|cs| cs.start < link.span.end && link.span.start < cs.end)
1023 })
1024 .collect()
1025}
1026
1027/// Scan `body` for wikilinks the way `census`/`check`/the rename machinery
1028/// actually should — never [`parse_wikilinks`] directly. When `path`'s
1029/// extension names a format `twig` understands, every code span (fenced/inline
1030/// code, raw escapes) is treated as opaque *before* the lexical `[[`…`]]` scan
1031/// ever sees it: each prose run between code spans is scanned on its own and the
1032/// results stitched back into `body`-relative spans. For an unrecognized
1033/// extension — or if the parse fails — this is exactly [`parse_wikilinks`] over
1034/// the whole body, the same behavior as before code-awareness existed.
1035///
1036/// The span is **lexical either way**: twig has no wikilink concept, so it
1037/// supplies the mask and nothing more. A caller about to *rewrite* what a span
1038/// covers wants [`parsed_link_spans`] instead, which reports only spans twig
1039/// itself identified as links.
1040///
1041/// Scanning prose runs *separately*, rather than scanning the whole body and
1042/// filtering the results (what [`exclude_code_spans`] alone can do), matters:
1043/// [`parse_wikilinks`]' greedy scan finds each `[[` a *later* `]]`, code or
1044/// not, closes — so one stray `[[` in a code block (a Python
1045/// `[[float('inf')] * width ...]`, DESIGN §8's motivating example, life-sized)
1046/// can eat every `]]` after it, including a real `[[gone.md]]` further down
1047/// the body, merging them into one bogus match that swallows the real link
1048/// whole. No post-hoc filter can get that link back — it was never emitted
1049/// as its own match. Keeping code spans out of the scan in the first place
1050/// is the only fix; this function is that fix.
1051pub fn scan_wikilinks(path: &Path, body: &str) -> Vec<Wikilink> {
1052 match code_spans_for(path, body) {
1053 Some(spans) if !spans.is_empty() => scan_outside_spans(body, &merge_spans(spans)),
1054 _ => parse_wikilinks(body),
1055 }
1056}
1057
1058/// One link found in body prose: the parsed [`Link`] (target, label, and whether
1059/// it was an Obsidian `[[…]]` wikilink or a markdown/djot `[label](target)`
1060/// link) together with the byte [`span`](BodyLink::span) of the whole construct
1061/// — exactly what a rewrite replaces. The unifying body-link currency: census,
1062/// `check`, and the rename machinery all consume this, blind to which syntax the
1063/// link was written in.
1064#[derive(Debug, Clone, PartialEq, Eq)]
1065pub struct BodyLink {
1066 /// The parsed link — [`render`](Link::render) reproduces its original
1067 /// wrapper, so a retargeted `[[a]]` stays a wikilink and a `[t](a)` stays a
1068 /// markdown link.
1069 pub link: Link,
1070 /// Byte range of the whole link construct within the scanned body.
1071 pub span: Range<usize>,
1072}
1073
1074impl BodyLink {
1075 /// The stable ID this link names, if any — [`Link::id_target`] on the inner
1076 /// link. ID targets are never rewritten by a move.
1077 pub fn id_target(&self) -> Option<crate::identity::Id> {
1078 self.link.id_target()
1079 }
1080
1081 /// Whether this link's target is a path — [`Link::is_path_target`] on the
1082 /// inner link. The predicate the body-rewrite passes filter on.
1083 pub fn is_path_target(&self) -> bool {
1084 self.link.is_path_target()
1085 }
1086}
1087
1088/// Scan `body` for **every** link a move or a check must account for — Obsidian
1089/// `[[…]]` wikilinks *and* markdown/djot `[label](target)` links — each as a
1090/// [`BodyLink`] in source order. This is the single body-scan seam
1091/// `census`/`check`/rename use; it supersedes the wikilink-only
1092/// [`scan_wikilinks`] for callers that must also see markdown/djot links.
1093///
1094/// Two syntaxes, two finders, both code-aware:
1095/// - **Wikilinks** come from the lexical [`scan_wikilinks`] scan (code spans
1096/// excluded at the source, so a `[[` inside a fence can never eat a later real
1097/// link).
1098/// - **Markdown/djot links** come from `twig`'s parser
1099/// ([`crate::content::link_spans`]): it reports the span of each real `link`
1100/// node, so a `[x](y)` in a code fence, an autolink, or bracket text that is
1101/// not a link is never returned. Each span holds exactly one link, so parsing
1102/// it with [`Link::parse`] cannot over-reach across a stray `)` — the
1103/// balanced-paren hazard the lexical parser has is structurally absent here.
1104/// Only inline `[label](target)` links are kept (a successful markdown parse);
1105/// reference-style and autolink forms are left for a later pass.
1106///
1107/// Falls back to wikilinks only when the extension names no `twig` grammar or the
1108/// parse fails — the same graceful degradation [`scan_wikilinks`] already has.
1109pub fn scan_body_links(path: &Path, body: &str) -> Vec<BodyLink> {
1110 let mut out: Vec<BodyLink> = scan_wikilinks(path, body)
1111 .into_iter()
1112 .map(|wl| BodyLink {
1113 link: Link {
1114 label: wl.label,
1115 target: wl.target,
1116 wikilink: true,
1117 },
1118 span: wl.span,
1119 })
1120 .collect();
1121 for span in parsed_link_spans(path, body) {
1122 let link = Link::parse(&body[span.clone()]);
1123 // Keep only inline `[label](target)` links (a labeled markdown parse):
1124 // reference/autolink spans parse to a bare or external target and are
1125 // skipped. Defensively drop a span overlapping a wikilink we already have.
1126 if link.label.is_none() || link.wikilink {
1127 continue;
1128 }
1129 if out
1130 .iter()
1131 .any(|b| b.span.start < span.end && span.start < b.span.end)
1132 {
1133 continue;
1134 }
1135 out.push(BodyLink { link, span });
1136 }
1137 out.sort_by_key(|b| b.span.start);
1138 out
1139}
1140
1141/// The spans of markdown/djot inline links in `body`, via `twig` — empty when
1142/// `path`'s extension names no grammar `twig` understands or the parse fails
1143/// (the same degrade-to-lexical rule as `code_spans_for`).
1144///
1145/// **The "twig says it is a link" predicate.** These spans come from twig's own
1146/// `link` nodes, so a span here is a link an actual parser recognized, and each
1147/// holds exactly one link. That is a materially stronger claim than
1148/// [`scan_wikilinks`] can make about a `[[…]]`: twig has no wikilink concept, so
1149/// it only supplies the *code mask* there and the span itself is still lexical.
1150/// The gap matters wherever a repair is going to *write* — a lexical span may sit
1151/// in prose that merely looks like a link, and DESIGN §8's whole objection to
1152/// editing body prose is that `[[float('inf')] * width]` must never be
1153/// "repaired". So `validate`'s body-link remedies are offered for a span in this
1154/// set and no other.
1155pub fn parsed_link_spans(path: &Path, body: &str) -> Vec<Range<usize>> {
1156 let Some(format) = crate::content::ContentFormat::from_extension(path) else {
1157 return Vec::new();
1158 };
1159 crate::content::link_spans(body, format).unwrap_or_default()
1160}
1161
1162/// Sort-then-merge overlapping/adjacent ranges. `code_spans_for`'s sources
1163/// don't currently nest or overlap (code-block/verbatim/raw nodes are AST
1164/// leaves), but merging first keeps [`scan_outside_spans`] correct even if
1165/// that ever changes, and collapses touching spans into one gap-free skip.
1166fn merge_spans(mut spans: Vec<Range<usize>>) -> Vec<Range<usize>> {
1167 spans.sort_by_key(|s| s.start);
1168 let mut out: Vec<Range<usize>> = Vec::with_capacity(spans.len());
1169 for span in spans {
1170 match out.last_mut() {
1171 Some(prev) if span.start <= prev.end => prev.end = prev.end.max(span.end),
1172 _ => out.push(span),
1173 }
1174 }
1175 out
1176}
1177
1178/// Run [`parse_wikilinks`] independently on each run of `body` outside
1179/// `code_spans` (sorted, non-overlapping), then shift each match's span back
1180/// to `body`-relative coordinates before stitching the runs' results
1181/// together in source order. This is what keeps a `[[` inside a code span
1182/// from ever being in the same scan as prose that follows it.
1183fn scan_outside_spans(body: &str, code_spans: &[Range<usize>]) -> Vec<Wikilink> {
1184 let mut out = Vec::new();
1185 let mut cursor = 0;
1186 for span in code_spans {
1187 if cursor < span.start {
1188 out.extend(shift_spans(
1189 parse_wikilinks(&body[cursor..span.start]),
1190 cursor,
1191 ));
1192 }
1193 cursor = cursor.max(span.end);
1194 }
1195 if cursor < body.len() {
1196 out.extend(shift_spans(parse_wikilinks(&body[cursor..]), cursor));
1197 }
1198 out
1199}
1200
1201fn shift_spans(links: Vec<Wikilink>, offset: usize) -> Vec<Wikilink> {
1202 links
1203 .into_iter()
1204 .map(|link| Wikilink {
1205 span: link.span.start + offset..link.span.end + offset,
1206 ..link
1207 })
1208 .collect()
1209}
1210
1211fn code_spans_for(path: &Path, body: &str) -> Option<Vec<Range<usize>>> {
1212 let format = crate::content::ContentFormat::from_extension(path)?;
1213 // A twig failure degrades to "no spans" rather than aborting the scan —
1214 // code-awareness is a refinement, the purely lexical scan above is
1215 // always a safe fallback. An unrecognized extension is `None` (via
1216 // `from_extension`), scanning the whole body as before.
1217 crate::content::code_spans(body, format).ok()
1218}
1219
1220/// Lexically normalize a relative path: drop `.` components and fold
1221/// `parent/..` pairs. Leading `..` components (escaping the workspace root)
1222/// are kept — the caller decides whether that is an error.
1223pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
1224 let mut out: Vec<Component> = Vec::new();
1225 for component in path.as_ref().components() {
1226 match component {
1227 Component::CurDir => {}
1228 Component::ParentDir => match out.last() {
1229 Some(Component::Normal(_)) => {
1230 out.pop();
1231 }
1232 _ => out.push(component),
1233 },
1234 other => out.push(other),
1235 }
1236 }
1237 out.iter().collect()
1238}
1239
1240/// Whether `path`, resolved against a workspace root, would land *outside* it.
1241///
1242/// Two ways a workspace-relative path can escape the tree it is joined onto:
1243/// an **absolute** path (or a Windows drive prefix), which `root.join(path)`
1244/// jumps to wholesale, ignoring the root entirely; and one whose
1245/// [`normalize`]d form still leads with `..`, a climb above the root that the
1246/// `parent/..` folding could not cancel. Either is refused by the read/write
1247/// guards (`prov`'s `Workspace`'s `load`, `prov`'s `ChangeSet::apply`) so a
1248/// relation target — which is *data*, authored by whoever wrote the document —
1249/// can never name a file the workspace does not contain.
1250///
1251/// A path that stays within the root (`notes/a.md`, `../sibling/b.md` where the
1252/// document is nested deeply enough that the `..` cancels) returns `false`.
1253pub fn escapes_root(path: impl AsRef<Path>) -> bool {
1254 matches!(
1255 normalize(path).components().next(),
1256 Some(Component::ParentDir | Component::RootDir | Component::Prefix(_))
1257 )
1258}
1259
1260/// Resolve a link target written in `doc` to a normalized path in the same
1261/// coordinate system as `doc` (workspace-relative when `doc` is). A target with
1262/// a leading `/` is **workspace-absolute** — resolved from the root, not `doc`'s
1263/// directory, and never against the filesystem root; any other target is
1264/// relative to `doc`'s directory.
1265///
1266/// Any [sub-document locator](Link::locator) is dropped first: it names a place
1267/// inside the document, so it has no bearing on which file this is.
1268pub fn resolve(doc: &Path, target: &str) -> PathBuf {
1269 let (target, _) = split_locator(target);
1270 if let Some(from_root) = target.strip_prefix('/') {
1271 return normalize(from_root);
1272 }
1273 let dir = doc.parent().unwrap_or(Path::new(""));
1274 normalize(dir.join(target))
1275}
1276
1277/// The relative path string that reaches `to` from `from_dir` (both normalized,
1278/// same coordinate system). Rendered with forward slashes — link targets are
1279/// text, not platform paths.
1280pub fn relative(from_dir: &Path, to: &Path) -> String {
1281 let from: Vec<&std::ffi::OsStr> = from_dir.iter().collect();
1282 let to_parts: Vec<&std::ffi::OsStr> = to.iter().collect();
1283 let common = from
1284 .iter()
1285 .zip(to_parts.iter())
1286 .take_while(|(a, b)| a == b)
1287 .count();
1288 let mut parts: Vec<String> = Vec::new();
1289 for _ in common..from.len() {
1290 parts.push("..".to_string());
1291 }
1292 for part in &to_parts[common..] {
1293 parts.push(part.to_string_lossy().into_owned());
1294 }
1295 if parts.is_empty() {
1296 ".".to_string()
1297 } else {
1298 parts.join("/")
1299 }
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304 use super::*;
1305
1306 #[test]
1307 fn locator_splits_at_the_first_separator_and_round_trips() {
1308 assert_eq!(split_locator("chapter.md"), ("chapter.md", None));
1309 assert_eq!(split_locator("chapter.md#3"), ("chapter.md", Some("3")));
1310 assert_eq!(split_locator("id:abc1234#2-3"), ("id:abc1234", Some("2-3")));
1311 // First separator wins, so a locator may itself contain one.
1312 assert_eq!(split_locator("a.md#b#c"), ("a.md", Some("b#c")));
1313 // An empty locator is still a locator — the author wrote the `#`.
1314 assert_eq!(split_locator("a.md#"), ("a.md", Some("")));
1315 // A leading `#` is a same-document reference, not a locator on "".
1316 assert_eq!(split_locator("#3"), ("#3", None));
1317
1318 for target in ["chapter.md", "chapter.md#3", "a.md#b#c", "a.md#", "#3"] {
1319 let (doc, locator) = split_locator(target);
1320 assert_eq!(join_locator(doc, locator), target, "round-trip `{target}`");
1321 }
1322 }
1323
1324 #[test]
1325 fn a_locator_does_not_change_which_document_is_named() {
1326 // Path targets: the locator is dropped before resolution.
1327 assert_eq!(
1328 resolve(Path::new("bofm/1-ne-1.md"), "1-ne-2.md#5"),
1329 PathBuf::from("bofm/1-ne-2.md")
1330 );
1331 assert_eq!(
1332 resolve(Path::new("a/b.md"), "/vol/c.md#2-3"),
1333 PathBuf::from("vol/c.md")
1334 );
1335
1336 // Id targets: same id with or without a locator.
1337 let plain = Link::parse("[1 Nephi 1](id:abc1234)");
1338 let located = Link::parse("[1 Nephi 1:1](id:abc1234#1)");
1339 assert_eq!(located.id_target(), plain.id_target());
1340 assert_eq!(located.id_ref(), plain.id_ref());
1341 assert_eq!(located.locator(), Some("1"));
1342 assert_eq!(located.addressed_target(), "id:abc1234");
1343 assert_eq!(plain.locator(), None);
1344
1345 // …and a located id target is still not a path, so no move rewrites it.
1346 assert!(!located.is_path_target());
1347 }
1348
1349 #[test]
1350 fn an_external_url_keeps_its_own_fragment() {
1351 let url = Link::parse("[talk](https://example.com/a#p3)");
1352 assert!(url.is_external());
1353 assert_eq!(url.locator(), None);
1354 assert_eq!(url.addressed_target(), "https://example.com/a#p3");
1355 // Untouched by a rewrite, fragment and all.
1356 assert_eq!(url.with_path("x.md").render(), "[talk](x.md)");
1357 }
1358
1359 #[test]
1360 fn with_path_preserves_the_locator_but_with_target_does_not() {
1361 let link = Link::parse("[1 Nephi 1:1](../1-ne-1.md#1)");
1362 // The rewrite passes use `with_path`: a move changes where the document
1363 // lives, never which part of it was pointed at.
1364 assert_eq!(
1365 link.with_path("bofm/1-ne-1.md").render(),
1366 "[1 Nephi 1:1](bofm/1-ne-1.md#1)"
1367 );
1368 // `with_target` still sets the target verbatim.
1369 assert_eq!(
1370 link.with_target("bofm/1-ne-1.md").render(),
1371 "[1 Nephi 1:1](bofm/1-ne-1.md)"
1372 );
1373 // A wikilink keeps its wrapper and its locator alike.
1374 let wl = Link::parse("[[1-ne-1.md#1|1 Nephi 1:1]]");
1375 assert_eq!(wl.locator(), Some("1"));
1376 assert_eq!(wl.with_path("x.md").render(), "[[x.md#1|1 Nephi 1:1]]");
1377 }
1378
1379 #[test]
1380 fn slug_makes_readable_stems_and_round_trips_the_common_case() {
1381 assert_eq!(slug("My Great Note"), "my-great-note");
1382 // Collapses/strips separators and punctuation; keeps it legible.
1383 assert_eq!(slug(" Hello, World! "), "hello-world");
1384 assert_eq!(slug("already-a-slug"), "already-a-slug");
1385 assert_eq!(slug("under_scored/and slashed"), "under-scored-and-slashed");
1386 assert_eq!(slug("v1.0 Release"), "v10-release");
1387 // Unicode letters/digits survive.
1388 assert_eq!(slug("Café Notes"), "café-notes");
1389 // No leading/trailing/double dashes ever reach the output.
1390 assert_eq!(slug("--x--y--"), "x-y");
1391 // A title with nothing slug-able still yields a valid stem.
1392 assert_eq!(slug("!!!"), "untitled");
1393 assert_eq!(slug(""), "untitled");
1394 // The everyday case is the inverse of path_to_title.
1395 assert_eq!(
1396 path_to_title(std::path::Path::new("my-great-note.md")),
1397 "My Great Note"
1398 );
1399 }
1400
1401 #[test]
1402 fn parses_labeled_and_bare_links() {
1403 let l = Link::parse("[Design](docs/design.md)");
1404 assert_eq!(l.label.as_deref(), Some("Design"));
1405 assert_eq!(l.target, "docs/design.md");
1406 assert_eq!(l.render(), "[Design](docs/design.md)");
1407
1408 let bare = Link::parse("notes/a.md");
1409 assert_eq!(bare.label, None);
1410 assert_eq!(bare.render(), "notes/a.md");
1411 }
1412
1413 #[test]
1414 fn odd_shapes_fall_back_to_bare() {
1415 // A target with brackets but not the [label](target) shape.
1416 for raw in ["[unclosed](x", "no[mid](x)", "[]"] {
1417 assert_eq!(Link::parse(raw).render(), raw);
1418 }
1419 }
1420
1421 #[test]
1422 fn with_target_keeps_the_label() {
1423 let l = Link::parse("[Design](old.md)").with_target("new.md");
1424 assert_eq!(l.render(), "[Design](new.md)");
1425 }
1426
1427 #[test]
1428 fn external_links_are_flagged() {
1429 assert!(Link::parse("https://example.com/x").is_external());
1430 assert!(Link::parse("[me](mailto:a@b.c)").is_external());
1431 assert!(!Link::parse("docs/design.md").is_external());
1432 }
1433
1434 #[test]
1435 fn id_refs_split_into_local_foreign_and_malformed() {
1436 use crate::identity::Id;
1437 let r = |t: &str| Link::parse(t).id_ref();
1438 assert_eq!(r("id:ajp7eq"), Some(IdRef::Local(Id("ajp7eq".into()))));
1439 assert_eq!(
1440 r("id:notes/ajp7eq"),
1441 Some(IdRef::Foreign {
1442 workspace: "notes".into(),
1443 id: Id("ajp7eq".into()),
1444 })
1445 );
1446 // The legacy scheme carries a qualifier just as well — an old workspace
1447 // gains cross-workspace references without a rewrite.
1448 assert_eq!(
1449 r("colophon:notes/ajp7eq"),
1450 Some(IdRef::Foreign {
1451 workspace: "notes".into(),
1452 id: Id("ajp7eq".into()),
1453 })
1454 );
1455 // Every way the scheme can be present but the reference absent.
1456 for bad in ["id:", "id:/x", "id:ws/", "id:a/b/c"] {
1457 assert_eq!(r(bad), Some(IdRef::Malformed), "{bad}");
1458 }
1459 // No scheme at all is not an id ref — it is a path or an alias.
1460 assert_eq!(r("docs/design.md"), None);
1461 assert_eq!(r("https://example.com/x"), None);
1462 }
1463
1464 #[test]
1465 fn a_foreign_id_is_not_a_local_id() {
1466 // The distinction that keeps a foreign reference from being looked up in
1467 // the wrong registry: `id_target` is *local ids only*.
1468 let foreign = Link::parse("id:notes/ajp7eq");
1469 assert_eq!(foreign.id_target(), None);
1470 assert_eq!(
1471 foreign.foreign_target(),
1472 Some(("notes".into(), crate::identity::Id("ajp7eq".into())))
1473 );
1474 let local = Link::parse("id:ajp7eq");
1475 assert_eq!(
1476 local.id_target(),
1477 Some(crate::identity::Id("ajp7eq".into()))
1478 );
1479 assert_eq!(local.foreign_target(), None);
1480 }
1481
1482 #[test]
1483 fn only_paths_are_path_targets() {
1484 // The predicate every rewrite pass filters on. A move may rewrite the
1485 // first group and must leave the second alone — including the malformed
1486 // id, which is a broken reference, not a filename to re-relativize.
1487 for path in ["docs/design.md", "/a.md", "../b.md", "My Note"] {
1488 assert!(Link::parse(path).is_path_target(), "{path}");
1489 }
1490 for stable in [
1491 "id:ajp7eq",
1492 "id:notes/ajp7eq",
1493 "colophon:notes/ajp7eq",
1494 "id:a/b/c",
1495 "https://example.com/x",
1496 "mailto:a@b.c",
1497 ] {
1498 assert!(!Link::parse(stable).is_path_target(), "{stable}");
1499 }
1500 }
1501
1502 #[test]
1503 fn foreign_targets_round_trip_through_rendering() {
1504 let target = foreign_id_target("notes", &crate::identity::Id("ajp7eq".into()));
1505 assert_eq!(target, "id:notes/ajp7eq");
1506 assert_eq!(
1507 Link::parse(&target).foreign_target(),
1508 Some(("notes".into(), crate::identity::Id("ajp7eq".into())))
1509 );
1510 // Through a labeled wikilink too, since that is how diaryx would author
1511 // one — the wrapper is orthogonal to the addressing.
1512 let wl = Link::parse("[[id:notes/ajp7eq|My Note]]");
1513 assert_eq!(wl.label.as_deref(), Some("My Note"));
1514 assert_eq!(
1515 wl.foreign_target(),
1516 Some(("notes".into(), crate::identity::Id("ajp7eq".into())))
1517 );
1518 assert_eq!(wl.render(), "[[id:notes/ajp7eq|My Note]]");
1519 }
1520
1521 #[test]
1522 fn parses_angle_bracketed_and_absolute_targets() {
1523 // Diaryx-style: a labeled link to an angle-bracketed, workspace-absolute
1524 // path containing a space.
1525 let l = Link::parse("[Archived Documents](</Archive/Archived documents.md>)");
1526 assert_eq!(l.label.as_deref(), Some("Archived Documents"));
1527 assert_eq!(l.target, "/Archive/Archived documents.md");
1528 // Round-trips: the space forces the angle brackets back on render.
1529 assert_eq!(
1530 l.render(),
1531 "[Archived Documents](</Archive/Archived documents.md>)"
1532 );
1533
1534 // A bare angle-bracketed target — no `[label](…)` around it — is
1535 // *not* unwrapped: angle brackets are only URL delimiters inside a
1536 // parsed markdown link, so a bare `<…>` value stays byte-literal (C2;
1537 // diaryx reads a bare `<…>` as a literal path, brackets and all). This
1538 // used to unwrap unconditionally; see `bare_angle_bracket_value_stays_literal`
1539 // for the dedicated regression coverage.
1540 let bare = Link::parse("</Creative Writing/Creative Writing.md>");
1541 assert_eq!(bare.target, "</Creative Writing/Creative Writing.md>");
1542 assert_eq!(bare.render(), "</Creative Writing/Creative Writing.md>");
1543
1544 // An absolute path without spaces needs no brackets, and stays bare.
1545 let plain = Link::parse("[Blog](/Blog/Blog.md)");
1546 assert_eq!(plain.target, "/Blog/Blog.md");
1547 assert_eq!(plain.render(), "[Blog](/Blog/Blog.md)");
1548 }
1549
1550 /// C2 regression: before the fix, the bare-value fallback ran `unbracket`
1551 /// on the *whole* raw value, so any `<...>`-shaped bare string (not just
1552 /// the diaryx example above) was silently unwrapped. Now the bare branch
1553 /// never touches angle brackets — only a successfully parsed
1554 /// `[label](<target>)` URL gets unwrapped.
1555 #[test]
1556 fn bare_angle_bracket_value_stays_literal() {
1557 for raw in ["<notes/a.md>", "<https://example.com>", "<a (b) c>", "<>"] {
1558 let l = Link::parse(raw);
1559 assert_eq!(l.label, None);
1560 assert_eq!(l.target, raw, "bare angle-bracket value must be literal");
1561 assert_eq!(l.render(), raw);
1562 }
1563 // Contrast: the *same* angle-bracketed text, once it's the URL of an
1564 // actual markdown link, is unwrapped — that part of the old behavior
1565 // was correct and stays.
1566 assert_eq!(Link::parse("[x](<notes/a.md>)").target, "notes/a.md");
1567 }
1568
1569 /// C3 regression: before the fix, the markdown-link branch demanded the
1570 /// *entire* trimmed input end in `)` (`raw.strip_suffix(')')`), so any
1571 /// trailing text after a well-formed link's closing paren made the whole
1572 /// value fall through to the bare branch — the complete string, including
1573 /// the `[label](target)` syntax, became one literal target. Balanced-paren
1574 /// scanning fixes both halves of that: trailing text is tolerated, and
1575 /// parens *inside* the target (nested, even) don't confuse the scan.
1576 #[test]
1577 fn markdown_link_split_tolerates_trailing_text_and_balanced_parens() {
1578 // Trailing prose after a legitimate link is ignored, not swallowed.
1579 let l = Link::parse("[Title](/path.md) trailing junk");
1580 assert_eq!(l.label.as_deref(), Some("Title"));
1581 assert_eq!(l.target, "/path.md");
1582
1583 // A target containing its own parens still closes at the matching `)`.
1584 let l = Link::parse("[Explanation (1.1)](/Archive/Explanation (1.1).md)");
1585 assert_eq!(l.label.as_deref(), Some("Explanation (1.1)"));
1586 assert_eq!(l.target, "/Archive/Explanation (1.1).md");
1587
1588 // Nested parens in the target keep working.
1589 let l = Link::parse("[File (a (b))](/path/file (a (b)).md)");
1590 assert_eq!(l.label.as_deref(), Some("File (a (b))"));
1591 assert_eq!(l.target, "/path/file (a (b)).md");
1592
1593 // Trailing text *and* parens in the target, together.
1594 let l = Link::parse("[T](/a (1).md) and then some more words");
1595 assert_eq!(l.target, "/a (1).md");
1596
1597 // An angle-bracketed URL still requires the `>` immediately followed by
1598 // `)` — trailing text after *that* `)` is likewise tolerated.
1599 let l = Link::parse("[Notes](</My Notes/x.md>) ignored tail");
1600 assert_eq!(l.target, "/My Notes/x.md");
1601
1602 // An unterminated target (no closing paren at all) still falls back to
1603 // bare, unchanged from before.
1604 let unterminated = "[Title](/path.md";
1605 assert_eq!(Link::parse(unterminated).render(), unterminated);
1606 }
1607
1608 /// C1: `parse_path_only` opts out of the `[[…]]` wikilink convention so a
1609 /// frontmatter path field can hold a literal bracket-shaped string without
1610 /// `Link::parse` reinterpreting it — the convention diaryx's own path-value
1611 /// parser never had. `parse` is unchanged (still treats it as a wikilink).
1612 #[test]
1613 fn wikilink_opt_out_keeps_bracket_literal_string() {
1614 let ordinary = Link::parse("[[notes/a.md]]");
1615 assert!(ordinary.wikilink);
1616 assert_eq!(ordinary.target, "notes/a.md");
1617
1618 let opted_out = Link::parse_path_only("[[notes/a.md]]");
1619 assert!(!opted_out.wikilink);
1620 assert_eq!(opted_out.label, None);
1621 assert_eq!(opted_out.target, "[[notes/a.md]]");
1622 assert_eq!(opted_out.render(), "[[notes/a.md]]");
1623
1624 // A pipe-labeled wikilink scalar is likewise kept as one literal bare
1625 // string, not split into label/target.
1626 let piped = Link::parse_path_only("[[notes/a.md|My Note]]");
1627 assert_eq!(piped.label, None);
1628 assert_eq!(piped.target, "[[notes/a.md|My Note]]");
1629
1630 // Every other `parse` rule is unaffected: markdown links, bare paths,
1631 // and angle-bracket handling (both C2's literal-bare and C3's
1632 // balanced-paren splitting) all behave identically under the opt-out.
1633 assert_eq!(
1634 Link::parse_path_only("[Design](docs/design.md)"),
1635 Link::parse("[Design](docs/design.md)")
1636 );
1637 assert_eq!(
1638 Link::parse_path_only("notes/a.md"),
1639 Link::parse("notes/a.md")
1640 );
1641 assert_eq!(
1642 Link::parse_path_only("<notes/a.md>"),
1643 Link::parse("<notes/a.md>")
1644 );
1645 }
1646
1647 #[test]
1648 fn formats_links_in_each_workspace_style() {
1649 let from = Path::new("School/MATH 213/hw.md");
1650 let target = Path::new("School/Archive/MATH 213 files.md");
1651 // MarkdownRoot: absolute, titled, angle-bracketed for the space.
1652 assert_eq!(
1653 format_link(LinkStyle::MarkdownRoot, from, target, "MATH 213 Files"),
1654 "[MATH 213 Files](</School/Archive/MATH 213 files.md>)"
1655 );
1656 // MarkdownRelative: relative, titled.
1657 assert_eq!(
1658 format_link(LinkStyle::MarkdownRelative, from, target, "MATH 213 Files"),
1659 "[MATH 213 Files](<../Archive/MATH 213 files.md>)"
1660 );
1661 // Plain styles: bare, no title.
1662 assert_eq!(
1663 format_link(LinkStyle::PlainRelative, from, target, "ignored"),
1664 "../Archive/MATH 213 files.md"
1665 );
1666 assert_eq!(
1667 format_link(LinkStyle::PlainRoot, from, target, "ignored"),
1668 "/School/Archive/MATH 213 files.md"
1669 );
1670 }
1671
1672 #[test]
1673 fn link_style_axes_round_trip_and_cover_every_combination() {
1674 use Notation::*;
1675 use PathStyle::*;
1676 // Every notation×path_style combination has a fused LinkStyle, and axes()
1677 // is its inverse — so the orthogonal config surface is lossless.
1678 for notation in [Markdown, Bare] {
1679 for path_style in [Root, Relative] {
1680 let style = LinkStyle::from_axes(notation, path_style);
1681 assert_eq!(style.axes(), (notation, path_style));
1682 }
1683 }
1684 // Wikilink has no bare/bracketed split, so it maps through the Markdown
1685 // family and its path text follows the path style.
1686 assert_eq!(
1687 LinkStyle::from_axes(Wikilink, Relative),
1688 LinkStyle::MarkdownRelative
1689 );
1690 assert_eq!(
1691 Notation::from_wrapper(Wrapper::Wikilink, LinkStyle::MarkdownRoot),
1692 Wikilink
1693 );
1694 assert_eq!(Notation::from_config_str("bare"), Some(Bare));
1695 assert_eq!(PathStyle::from_config_str("relative"), Some(Relative));
1696 // Retired: a bare workspace-relative path is unspellable, because a bare
1697 // path is directory-relative and the two cannot both be true.
1698 assert_eq!(PathStyle::from_config_str("canonical"), None);
1699 assert_eq!(LinkStyle::default(), LinkStyle::MarkdownRoot);
1700 assert_eq!(
1701 path_to_title(Path::new("Folder/utility_index.md")),
1702 "Utility Index"
1703 );
1704 }
1705
1706 #[test]
1707 fn the_bare_workspace_absolute_style_renders_a_leading_slash() {
1708 let from = Path::new("a/b.md");
1709 let to = Path::new("c/d.md");
1710 assert_eq!(format_link(LinkStyle::PlainRoot, from, to, "D"), "/c/d.md");
1711 }
1712
1713 #[test]
1714 fn resolves_workspace_absolute_paths_from_the_root() {
1715 // A leading slash means "from the workspace root", regardless of where
1716 // the linking document sits — and never the filesystem root.
1717 assert_eq!(
1718 resolve(Path::new("Meta/Meta files.md"), "/Blog/Blog.md"),
1719 PathBuf::from("Blog/Blog.md")
1720 );
1721 assert_eq!(
1722 resolve(Path::new("deep/nested/doc.md"), "/Resume.md"),
1723 PathBuf::from("Resume.md")
1724 );
1725 // Relative targets still resolve against the document's own directory.
1726 assert_eq!(
1727 resolve(Path::new("Meta/Meta files.md"), "../Blog/Blog.md"),
1728 PathBuf::from("Blog/Blog.md")
1729 );
1730 }
1731
1732 #[test]
1733 fn normalizes_dot_and_dotdot() {
1734 assert_eq!(normalize("a/./b/../c.md"), PathBuf::from("a/c.md"));
1735 assert_eq!(normalize("../up.md"), PathBuf::from("../up.md"));
1736 assert_eq!(normalize("a/b/../../x.md"), PathBuf::from("x.md"));
1737 }
1738
1739 #[test]
1740 fn resolves_against_the_documents_directory() {
1741 assert_eq!(
1742 resolve(Path::new("docs/index.md"), "../README.md"),
1743 PathBuf::from("README.md")
1744 );
1745 assert_eq!(
1746 resolve(Path::new("README.md"), "docs/design.md"),
1747 PathBuf::from("docs/design.md")
1748 );
1749 }
1750
1751 #[test]
1752 fn scans_bare_and_labeled_wikilinks_with_spans() {
1753 let body = "see [[notes/a.md]] and [[colophon:ajp7eq|My file]] here";
1754 let links = parse_wikilinks(body);
1755 assert_eq!(links.len(), 2);
1756
1757 assert_eq!(links[0].target, "notes/a.md");
1758 assert_eq!(links[0].label, None);
1759 assert_eq!(&body[links[0].span.clone()], "[[notes/a.md]]");
1760 assert_eq!(links[0].id_target(), None);
1761
1762 assert_eq!(links[1].target, "colophon:ajp7eq");
1763 assert_eq!(links[1].label.as_deref(), Some("My file"));
1764 assert_eq!(&body[links[1].span.clone()], "[[colophon:ajp7eq|My file]]");
1765 assert_eq!(
1766 links[1].id_target(),
1767 Some(crate::identity::Id("ajp7eq".into()))
1768 );
1769 }
1770
1771 #[test]
1772 fn wikilink_scan_trims_and_skips_degenerate_shapes() {
1773 // Whitespace inside the brackets is trimmed on both sides of the pipe.
1774 let trimmed = parse_wikilinks("x [[ notes/a.md | Label ]] y");
1775 assert_eq!(trimmed[0].target, "notes/a.md");
1776 assert_eq!(trimmed[0].label.as_deref(), Some("Label"));
1777
1778 // Empty target and unclosed openers are not links.
1779 assert!(parse_wikilinks("nothing [[]] here").is_empty());
1780 assert!(parse_wikilinks("[[ | orphan label ]]").is_empty());
1781 assert!(parse_wikilinks("dangling [[notes/a.md without close").is_empty());
1782 }
1783
1784 #[test]
1785 fn wikilink_render_round_trips_and_retargets() {
1786 let link = &parse_wikilinks("[[old.md|Design]]")[0];
1787 assert_eq!(link.render(), "[[old.md|Design]]");
1788 // Retarget keeps the label — the rename path relies on this.
1789 assert_eq!(link.with_target("new.md").render(), "[[new.md|Design]]");
1790
1791 let bare = &parse_wikilinks("[[old.md]]")[0];
1792 assert_eq!(bare.render(), "[[old.md]]");
1793 }
1794
1795 #[test]
1796 fn exclude_code_spans_drops_only_overlapping_wikilinks() {
1797 let body = "see [[notes/a.md]] and `[[not/a/link]]` too";
1798 let links = parse_wikilinks(body);
1799 assert_eq!(links.len(), 2, "the lexical scanner has no code awareness");
1800
1801 let code_start = body.find('`').unwrap();
1802 let code_end = body.rfind('`').unwrap() + 1;
1803 let code_span = code_start..code_end;
1804 let kept = exclude_code_spans(links, std::slice::from_ref(&code_span));
1805
1806 assert_eq!(kept.len(), 1);
1807 assert_eq!(kept[0].target, "notes/a.md");
1808 }
1809
1810 #[test]
1811 fn relative_walks_up_and_down() {
1812 assert_eq!(
1813 relative(Path::new("docs"), Path::new("README.md")),
1814 "../README.md"
1815 );
1816 assert_eq!(
1817 relative(Path::new(""), Path::new("docs/design.md")),
1818 "docs/design.md"
1819 );
1820 assert_eq!(relative(Path::new("a/b"), Path::new("a/b/c.md")), "c.md");
1821 assert_eq!(relative(Path::new("a/b"), Path::new("a/b")), ".");
1822 }
1823
1824 #[test]
1825 fn parses_and_round_trips_wikilink_scalars_in_metadata() {
1826 // A metadata scalar written as a wikilink resolves through the same
1827 // Link path as a markdown one, and round-trips its wrapper.
1828 let l = Link::parse("[[id:ajp7eqb|My File]]");
1829 assert!(l.wikilink);
1830 assert_eq!(l.label.as_deref(), Some("My File"));
1831 assert_eq!(l.target, "id:ajp7eqb");
1832 assert_eq!(l.id_target(), Some(crate::identity::Id("ajp7eqb".into())));
1833 assert_eq!(l.render(), "[[id:ajp7eqb|My File]]");
1834
1835 let bare = Link::parse("[[notes/a.md]]");
1836 assert!(bare.wikilink);
1837 assert_eq!(bare.label, None);
1838 assert_eq!(bare.render(), "[[notes/a.md]]");
1839 // Retarget keeps the wikilink wrapper and label.
1840 assert_eq!(
1841 l.with_target("id:zzzzzz9").render(),
1842 "[[id:zzzzzz9|My File]]"
1843 );
1844 }
1845
1846 #[test]
1847 fn id_scheme_reads_current_and_legacy_spellings() {
1848 assert_eq!(strip_id_scheme("id:ajp7eqb"), Some("ajp7eqb"));
1849 assert_eq!(strip_id_scheme("colophon:ajp7eqb"), Some("ajp7eqb"));
1850 assert_eq!(strip_id_scheme("notes/a.md"), None);
1851 // New links are authored in the `id:` spelling.
1852 assert_eq!(
1853 id_target(&crate::identity::Id("ajp7eqb".into())),
1854 "id:ajp7eqb"
1855 );
1856 assert_eq!(
1857 Link::parse("colophon:ajp7eqb").id_target().unwrap().0,
1858 "ajp7eqb"
1859 );
1860 }
1861
1862 #[test]
1863 fn format_reference_renders_each_style() {
1864 let from = Path::new("notes/hw.md");
1865 let to = Path::new("Archive/a.md");
1866 let id = crate::identity::Id("ajp7eqb".into());
1867 let s = |wrapper, addressing, label| ReferenceStyle {
1868 wrapper,
1869 addressing,
1870 label,
1871 path_style: LinkStyle::MarkdownRoot,
1872 };
1873
1874 // Markdown + path → the classic LinkStyle rendering.
1875 assert_eq!(
1876 format_reference(
1877 s(Wrapper::Markdown, Addressing::Path, false),
1878 from,
1879 to,
1880 None,
1881 "A"
1882 ),
1883 "[A](/Archive/a.md)"
1884 );
1885 // Wikilink + path, label off vs on.
1886 assert_eq!(
1887 format_reference(
1888 s(Wrapper::Wikilink, Addressing::Path, false),
1889 from,
1890 to,
1891 None,
1892 "A"
1893 ),
1894 "[[/Archive/a.md]]"
1895 );
1896 assert_eq!(
1897 format_reference(
1898 s(Wrapper::Wikilink, Addressing::Path, true),
1899 from,
1900 to,
1901 None,
1902 "A"
1903 ),
1904 "[[/Archive/a.md|A]]"
1905 );
1906 // Markdown + id: bare when unlabeled (the diaryx-shaped id link), a
1907 // titled markdown link when labeled.
1908 assert_eq!(
1909 format_reference(
1910 s(Wrapper::Markdown, Addressing::Id, false),
1911 from,
1912 to,
1913 Some(&id),
1914 "A"
1915 ),
1916 "id:ajp7eqb"
1917 );
1918 assert_eq!(
1919 format_reference(
1920 s(Wrapper::Markdown, Addressing::Id, true),
1921 from,
1922 to,
1923 Some(&id),
1924 "A"
1925 ),
1926 "[A](id:ajp7eqb)"
1927 );
1928 // Wikilink + id, no label / with label.
1929 assert_eq!(
1930 format_reference(
1931 s(Wrapper::Wikilink, Addressing::Id, false),
1932 from,
1933 to,
1934 Some(&id),
1935 "A"
1936 ),
1937 "[[id:ajp7eqb]]"
1938 );
1939 assert_eq!(
1940 format_reference(
1941 s(Wrapper::Wikilink, Addressing::Id, true),
1942 from,
1943 to,
1944 Some(&id),
1945 "A"
1946 ),
1947 "[[id:ajp7eqb|A]]"
1948 );
1949 // Alias is a bare-name wikilink, even if markdown was requested.
1950 assert_eq!(
1951 format_reference(
1952 s(Wrapper::Markdown, Addressing::Alias, false),
1953 from,
1954 to,
1955 None,
1956 "My File"
1957 ),
1958 "[[My File]]"
1959 );
1960 // Id addressing with no id available degrades to a path link.
1961 assert_eq!(
1962 format_reference(
1963 s(Wrapper::Wikilink, Addressing::Id, true),
1964 from,
1965 to,
1966 None,
1967 "A"
1968 ),
1969 "[A](/Archive/a.md)"
1970 );
1971 }
1972
1973 #[test]
1974 fn reference_style_config_round_trips_and_normalizes() {
1975 assert_eq!(
1976 Wrapper::from_config_str("wikilink"),
1977 Some(Wrapper::Wikilink)
1978 );
1979 assert_eq!(
1980 Addressing::from_config_str("alias"),
1981 Some(Addressing::Alias)
1982 );
1983 assert_eq!(Wrapper::Wikilink.as_config_str(), "wikilink");
1984 assert_eq!(Addressing::Id.as_config_str(), "id");
1985 // markdown + alias is impossible; normalization forces wikilink.
1986 let n = ReferenceStyle {
1987 addressing: Addressing::Alias,
1988 ..ReferenceStyle::default()
1989 }
1990 .normalized();
1991 assert_eq!(n.wrapper, Wrapper::Wikilink);
1992 assert!(
1993 ReferenceStyle {
1994 addressing: Addressing::Id,
1995 ..ReferenceStyle::default()
1996 }
1997 .registers()
1998 );
1999 assert!(!ReferenceStyle::default().registers());
2000 }
2001
2002 #[test]
2003 fn path_text_takes_the_path_style_shape() {
2004 let from = Path::new("a/b/hw.md");
2005 let to = Path::new("a/c/x.md");
2006 assert_eq!(path_text(LinkStyle::MarkdownRoot, from, to), "/a/c/x.md");
2007 assert_eq!(
2008 path_text(LinkStyle::MarkdownRelative, from, to),
2009 "../c/x.md"
2010 );
2011 assert_eq!(path_text(LinkStyle::PlainRoot, from, to), "/a/c/x.md");
2012 }
2013
2014 /// Laws, rather than examples.
2015 ///
2016 /// Every test above names one input and asserts the output prov produced
2017 /// for it. That is the right shape for a decision (`slug("v1.0 Release")`
2018 /// is `"v10-release"` because someone chose it), and the wrong shape for a
2019 /// *law* — a claim quantified over every input, of which an example
2020 /// witnesses exactly one. The round-trip below is a law: a link prov
2021 /// authors must name the document prov meant, from wherever it was
2022 /// written, in whichever style the workspace declared.
2023 mod properties {
2024 use super::*;
2025 use proptest::prelude::*;
2026
2027 /// A workspace-relative document path — up to two directories deep,
2028 /// short lowercase names. The alphabet is small on purpose: a
2029 /// counterexample is only useful if you can read it, and `a/b.md`
2030 /// makes the same point as `Xk9/qZ2.md` with none of the noise.
2031 fn doc_path() -> impl Strategy<Value = PathBuf> {
2032 (prop::collection::vec("[a-z]{1,3}", 0..3usize), "[a-z]{1,3}").prop_map(
2033 |(dirs, stem)| {
2034 let mut path = PathBuf::new();
2035 for dir in dirs {
2036 path.push(dir);
2037 }
2038 path.push(format!("{stem}.md"));
2039 path
2040 },
2041 )
2042 }
2043
2044 /// A path as a *human* might write one into frontmatter: real names
2045 /// mixed with the `.` and `..` a relative reference is made of. Unlike
2046 /// [`doc_path`] this may climb above the root (`../../x`), which is
2047 /// legal to *write* and refused later by [`escapes_root`] — the
2048 /// normalizer has to survive it either way.
2049 fn messy_path() -> impl Strategy<Value = PathBuf> {
2050 prop::collection::vec(
2051 prop_oneof![Just(".".to_string()), Just("..".to_string()), "[a-z]{1,3}"],
2052 1..5usize,
2053 )
2054 .prop_map(|parts| parts.iter().collect())
2055 }
2056
2057 /// Every style there is.
2058 ///
2059 /// This list was once four of six. The two `Canonical` styles emitted a
2060 /// bare *workspace*-relative path that [`resolve`] reads as
2061 /// *directory*-relative, so they failed the law below — and they are
2062 /// gone now rather than excused, which is why the strategy can name the
2063 /// whole enum again. A style that cannot be read back is not a style.
2064 fn round_tripping_style() -> impl Strategy<Value = LinkStyle> {
2065 prop::sample::select(vec![
2066 LinkStyle::MarkdownRoot,
2067 LinkStyle::MarkdownRelative,
2068 LinkStyle::PlainRoot,
2069 LinkStyle::PlainRelative,
2070 ])
2071 }
2072
2073 proptest! {
2074 /// `resolve ∘ format = id`: authoring a link to `to` from the
2075 /// document at `from`, then reading it back the way every consumer
2076 /// does (`Link::parse` for the scalar, `resolve` for the path),
2077 /// must land on `to` again. The whole point of a style axis is
2078 /// that it changes how a reference is *spelled* and not what it
2079 /// *means* — so this holds for every style, which is a claim the
2080 /// enum only earned once the canonical pair was retired.
2081 #[test]
2082 fn a_formatted_link_resolves_back_to_the_document_it_names(
2083 from in doc_path(),
2084 to in doc_path(),
2085 style in round_tripping_style(),
2086 ) {
2087 let written = format_link(style, &from, &to, "Label");
2088 let got = resolve(&from, &Link::parse(&written).target);
2089 prop_assert_eq!(
2090 &got,
2091 &to,
2092 "{:?} wrote `{}` in `{}`",
2093 style,
2094 written,
2095 from.display()
2096 );
2097 }
2098
2099 /// The law a move rests on: **re-relativizing a link preserves its
2100 /// referent.** `mutate::rename` rewrites every outbound path link
2101 /// of a document it moves by resolving the old text against the old
2102 /// location and re-rendering it against the new one
2103 /// (`rename.rs:236`). That is only safe if the two resolve to the
2104 /// same document — which is this, quantified over every pair of
2105 /// locations a document could move between.
2106 #[test]
2107 fn re_relativizing_a_link_preserves_the_document_it_names(
2108 from in doc_path(),
2109 to in doc_path(),
2110 referent in doc_path(),
2111 ) {
2112 let dir = |p: &Path| p.parent().unwrap_or(Path::new("")).to_path_buf();
2113 // As authored in the document at its old location.
2114 let written = relative(&dir(&from), &referent);
2115 // What `rename` writes when the document lands at `to`.
2116 let rewritten = relative(&dir(&to), &resolve(&from, &written));
2117 prop_assert_eq!(
2118 resolve(&to, &rewritten),
2119 referent,
2120 "`{}` moving {} → {} became `{}`",
2121 written,
2122 from.display(),
2123 to.display(),
2124 rewritten
2125 );
2126 }
2127
2128 /// [`normalize`] is idempotent — folding `.`/`..` a second time
2129 /// changes nothing. Load-bearing because resolved paths are
2130 /// compared for equality all over the crate (`move_conflict`, the
2131 /// registry's id↔path bijection, every `check` finding that says
2132 /// two links name the same document); a normal form that still
2133 /// moves under re-application would make those comparisons depend
2134 /// on how many times a path had been through the resolver.
2135 #[test]
2136 fn normalizing_a_path_twice_is_normalizing_it_once(path in messy_path()) {
2137 let once = normalize(&path);
2138 prop_assert_eq!(normalize(&once), once.clone(), "on `{}`", path.display());
2139 }
2140
2141 /// A normalized path has no `.` left, and any `..` that survives is
2142 /// a *leading* one — the climb above the root that could not be
2143 /// cancelled, which is precisely what [`escapes_root`] then looks
2144 /// for. A `..` appearing after a normal component would mean the
2145 /// fold missed a cancellation, and every path comparison downstream
2146 /// would be reading a path that still had reducing left to do.
2147 #[test]
2148 fn a_normalized_path_keeps_only_the_climb_it_could_not_cancel(
2149 path in messy_path(),
2150 ) {
2151 let normalized = normalize(&path);
2152 let parts: Vec<_> = normalized.components().collect();
2153 let climb = parts
2154 .iter()
2155 .take_while(|c| matches!(c, Component::ParentDir))
2156 .count();
2157 prop_assert!(
2158 parts[climb..]
2159 .iter()
2160 .all(|c| matches!(c, Component::Normal(_) | Component::RootDir)),
2161 "`{}` normalized to `{}`",
2162 path.display(),
2163 normalized.display()
2164 );
2165 }
2166 }
2167 }
2168}