Skip to main content

flower_core/
annotate.rs

1//! Per-row findings: what the *host* has to say about a node, drawn beside it.
2//!
3//! flower-core validates a value against a [`Schema`](crate::Schema) at the
4//! commit funnel, which answers "may this be written?" one edit at a time. An
5//! [`Annotation`] answers the other question — "what is wrong with the document
6//! as it stands?" — and flower cannot answer it: a broken link, a duplicate id,
7//! a containment cycle are facts about a *workspace*, and flower-core is
8//! single-document and has no filesystem. So the host computes them and hands
9//! them over, the way it hands over hidden keys, demoted keys, and a schema.
10//!
11//! They are host state, not document state. Nothing here is written to the
12//! file, nothing here survives a reopen, and an edit does not clear them: the
13//! model re-attaches whatever it was last given on every rebuild, so a row
14//! keeps its marker while the reader types. What an edit *does* invalidate is
15//! whether the finding is still true, and only the host can re-run the check
16//! that decided — so a host refreshes them after a save (or whenever its check
17//! finishes) by calling [`Model::set_annotations`](crate::Model::set_annotations)
18//! again.
19
20use crate::tree::Seg;
21
22/// How loudly a finding reads.
23///
24/// Deliberately three, and deliberately not a number: a host with five levels
25/// maps them down, and a renderer with one glyph per level never has to guess
26/// where the cut is.
27///
28/// Named `Severity` inside this module and not re-exported at the crate root,
29/// where the name is already fig-schema's
30/// [`Severity`](fig_schema::Severity) — a different fact, about what *changing*
31/// a field costs rather than about what is wrong with it now. Two things worth
32/// telling apart are worth two names, and `annotate::Severity` is the one that
33/// moved.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35pub enum Severity {
36    /// The document is wrong: a link that resolves to nothing, a required
37    /// field that is absent.
38    Error,
39    /// The document is suspicious: a retired term, a relation with no inverse.
40    Warning,
41    /// Something worth knowing and nothing to fix.
42    Info,
43}
44
45/// One host finding, addressed at a node by the same path everything else here
46/// is addressed by.
47///
48/// The path need not name a row that exists. A finding about a key the document
49/// has since lost is simply never matched — dropping it would make the host
50/// responsible for pruning its own list against a tree it does not own.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Annotation {
53    /// The fig path of the node this is about. The empty path is the document.
54    pub path: Vec<Seg>,
55    pub severity: Severity,
56    /// One line, written for the person looking at the row. A renderer with a
57    /// status bar shows it there; one with room shows it under the row.
58    pub message: String,
59}
60
61impl Annotation {
62    /// A finding at `path`.
63    pub fn new(path: Vec<Seg>, severity: Severity, message: impl Into<String>) -> Self {
64        Self {
65            path,
66            severity,
67            message: message.into(),
68        }
69    }
70
71    /// Shorthand for an [`Severity::Error`] at `path`.
72    pub fn error(path: Vec<Seg>, message: impl Into<String>) -> Self {
73        Self::new(path, Severity::Error, message)
74    }
75
76    /// Shorthand for a [`Severity::Warning`] at `path`.
77    pub fn warning(path: Vec<Seg>, message: impl Into<String>) -> Self {
78        Self::new(path, Severity::Warning, message)
79    }
80
81    /// Shorthand for an [`Severity::Info`] at `path`.
82    pub fn info(path: Vec<Seg>, message: impl Into<String>) -> Self {
83        Self::new(path, Severity::Info, message)
84    }
85}
86
87/// The finding that applies at `path`: the one addressed exactly at it, else
88/// the one addressed at its nearest annotated ancestor.
89///
90/// The fallback is what makes a finding about a list answer for a question
91/// asked about an item of it — a host inspecting `contents.3` and finding
92/// nothing there wants to know that `contents` is in trouble. It is *not* how
93/// rows are marked ([`Model::annotation_at`](crate::Model::annotation_at)
94/// versus what [`build_page`](crate::page::build_page)'s rows carry): a marker
95/// inherited down a subtree would put an error glyph on ninety-five rows
96/// because one of them was wrong, and the row that is wrong is the one worth
97/// pointing at.
98///
99/// Among equals — two findings at the same path — the first given wins, so a
100/// host's own ordering decides.
101pub fn applying_at<'a>(annotations: &'a [Annotation], path: &[Seg]) -> Option<&'a Annotation> {
102    if let Some(exact) = annotations.iter().find(|a| a.path == path) {
103        return Some(exact);
104    }
105    annotations
106        .iter()
107        .filter(|a| a.path.len() < path.len() && path.starts_with(&a.path))
108        .max_by_key(|a| a.path.len())
109}
110
111/// The finding addressed exactly at `path` — what a row carries.
112pub fn exactly_at<'a>(annotations: &'a [Annotation], path: &[Seg]) -> Option<&'a Annotation> {
113    annotations.iter().find(|a| a.path == path)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn key(k: &str) -> Seg {
121        Seg::Key(k.to_string())
122    }
123
124    #[test]
125    fn an_exact_finding_beats_an_ancestors() {
126        let annotations = vec![
127            Annotation::warning(vec![key("contents")], "two of these are missing"),
128            Annotation::error(vec![key("contents"), Seg::Index(3)], "resolves to nothing"),
129        ];
130        let item = [key("contents"), Seg::Index(3)];
131        assert_eq!(
132            applying_at(&annotations, &item).map(|a| a.severity),
133            Some(Severity::Error)
134        );
135        // An item with no finding of its own inherits the list's, for a caller
136        // asking — and carries none of its own, for a renderer marking.
137        let other = [key("contents"), Seg::Index(0)];
138        assert_eq!(
139            applying_at(&annotations, &other).map(|a| a.severity),
140            Some(Severity::Warning)
141        );
142        assert_eq!(exactly_at(&annotations, &other), None);
143        assert!(applying_at(&annotations, &[key("title")]).is_none());
144    }
145
146    #[test]
147    fn the_nearest_ancestor_is_the_one_that_answers() {
148        let annotations = vec![
149            Annotation::info(Vec::new(), "the document"),
150            Annotation::warning(vec![key("a")], "the outer"),
151            Annotation::error(vec![key("a"), key("b")], "the inner"),
152        ];
153        let deep = [key("a"), key("b"), key("c")];
154        assert_eq!(
155            applying_at(&annotations, &deep).map(|a| a.message.as_str()),
156            Some("the inner")
157        );
158    }
159}