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}