Skip to main content

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