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}