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/// Classify a target string by syntax. The order matters: `id:` handles are
108/// checked before anything else because `id:ajp7eq` is also a syntactically
109/// valid relative path, and prov reads the scheme first.
110fn kind_of(link: &Link) -> TargetKind {
111    if link.is_same_document() {
112        return TargetKind::SameDocument;
113    }
114    match link.id_ref() {
115        Some(IdRef::Local(_)) => return TargetKind::Id,
116        Some(IdRef::Foreign { workspace, .. }) => {
117            return TargetKind::Foreign {
118                workspace: workspace.to_string(),
119            };
120        }
121        Some(IdRef::Malformed) => return TargetKind::MalformedId,
122        None => {}
123    }
124    if link.is_external() {
125        return TargetKind::External;
126    }
127    TargetKind::Path
128}
129
130/// Every link `meta` declares, in relation order and then in list order.
131///
132/// A relation field holding a scalar yields one link at `[Key(name)]`; one
133/// holding a sequence yields one per **string** item, at `[Key(name),
134/// Index(i)]`. A non-string item is skipped rather than guessed at: prov reads a
135/// relation's targets as strings, and a map inside a `contents:` list is a
136/// document that needs fixing, not a link this crate should invent.
137pub fn links_in(meta: &Value, facets: &Facets) -> Vec<MetaLink> {
138    let mut links = Vec::new();
139    for relation in facets.relations().relations() {
140        let Facet::Relation(facet) = facets.of_key(&relation.name) else {
141            continue;
142        };
143        let Some(value) = meta.get(relation.name.as_str()) else {
144            continue;
145        };
146        let at = |path: Vec<Seg>, raw: &str| MetaLink {
147            path,
148            relation: facet.clone(),
149            link: parse(raw),
150            kind: TargetKind::Path, // replaced below; `parse` is needed first
151        };
152        match value {
153            Value::Seq(items) => {
154                for (index, item) in items.iter().enumerate() {
155                    if let Some(raw) = item.as_str() {
156                        let path = vec![Seg::Key(relation.name.clone()), Seg::Index(index)];
157                        links.push(finish(at(path, raw)));
158                    }
159                }
160            }
161            other => {
162                if let Some(raw) = other.as_str() {
163                    let path = vec![Seg::Key(relation.name.clone())];
164                    links.push(finish(at(path, raw)));
165                }
166            }
167        }
168    }
169    links
170}
171
172/// The link at exactly `path`, if there is one — the cursor question.
173///
174/// Exact, not prefix: standing on the `contents` row itself is standing on a
175/// *list*, not on a link, and answering with its first item would follow
176/// somewhere the cursor was not. A frontend that wants "the list's first link"
177/// asks [`links_under`] instead and says so.
178pub fn link_at(meta: &Value, facets: &Facets, path: &[Seg]) -> Option<MetaLink> {
179    links_in(meta, facets)
180        .into_iter()
181        .find(|link| link.path == path)
182}
183
184/// Every link at or below `path` — the list under a relation row, or the one
185/// link a scalar relation row holds.
186pub fn links_under(meta: &Value, facets: &Facets, path: &[Seg]) -> Vec<MetaLink> {
187    links_in(meta, facets)
188        .into_iter()
189        .filter(|link| link.path.starts_with(path))
190        .collect()
191}
192
193/// Parse a relation target.
194///
195/// [`Link::parse`](prov::Link::parse), not `parse_path_only`: a relation field
196/// is exactly where prov permits the Obsidian `[[target]]` wrapper, and reading
197/// one as a literal path would make a wikilink vault's every link unfollowable.
198/// The opt-out exists for path *properties*, which relation fields are not.
199fn parse(raw: &str) -> Link {
200    Link::parse(raw)
201}
202
203/// Fill in the kind, which needs the parsed link.
204fn finish(mut link: MetaLink) -> MetaLink {
205    link.kind = kind_of(&link.link);
206    link
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use prov::{Document, WorkspaceConfig};
213
214    const DOC: &str = "\
215---
216title: A Note
217contents:
218- '[Child](child.md)'
219- '[[notes/other.md|Other]]'
220- 'id:ajp7eq'
221part_of: '[Root](/README.md)'
222links:
223- 'https://example.com/'
224- '#section-2'
225- 'id:otherws/bkq8fr'
226config: prov.yaml
227mood: rainy
228---
229# Note
230";
231
232    fn meta() -> Value {
233        let doc = Document::parse("notes/note.md", DOC).expect("parse");
234        Value::from(&doc.meta)
235    }
236
237    fn facets() -> Facets {
238        Facets::from_config(&WorkspaceConfig::default())
239    }
240
241    fn at(links: &[MetaLink], path: &[Seg]) -> MetaLink {
242        links
243            .iter()
244            .find(|l| l.path == path)
245            .unwrap_or_else(|| panic!("no link at {path:?}"))
246            .clone()
247    }
248
249    #[test]
250    fn every_link_knows_where_it_sits() {
251        let meta = meta();
252        let links = links_in(&meta, &facets());
253        let paths: Vec<Vec<Seg>> = links.iter().map(|l| l.path.clone()).collect();
254        assert!(paths.contains(&vec![Seg::Key("contents".into()), Seg::Index(1)]));
255        assert!(paths.contains(&vec![Seg::Key("part_of".into())]));
256        assert!(paths.contains(&vec![Seg::Key("config".into())]));
257        // `mood` is not a relation, so it contributes nothing.
258        assert!(!paths.iter().any(|p| p == &vec![Seg::Key("mood".into())]));
259    }
260
261    #[test]
262    fn the_label_and_the_wrapper_survive_the_round_trip() {
263        let meta = meta();
264        let links = links_in(&meta, &facets());
265
266        let child = at(&links, &[Seg::Key("contents".into()), Seg::Index(0)]);
267        assert_eq!(child.display(), "Child");
268        assert_eq!(child.target(), "child.md");
269        assert_eq!(child.link.render(), "[Child](child.md)");
270
271        // A wikilink is a wikilink, not a literal — this is the relation field
272        // where prov permits the wrapper.
273        let other = at(&links, &[Seg::Key("contents".into()), Seg::Index(1)]);
274        assert!(other.link.wikilink);
275        assert_eq!(other.display(), "Other");
276        assert_eq!(other.link.render(), "[[notes/other.md|Other]]");
277    }
278
279    #[test]
280    fn a_target_is_classified_by_syntax_alone() {
281        let meta = meta();
282        let links = links_in(&meta, &facets());
283
284        assert_eq!(
285            at(&links, &[Seg::Key("contents".into()), Seg::Index(0)]).kind,
286            TargetKind::Path
287        );
288        assert_eq!(
289            at(&links, &[Seg::Key("contents".into()), Seg::Index(2)]).kind,
290            TargetKind::Id
291        );
292        assert_eq!(
293            at(&links, &[Seg::Key("links".into()), Seg::Index(0)]).kind,
294            TargetKind::External
295        );
296        assert_eq!(
297            at(&links, &[Seg::Key("links".into()), Seg::Index(1)]).kind,
298            TargetKind::SameDocument
299        );
300        assert_eq!(
301            at(&links, &[Seg::Key("links".into()), Seg::Index(2)]).kind,
302            TargetKind::Foreign {
303                workspace: "otherws".into()
304            }
305        );
306        assert!(at(&links, &[Seg::Key("links".into()), Seg::Index(2)]).leaves_the_workspace());
307    }
308
309    /// The vocabulary is what says a key is a link, so the spanning backbone and
310    /// the one-way machinery pointer arrive marked as what they are — which is
311    /// the difference between "open this document" and "open the workspace's
312    /// config".
313    #[test]
314    fn a_link_carries_what_its_relation_is() {
315        let meta = meta();
316        let links = links_in(&meta, &facets());
317
318        let child = at(&links, &[Seg::Key("contents".into()), Seg::Index(0)]);
319        assert!(child.relation.spanning);
320        assert!(!child.relation.pointer);
321
322        let config = at(&links, &[Seg::Key("config".into())]);
323        assert!(config.relation.pointer);
324        assert!(!config.relation.spanning);
325    }
326
327    #[test]
328    fn the_cursor_question_is_exact() {
329        let meta = meta();
330        let facets = facets();
331
332        assert!(
333            link_at(&meta, &facets, &[Seg::Key("part_of".into())]).is_some(),
334            "a scalar relation row is a link"
335        );
336        assert!(
337            link_at(&meta, &facets, &[Seg::Key("contents".into())]).is_none(),
338            "standing on the list is not standing on a link"
339        );
340        assert_eq!(
341            links_under(&meta, &facets, &[Seg::Key("contents".into())]).len(),
342            3,
343            "the list's own links, for a caller that asks for them"
344        );
345    }
346
347    /// A relation whose value is a list holding something that is not a string
348    /// is a document to fix, not a link to invent.
349    #[test]
350    fn a_non_string_item_is_skipped_rather_than_guessed_at() {
351        const ODD: &str = "---\ncontents:\n- ok.md\n- {a: b}\n---\n# x\n";
352        let doc = Document::parse("note.md", ODD).expect("parse");
353        let links = links_in(&Value::from(&doc.meta), &facets());
354        assert_eq!(links.len(), 1);
355        assert_eq!(links[0].target(), "ok.md");
356    }
357}