Skip to main content

molframe_core/diagnostic/
finding.rs

1//! A finding about data, and the list they accumulate into.
2//!
3//! A finding always carries a code, and through the code a cause and a remedy —
4//! neither is something a call site can forget. The message is optional and
5//! defaults to the code's registered cause, so raising a finding that needs no
6//! elaboration allocates nothing.
7
8use super::code::{Code, Severity, Strictness};
9use crate::span::ByteSpan;
10use std::fmt;
11
12/// A named piece of supporting detail attached to a finding.
13///
14/// The label is fixed by the site that raises the finding; the value comes from
15/// the data. Together they carry the specifics a static remedy cannot.
16#[derive(Clone, PartialEq, Eq, Debug)]
17pub struct ContextItem {
18    label: &'static str,
19    value: Box<str>,
20}
21
22impl ContextItem {
23    /// Creates a context item.
24    #[must_use]
25    pub fn new(label: &'static str, value: impl Into<Box<str>>) -> Self {
26        Self {
27            label,
28            value: value.into(),
29        }
30    }
31
32    /// The fixed label.
33    #[must_use]
34    pub const fn label(&self) -> &'static str {
35        self.label
36    }
37
38    /// The value taken from the data.
39    #[must_use]
40    pub fn value(&self) -> &str {
41        &self.value
42    }
43}
44
45/// One finding about the data.
46///
47/// A finding always has a code, and through the code always has a cause and a
48/// remedy — neither is something a call site can forget to supply. The message
49/// is optional and defaults to the code's registered cause, so raising a finding
50/// that needs no elaboration allocates nothing.
51///
52/// # Examples
53///
54/// ```
55/// use molframe_core::{Code, Diagnostic, Severity};
56///
57/// let finding = Diagnostic::new(Code::W3203).in_category("atom_site").at_row(17);
58/// assert_eq!(finding.severity(), Severity::Loose);
59/// assert_eq!(finding.message(), "element inferred from the atom name");
60/// assert!(!finding.remedy().is_empty());
61/// ```
62#[derive(Clone, PartialEq, Eq, Debug)]
63pub struct Diagnostic {
64    code: Code,
65    severity: Severity,
66    row_present: bool,
67    row: u64,
68    message: Option<Box<str>>,
69    span: Option<Box<ByteSpan>>,
70    category: Option<Box<str>>,
71    field: Option<Box<str>>,
72    context: Vec<ContextItem>,
73}
74
75impl Diagnostic {
76    /// Heap bytes retained by this occurrence's owned details.
77    #[must_use]
78    pub fn retained_bytes(&self) -> usize {
79        let text = self.message.as_ref().map_or(0, |value| value.len())
80            + self.category.as_ref().map_or(0, |value| value.len())
81            + self.field.as_ref().map_or(0, |value| value.len())
82            + self
83                .context
84                .iter()
85                .map(|item| item.value.len())
86                .sum::<usize>();
87        text.saturating_add(
88            self.context
89                .capacity()
90                .saturating_mul(std::mem::size_of::<ContextItem>()),
91        )
92        .saturating_add(
93            self.span
94                .as_ref()
95                .map_or(0, |_| std::mem::size_of::<ByteSpan>()),
96        )
97    }
98
99    /// Creates a finding carrying the code's registered severity and cause.
100    #[must_use]
101    pub fn new(code: Code) -> Self {
102        Self {
103            code,
104            severity: code.severity(),
105            row_present: false,
106            row: 0,
107            message: None,
108            span: None,
109            category: None,
110            field: None,
111            context: Vec::new(),
112        }
113    }
114
115    /// Replaces the message with one describing this occurrence.
116    ///
117    /// Use it when the specifics matter; leave it alone when the registered
118    /// cause already says everything there is to say.
119    #[must_use]
120    pub fn with_message(mut self, message: impl Into<Box<str>>) -> Self {
121        self.message = Some(message.into());
122        self
123    }
124
125    /// Overrides the severity for this occurrence.
126    ///
127    /// The registry decides severity in general. This exists for the cases where
128    /// the same code is genuinely worse in one context than another.
129    #[must_use]
130    pub const fn with_severity(mut self, severity: Severity) -> Self {
131        self.severity = severity;
132        self
133    }
134
135    /// Records where in the source this finding was raised.
136    #[must_use]
137    pub fn at(mut self, span: ByteSpan) -> Self {
138        self.span = Some(Box::new(span));
139        self
140    }
141
142    /// Records the category this finding concerns.
143    #[must_use]
144    pub fn in_category(mut self, category: impl Into<Box<str>>) -> Self {
145        self.category = Some(category.into());
146        self
147    }
148
149    /// Records the item this finding concerns.
150    #[must_use]
151    pub fn about_field(mut self, field: impl Into<Box<str>>) -> Self {
152        self.field = Some(field.into());
153        self
154    }
155
156    /// Records the row this finding concerns.
157    #[must_use]
158    pub const fn at_row(mut self, row: u64) -> Self {
159        self.row = row;
160        self.row_present = true;
161        self
162    }
163
164    /// Attaches a labelled detail.
165    #[must_use]
166    pub fn with_context(mut self, label: &'static str, value: impl Into<Box<str>>) -> Self {
167        self.context.push(ContextItem::new(label, value));
168        self
169    }
170
171    /// The stable code.
172    #[must_use]
173    pub const fn code(&self) -> Code {
174        self.code
175    }
176
177    /// How bad this finding is.
178    #[must_use]
179    pub const fn severity(&self) -> Severity {
180        self.severity
181    }
182
183    /// What happened.
184    #[must_use]
185    pub fn message(&self) -> &str {
186        match &self.message {
187            Some(message) => message,
188            None => self.code.cause(),
189        }
190    }
191
192    /// What to do about it.
193    #[must_use]
194    pub fn remedy(&self) -> &'static str {
195        self.code.remedy()
196    }
197
198    /// Where in the source this was raised, if a position was known.
199    #[must_use]
200    pub fn span(&self) -> Option<ByteSpan> {
201        self.span.as_deref().copied()
202    }
203
204    /// The category this concerns, if any.
205    #[must_use]
206    pub fn category(&self) -> Option<&str> {
207        self.category.as_deref()
208    }
209
210    /// The item this concerns, if any.
211    #[must_use]
212    pub fn field(&self) -> Option<&str> {
213        self.field.as_deref()
214    }
215
216    /// The row this concerns, if any.
217    #[must_use]
218    pub const fn row(&self) -> Option<u64> {
219        if self.row_present {
220            Some(self.row)
221        } else {
222            None
223        }
224    }
225
226    /// The labelled details attached to this finding.
227    #[must_use]
228    pub fn context(&self) -> &[ContextItem] {
229        &self.context
230    }
231
232    /// Returns true when this finding is an error at the given strictness.
233    #[must_use]
234    pub const fn is_error(&self, strictness: Strictness) -> bool {
235        self.severity.is_error(strictness)
236    }
237
238    /// The key findings are ordered by: worst first, then earliest in the file,
239    /// then by code.
240    fn order_key(&self) -> (std::cmp::Reverse<Severity>, u64, Code) {
241        let offset = self
242            .span
243            .as_deref()
244            .map_or(u64::MAX, |span| span.start.byte_offset);
245        (std::cmp::Reverse(self.severity), offset, self.code)
246    }
247}
248
249impl fmt::Display for Diagnostic {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        write!(
252            f,
253            "{}[{}]: {}",
254            self.severity.label(),
255            self.code,
256            self.message()
257        )
258    }
259}
260
261/// A growing list of findings that keeps itself in reproducible order.
262///
263/// Callers push in whatever order problems are noticed; [`Diagnostics::finish`]
264/// imposes the order that makes two runs comparable.
265///
266/// The list is bounded. Past its ceiling, findings are counted rather than
267/// retained, and [`Diagnostics::finish`] appends one `MOLFRAME-W1901` recording
268/// how many were suppressed. The worst severity still reflects every finding
269/// pushed, retained or not, so a decision taken on severity is unaffected by
270/// the ceiling.
271#[derive(Clone, Debug)]
272pub struct Diagnostics {
273    findings: Vec<Diagnostic>,
274    worst: Option<Severity>,
275    ceiling: usize,
276    suppressed: u64,
277}
278
279impl Default for Diagnostics {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285impl Diagnostics {
286    /// How many findings a list retains before it starts counting instead.
287    ///
288    /// A systematically malformed column raises one finding per row, and a
289    /// finding carries a message, a category, a field and a context vector —
290    /// roughly ninety bytes and up to four allocations each. At a hundred
291    /// million rows that is nine gigabytes of diagnostics describing a single
292    /// cause, which exhausts memory reporting a problem rather than reporting
293    /// it.
294    ///
295    /// Ten thousand is far past the point where a reader learns anything new
296    /// and far below the point where the list itself is the problem.
297    pub const DEFAULT_CEILING: usize = 10_000;
298
299    /// Creates an empty list with the default ceiling.
300    #[must_use]
301    pub const fn new() -> Self {
302        Self {
303            findings: Vec::new(),
304            worst: None,
305            ceiling: Self::DEFAULT_CEILING,
306            suppressed: 0,
307        }
308    }
309
310    /// Creates a list with room for `capacity` findings.
311    ///
312    /// Worth doing when a permissive parse over a known-messy file is expected
313    /// to raise many, so the list does not grow by repeated reallocation. The
314    /// ceiling is raised to `capacity` where that is higher than the default,
315    /// because a caller who has reserved the room has said what they want.
316    #[must_use]
317    pub fn with_capacity(capacity: usize) -> Self {
318        Self {
319            findings: Vec::with_capacity(capacity),
320            worst: None,
321            ceiling: capacity.max(Self::DEFAULT_CEILING),
322            suppressed: 0,
323        }
324    }
325
326    /// Sets how many findings this list retains before counting instead.
327    #[must_use]
328    pub const fn with_ceiling(mut self, ceiling: usize) -> Self {
329        self.ceiling = ceiling;
330        self
331    }
332
333    /// How many findings this list retains before counting instead.
334    #[must_use]
335    pub const fn ceiling(&self) -> usize {
336        self.ceiling
337    }
338
339    /// How many findings were counted rather than retained.
340    #[must_use]
341    pub const fn suppressed(&self) -> u64 {
342        self.suppressed
343    }
344
345    /// Records a finding.
346    ///
347    /// Past the ceiling the finding is counted and dropped. The severity is
348    /// recorded either way, so nothing that depends on the worst severity
349    /// changes with the ceiling.
350    pub fn push(&mut self, finding: Diagnostic) {
351        self.worst = Some(match self.worst {
352            Some(worst) if worst >= finding.severity => worst,
353            _ => finding.severity,
354        });
355        if self.findings.len() >= self.ceiling {
356            self.suppressed = self.suppressed.saturating_add(1);
357            return;
358        }
359        self.findings.push(finding);
360    }
361
362    /// The worst severity recorded so far, if anything has been.
363    #[must_use]
364    pub const fn worst(&self) -> Option<Severity> {
365        self.worst
366    }
367
368    /// Returns true when any recorded finding is an error at this strictness.
369    #[must_use]
370    pub fn has_error(&self, strictness: Strictness) -> bool {
371        self.worst.is_some_and(|worst| worst.is_error(strictness))
372    }
373
374    /// The findings recorded so far, in the order they were pushed.
375    #[must_use]
376    pub fn as_slice(&self) -> &[Diagnostic] {
377        &self.findings
378    }
379
380    /// Returns true when nothing has been recorded.
381    #[must_use]
382    pub fn is_empty(&self) -> bool {
383        self.findings.is_empty()
384    }
385
386    /// The number of findings recorded.
387    #[must_use]
388    pub fn len(&self) -> usize {
389        self.findings.len()
390    }
391
392    /// Consumes the list, returning the findings in reproducible order.
393    ///
394    /// The sort is stable, so two findings identical on every ordering key stay
395    /// in the order they were raised.
396    #[must_use]
397    pub fn finish(mut self) -> Vec<Diagnostic> {
398        self.findings.sort_by_key(Diagnostic::order_key);
399        if self.suppressed != 0 {
400            self.findings.push(
401                Diagnostic::new(Code::W1901)
402                    .with_context("suppressed", self.suppressed.to_string())
403                    .with_context("retained", self.findings.len().to_string()),
404            );
405        }
406        self.findings
407    }
408}
409
410impl Extend<Diagnostic> for Diagnostics {
411    fn extend<T: IntoIterator<Item = Diagnostic>>(&mut self, iter: T) {
412        for finding in iter {
413            self.push(finding);
414        }
415    }
416}
417
418#[cfg(test)]
419#[path = "finding_tests.rs"]
420mod tests;