Skip to main content

provui_core/
links.rs

1//! The links a document's metadata declares — where each one sits, what it says,
2//! and what shape of target it names.
3//!
4//! prov already extracts a document's edges
5//! ([`RelationSet::edges`](prov::RelationSet::edges)), but an editor needs one
6//! thing that answer does not carry: **where** each link is. An edge is a
7//! `(relation, target)` pair, and a frontend asking "the row under the cursor —
8//! is that a link, and if so which one?" needs `contents[2]`, not the third
9//! string of the `contents` field. So [`MetaLink`] is prov's edge with its
10//! metadata path attached, and [`link_at`] is the cursor question asked directly.
11//!
12//! Everything here is **lexical and offline**: a target is parsed, classified by
13//! syntax, and handed back. No filesystem, no registry, no claim that anything
14//! exists. Turning a [`MetaLink`] into a document you can open is
15//! [`crate::workspace`], because that is the step that needs a workspace to do
16//! it in — and a frontend that only wants to *draw* links differently (an icon,
17//! a tint, the label instead of the path) needs none of that and should not
18//! link it.
19
20use fig::Value;
21use flower_core::Seg;
22use prov::Link;
23use prov::link::IdRef;
24
25use crate::facets::{Facet, Facets};
26
27/// What shape of thing a link's target names, by syntax alone.
28///
29/// prov's own [`Target`](prov::Target) is the *resolved* answer and needs a
30/// workspace to produce; this is what can be known from the string, which is
31/// what an editor drawing a row has. The two line up variant for variant, so a
32/// frontend that starts with this and later gains a workspace does not restate
33/// its rendering.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum TargetKind {
36    /// A path — relative to the document, or workspace-absolute with a leading
37    /// `/`. The ordinary case, and the only one resolvable without a registry.
38    Path,
39    /// An `id:<id>` handle into this workspace's registry.
40    Id,
41    /// An `id:<workspace>/<id>` handle into *another* workspace. prov holds no
42    /// map from a workspace name to a location, so this names something without
43    /// locating it.
44    Foreign {
45        /// The workspace qualifier, as written.
46        workspace: String,
47    },
48    /// A URL or mail address — off-workspace, never resolved and never
49    /// rewritten by a move.
50    External,
51    /// A target that is *only* a `#locator`: a place inside the document the
52    /// link is written in, naming no other document.
53    SameDocument,
54    /// The `id:` scheme with a body that is no reference at all (`id:`,
55    /// `id:ws/`, `id:a/b/c`).
56    ///
57    /// Its own case rather than a path, on prov's grounds: the author wrote
58    /// `id:`, so reading the rest as a filename would turn a typo into a
59    /// dangling path and hide what actually went wrong.
60    MalformedId,
61}
62
63/// One link declared by a document's metadata.
64#[derive(Debug, Clone)]
65pub struct MetaLink {
66    /// Where the link sits in the metadata: `[Key("part_of")]` for a scalar
67    /// relation, `[Key("contents"), Index(2)]` for the third item of a list.
68    /// The path a frontend compares against its cursor.
69    pub path: Vec<Seg>,
70    /// The relation that declared it, and everything the vocabulary says about
71    /// that relation — including whether it is the spanning backbone and whether
72    /// it is a one-way pointer at machinery.
73    pub relation: crate::facets::RelationFacet,
74    /// The parsed link: its label, its target, and which wrapper it was written
75    /// in. [`Link::render`](prov::Link::render) puts it back the way it came.
76    pub link: Link,
77    /// What shape of thing the target names, by syntax.
78    pub kind: TargetKind,
79}
80
81impl MetaLink {
82    /// The target as written, locator and all.
83    pub fn target(&self) -> &str {
84        &self.link.target
85    }
86
87    /// The `#locator` suffix, when the target has one — a place inside the
88    /// document the rest of the target names. Carried by prov, never resolved.
89    pub fn locator(&self) -> Option<&str> {
90        self.link.locator()
91    }
92
93    /// What to put on a row: the link's label when it was written with one, and
94    /// the target otherwise. A labeled link is labeled *because* the target is
95    /// not what a reader wants to read.
96    pub fn display(&self) -> &str {
97        self.link.label.as_deref().unwrap_or(&self.link.target)
98    }
99
100    /// Whether following this link would leave the workspace — a URL, or a
101    /// reference into a workspace prov cannot locate from here.
102    pub fn leaves_the_workspace(&self) -> bool {
103        matches!(self.kind, TargetKind::External | TargetKind::Foreign { .. })
104    }
105}
106
107/// A link, wherever it was written — the shape [`crate::WorkspaceView`] resolves.
108///
109/// Resolution needs exactly two things from a link: the parsed [`Link`] prov
110/// reads the target off, and the [`TargetKind`] that says whether there is
111/// anything to resolve at all. Neither is a fact about *where* the link sits, so
112/// neither [`MetaLink`]'s metadata path nor [`BodyLink`]'s byte span appears
113/// here — and a body link consequently resolves through the same code a
114/// frontmatter link does, in a workspace or without one.
115///
116/// [`BodyLink`]: crate::BodyLink
117pub trait AnyLink {
118    /// The parsed link: label, target, wrapper.
119    fn link(&self) -> &Link;
120
121    /// What shape of thing the target names, by syntax.
122    fn kind(&self) -> &TargetKind;
123
124    /// The target as written, locator and all.
125    fn target(&self) -> &str {
126        &self.link().target
127    }
128
129    /// Whether following this link would leave the workspace — a URL, or a
130    /// reference into a workspace prov cannot locate from here.
131    fn leaves_the_workspace(&self) -> bool {
132        matches!(
133            self.kind(),
134            TargetKind::External | TargetKind::Foreign { .. }
135        )
136    }
137}
138
139impl AnyLink for MetaLink {
140    fn link(&self) -> &Link {
141        &self.link
142    }
143
144    fn kind(&self) -> &TargetKind {
145        &self.kind
146    }
147}
148
149impl AnyLink for crate::BodyLink {
150    fn link(&self) -> &Link {
151        &self.link
152    }
153
154    fn kind(&self) -> &TargetKind {
155        &self.kind
156    }
157}
158
159/// Classify a target string by syntax. The order matters: `id:` handles are
160/// checked before anything else because `id:ajp7eq` is also a syntactically
161/// valid relative path, and prov reads the scheme first.
162///
163/// Crate-visible rather than private because [`crate::body_links`] classifies a
164/// prose link with it: the same target written in `contents` and written in a
165/// paragraph is the same target, and two copies of this would be two chances to
166/// disagree about `id:`.
167pub(crate) fn kind_of(link: &Link) -> TargetKind {
168    if link.is_same_document() {
169        return TargetKind::SameDocument;
170    }
171    match link.id_ref() {
172        Some(IdRef::Local(_)) => return TargetKind::Id,
173        Some(IdRef::Foreign { workspace, .. }) => {
174            return TargetKind::Foreign {
175                workspace: workspace.to_string(),
176            };
177        }
178        Some(IdRef::Malformed) => return TargetKind::MalformedId,
179        None => {}
180    }
181    if link.is_external() {
182        return TargetKind::External;
183    }
184    TargetKind::Path
185}
186
187/// Every link `meta` declares, in relation order and then in list order.
188///
189/// A relation field holding a scalar yields one link at `[Key(name)]`; one
190/// holding a sequence yields one per **string** item, at `[Key(name),
191/// Index(i)]`. A non-string item is skipped rather than guessed at: prov reads a
192/// relation's targets as strings, and a map inside a `contents:` list is a
193/// document that needs fixing, not a link this crate should invent.
194pub fn links_in(meta: &Value, facets: &Facets) -> Vec<MetaLink> {
195    let mut links = Vec::new();
196    for relation in facets.relations().relations() {
197        let Facet::Relation(facet) = facets.of_key(&relation.name) else {
198            continue;
199        };
200        let Some(value) = meta.get(relation.name.as_str()) else {
201            continue;
202        };
203        let at = |path: Vec<Seg>, raw: &str| MetaLink {
204            path,
205            relation: facet.clone(),
206            link: parse(raw),
207            kind: TargetKind::Path, // replaced below; `parse` is needed first
208        };
209        match value {
210            Value::Seq(items) => {
211                for (index, item) in items.iter().enumerate() {
212                    if let Some(raw) = item.as_str() {
213                        let path = vec![Seg::Key(relation.name.clone()), Seg::Index(index)];
214                        links.push(finish(at(path, raw)));
215                    }
216                }
217            }
218            other => {
219                if let Some(raw) = other.as_str() {
220                    let path = vec![Seg::Key(relation.name.clone())];
221                    links.push(finish(at(path, raw)));
222                }
223            }
224        }
225    }
226    links
227}
228
229/// The link at exactly `path`, if there is one — the cursor question.
230///
231/// Exact, not prefix: standing on the `contents` row itself is standing on a
232/// *list*, not on a link, and answering with its first item would follow
233/// somewhere the cursor was not. A frontend that wants "the list's first link"
234/// asks [`links_under`] instead and says so.
235pub fn link_at(meta: &Value, facets: &Facets, path: &[Seg]) -> Option<MetaLink> {
236    links_in(meta, facets)
237        .into_iter()
238        .find(|link| link.path == path)
239}
240
241/// Every link at or below `path` — the list under a relation row, or the one
242/// link a scalar relation row holds.
243pub fn links_under(meta: &Value, facets: &Facets, path: &[Seg]) -> Vec<MetaLink> {
244    links_in(meta, facets)
245        .into_iter()
246        .filter(|link| link.path.starts_with(path))
247        .collect()
248}
249
250/// Parse a relation target.
251///
252/// [`Link::parse`](prov::Link::parse), not `parse_path_only`: a relation field
253/// is exactly where prov permits the Obsidian `[[target]]` wrapper, and reading
254/// one as a literal path would make a wikilink vault's every link unfollowable.
255/// The opt-out exists for path *properties*, which relation fields are not.
256fn parse(raw: &str) -> Link {
257    Link::parse(raw)
258}
259
260/// Fill in the kind, which needs the parsed link.
261fn finish(mut link: MetaLink) -> MetaLink {
262    link.kind = kind_of(&link.link);
263    link
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use prov::{Document, WorkspaceConfig};
270
271    const DOC: &str = "\
272---
273title: A Note
274contents:
275- '[Child](child.md)'
276- '[[notes/other.md|Other]]'
277- 'id:ajp7eq'
278part_of: '[Root](/README.md)'
279links:
280- 'https://example.com/'
281- '#section-2'
282- 'id:otherws/bkq8fr'
283config: prov.yaml
284mood: rainy
285---
286# Note
287";
288
289    fn meta() -> Value {
290        let doc = Document::parse("notes/note.md", DOC).expect("parse");
291        Value::from(&doc.meta)
292    }
293
294    fn facets() -> Facets {
295        Facets::from_config(&WorkspaceConfig::default())
296    }
297
298    fn at(links: &[MetaLink], path: &[Seg]) -> MetaLink {
299        links
300            .iter()
301            .find(|l| l.path == path)
302            .unwrap_or_else(|| panic!("no link at {path:?}"))
303            .clone()
304    }
305
306    #[test]
307    fn every_link_knows_where_it_sits() {
308        let meta = meta();
309        let links = links_in(&meta, &facets());
310        let paths: Vec<Vec<Seg>> = links.iter().map(|l| l.path.clone()).collect();
311        assert!(paths.contains(&vec![Seg::Key("contents".into()), Seg::Index(1)]));
312        assert!(paths.contains(&vec![Seg::Key("part_of".into())]));
313        assert!(paths.contains(&vec![Seg::Key("config".into())]));
314        // `mood` is not a relation, so it contributes nothing.
315        assert!(!paths.iter().any(|p| p == &vec![Seg::Key("mood".into())]));
316    }
317
318    #[test]
319    fn the_label_and_the_wrapper_survive_the_round_trip() {
320        let meta = meta();
321        let links = links_in(&meta, &facets());
322
323        let child = at(&links, &[Seg::Key("contents".into()), Seg::Index(0)]);
324        assert_eq!(child.display(), "Child");
325        assert_eq!(child.target(), "child.md");
326        assert_eq!(child.link.render(), "[Child](child.md)");
327
328        // A wikilink is a wikilink, not a literal — this is the relation field
329        // where prov permits the wrapper.
330        let other = at(&links, &[Seg::Key("contents".into()), Seg::Index(1)]);
331        assert!(other.link.wikilink);
332        assert_eq!(other.display(), "Other");
333        assert_eq!(other.link.render(), "[[notes/other.md|Other]]");
334    }
335
336    #[test]
337    fn a_target_is_classified_by_syntax_alone() {
338        let meta = meta();
339        let links = links_in(&meta, &facets());
340
341        assert_eq!(
342            at(&links, &[Seg::Key("contents".into()), Seg::Index(0)]).kind,
343            TargetKind::Path
344        );
345        assert_eq!(
346            at(&links, &[Seg::Key("contents".into()), Seg::Index(2)]).kind,
347            TargetKind::Id
348        );
349        assert_eq!(
350            at(&links, &[Seg::Key("links".into()), Seg::Index(0)]).kind,
351            TargetKind::External
352        );
353        assert_eq!(
354            at(&links, &[Seg::Key("links".into()), Seg::Index(1)]).kind,
355            TargetKind::SameDocument
356        );
357        assert_eq!(
358            at(&links, &[Seg::Key("links".into()), Seg::Index(2)]).kind,
359            TargetKind::Foreign {
360                workspace: "otherws".into()
361            }
362        );
363        assert!(at(&links, &[Seg::Key("links".into()), Seg::Index(2)]).leaves_the_workspace());
364    }
365
366    /// The vocabulary is what says a key is a link, so the spanning backbone and
367    /// the one-way machinery pointer arrive marked as what they are — which is
368    /// the difference between "open this document" and "open the workspace's
369    /// config".
370    #[test]
371    fn a_link_carries_what_its_relation_is() {
372        let meta = meta();
373        let links = links_in(&meta, &facets());
374
375        let child = at(&links, &[Seg::Key("contents".into()), Seg::Index(0)]);
376        assert!(child.relation.spanning);
377        assert!(!child.relation.pointer);
378
379        let config = at(&links, &[Seg::Key("config".into())]);
380        assert!(config.relation.pointer);
381        assert!(!config.relation.spanning);
382    }
383
384    #[test]
385    fn the_cursor_question_is_exact() {
386        let meta = meta();
387        let facets = facets();
388
389        assert!(
390            link_at(&meta, &facets, &[Seg::Key("part_of".into())]).is_some(),
391            "a scalar relation row is a link"
392        );
393        assert!(
394            link_at(&meta, &facets, &[Seg::Key("contents".into())]).is_none(),
395            "standing on the list is not standing on a link"
396        );
397        assert_eq!(
398            links_under(&meta, &facets, &[Seg::Key("contents".into())]).len(),
399            3,
400            "the list's own links, for a caller that asks for them"
401        );
402    }
403
404    /// A relation whose value is a list holding something that is not a string
405    /// is a document to fix, not a link to invent.
406    #[test]
407    fn a_non_string_item_is_skipped_rather_than_guessed_at() {
408        const ODD: &str = "---\ncontents:\n- ok.md\n- {a: b}\n---\n# x\n";
409        let doc = Document::parse("note.md", ODD).expect("parse");
410        let links = links_in(&Value::from(&doc.meta), &facets());
411        assert_eq!(links.len(), 1);
412        assert_eq!(links[0].target(), "ok.md");
413    }
414}