Skip to main content

provui_core/
findings.rs

1//! What is wrong with a document, placed where an editor can draw it.
2//!
3//! prov's [`Finding`](prov::Finding) is a workspace-level answer: a broken link,
4//! a term outside a closed vocabulary, a child that does not link back. It names
5//! the document and — for the link findings — the *site*, which is either a
6//! relation's name or a byte span in the body. That is exactly the right shape
7//! for a report and one step short of what an editor needs, which is a place in
8//! one of its two panes.
9//!
10//! This module is that step. A [`Site`] is either a metadata path (the same
11//! `Vec<Seg>` a [`MetaLink`](crate::MetaLink) and a flower row carry), a byte
12//! range in the body (the same coordinates [`crate::BodyLink`] and leaf's caret
13//! are in), or the document as a whole — for the findings that are about the
14//! file rather than about anything written in it.
15//!
16//! ## Nothing is lost on the way any more
17//!
18//! Two things this module used to reconstruct, prov now states. A
19//! `LinkSite::Relation` carries the list **index** beside the field name, so a
20//! broken third item of a `contents:` list arrives as `contents[2]` rather than
21//! as "somewhere in `contents`" — this module used to match the finding's
22//! target text against the document's own links to recover it, and gave up on
23//! a list naming one target twice. And a finding carries its own
24//! [severity](prov::Finding::severity), drawn on the same line this crate drew
25//! it — drift or advice is a warning, a broken structure is an error — so the
26//! list of warning kinds that lived here is gone with the reason for it. A
27//! finding prov adds arrives with prov's own judgement of how loud it is.
28
29use std::path::Path;
30
31use flower_core::Seg;
32use prov::prov_graph::field::Step;
33
34/// Where in a document a finding sits.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Site {
37    /// In the metadata, at this path — `[Key("part_of")]`, or
38    /// `[Key("contents"), Index(2)]` where the item was recoverable. The path a
39    /// frontend compares against its metadata cursor.
40    Meta(Vec<Seg>),
41    /// In the prose body, at this byte range — the same coordinates
42    /// [`crate::BodyLink::span`] and leaf's caret use, so a finding about a body
43    /// link washes under it without conversion.
44    Body(std::ops::Range<usize>),
45    /// About the document rather than about anything written in it: an
46    /// unreadable file, a fixity mismatch, an orphan, a config key prov ignores.
47    Document,
48}
49
50/// How loudly to say it — prov's own line, restated as this crate's type so a
51/// frontend does not depend on prov to draw a marker.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54    /// Something is broken: a link resolves to nothing, a closed vocabulary is
55    /// violated, a document cannot be read.
56    Error,
57    /// Something has drifted or is being advised against, and nothing is broken:
58    /// a near-miss spelling in an open vocabulary, a link that resolves only
59    /// case-insensitively, a stale label, a confirmation older than the document.
60    Warning,
61}
62
63/// One of prov's findings, placed.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Finding {
66    /// Where in the document it sits.
67    pub site: Site,
68    /// How loudly to say it — see [`Severity`].
69    pub severity: Severity,
70    /// prov's own sentence about it, with the leading `path: ` dropped where it
71    /// was there: a per-document panel already knows which document it is
72    /// showing, and repeating the path in every row costs the width the message
73    /// needs.
74    pub message: String,
75    /// prov's stable snake_case name for the kind
76    /// ([`Finding::kind`](prov::Finding::kind)) — `broken_link`, `unknown_term`,
77    /// … For a frontend that branches on the kind rather than reading the prose,
78    /// and the `id` an applied highlight carries.
79    pub kind: &'static str,
80}
81
82/// Place one of prov's findings, and translate it.
83///
84/// `subject` is the document the finding is lodged against
85/// ([`Finding::subject`](prov::Finding::subject)), used to trim the message's
86/// path prefix.
87pub fn place(finding: &prov::Finding, subject: &Path) -> Finding {
88    Finding {
89        site: site_of(finding),
90        severity: match finding.severity() {
91            prov::Severity::Warning => Severity::Warning,
92            prov::Severity::Error => Severity::Error,
93        },
94        message: trim_subject(&finding.to_string(), subject),
95        kind: finding.kind(),
96    }
97}
98
99/// Where a finding sits: the relation row — and the item in it, where prov
100/// counted one — or the body span, or the document.
101pub fn site_of(finding: &prov::Finding) -> Site {
102    let Some(site) = link_site(finding) else {
103        return field_site(finding);
104    };
105    match site {
106        prov::LinkSite::Body(span) => Site::Body(span.clone()),
107        prov::LinkSite::Relation { field, index } => {
108            let mut path = vec![Seg::Key(field.clone())];
109            if let Some(i) = index {
110                path.push(Seg::Index(*i));
111            }
112            Site::Meta(path)
113        }
114        // A path-valued field (`type: ref`) at its concrete address —
115        // `sources[2].resource` — which prov parses back into the steps an
116        // editor path takes.
117        prov::LinkSite::Field { .. } => match site.address() {
118            Some(address) => Site::Meta(
119                address
120                    .steps()
121                    .iter()
122                    .map(|step| match step {
123                        Step::Key(key) => Seg::Key(key.clone()),
124                        Step::At(i) => Seg::Index(*i),
125                        Step::Each => unreachable!("an address has no `[]` step"),
126                    })
127                    .collect(),
128            ),
129            None => Site::Document,
130        },
131    }
132}
133
134/// The link site a finding carries — `None` for a finding that is not about a
135/// link at all.
136fn link_site(finding: &prov::Finding) -> Option<&prov::LinkSite> {
137    use prov::Finding as F;
138    match finding {
139        F::BrokenLink { site, .. }
140        | F::CaseMismatch { site, .. }
141        | F::MalformedId { site, .. }
142        | F::StaleLabel { site, .. }
143        | F::DanglingId { site, .. }
144        | F::AmbiguousAlias { site, .. } => Some(site),
145        _ => None,
146    }
147}
148
149/// The metadata site of a finding that names a *field* rather than a link site,
150/// and [`Site::Document`] for everything else.
151fn field_site(finding: &prov::Finding) -> Site {
152    use prov::Finding as F;
153    match finding {
154        F::UnknownTerm { field, .. } | F::TermNearMiss { field, .. } => {
155            Site::Meta(vec![Seg::Key(field.clone())])
156        }
157        F::FieldScopeUnresolved { field, .. } => Site::Meta(vec![Seg::Key(field.clone())]),
158        _ => Site::Document,
159    }
160}
161
162/// prov's `Display` begins every message with the path of the document it is
163/// about. A per-document panel knows that already.
164fn trim_subject(message: &str, subject: &Path) -> String {
165    let prefix = format!("{}: ", subject.display());
166    message.strip_prefix(&prefix).unwrap_or(message).to_string()
167}