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}