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;