Skip to main content

noyalib/
diagnostic.rs

1//! Framework-neutral structured diagnostics and optional `miette` bridges.
2//!
3//! [`Diagnostic`] is the canonical machine-readable representation used by
4//! terminal renderers, editor protocols, and service integrations. Optional
5//! adapters translate that payload into ecosystem-specific formats instead of
6//! maintaining separate error-code, help, and label tables.
7//!
8//! # Examples
9//!
10//! ```
11//! use noyalib::{DiagnosticCode, Value, from_str};
12//!
13//! let err = from_str::<Value>("key: [unclosed").unwrap_err();
14//! let diagnostic = err.diagnostic();
15//! assert_eq!(diagnostic.code(), DiagnosticCode::Parse);
16//! assert!(!diagnostic.labels().is_empty());
17//! ```
18
19// SPDX-License-Identifier: MIT OR Apache-2.0
20// Copyright (c) 2026 Noyalib. All rights reserved.
21
22use crate::prelude::{String, Vec};
23use core::fmt;
24
25#[cfg(feature = "miette")]
26use crate::spanned::Spanned;
27
28/// Stable machine-readable code for a noyalib diagnostic.
29///
30/// Codes are independent of rendered prose, so an LSP client or service can
31/// route diagnostics without parsing [`Diagnostic::message`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34pub enum DiagnosticCode {
35    /// YAML syntax could not be parsed.
36    Parse,
37    /// A value could not be serialized.
38    Serialize,
39    /// Input could not be deserialized into the requested type.
40    Deserialize,
41    /// A value had a different type from the requested one.
42    TypeMismatch,
43    /// A required field was absent.
44    MissingField,
45    /// An undeclared field was present.
46    UnknownField,
47    /// The configured recursion limit was exceeded.
48    RecursionLimit,
49    /// The alias expansion limit was exceeded.
50    RepetitionLimit,
51    /// A configurable resource budget was exceeded.
52    Budget,
53    /// An alias referred to an unavailable anchor.
54    UnknownAnchor,
55    /// A mapping contained a duplicate key.
56    DuplicateKey,
57    /// Distinct YAML keys collapsed to one string key.
58    KeyCollision,
59    /// An integer exceeded the configured representation.
60    IntegerOverflow,
61    /// A mapping key violated the scalar-key policy.
62    NonScalarKey,
63    /// Input ended before the current construct completed.
64    EndOfStream,
65    /// A single-document API received more than one document.
66    MoreThanOneDocument,
67    /// An I/O operation failed.
68    Io,
69    /// The error has no more specific stable code.
70    Other,
71}
72
73impl DiagnosticCode {
74    /// Return the stable namespaced spelling used on external protocols.
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Parse => "noyalib::parse",
79            Self::Serialize => "noyalib::serialize",
80            Self::Deserialize => "noyalib::deserialize",
81            Self::TypeMismatch => "noyalib::type_mismatch",
82            Self::MissingField => "noyalib::missing_field",
83            Self::UnknownField => "noyalib::unknown_field",
84            Self::RecursionLimit => "noyalib::recursion_limit",
85            Self::RepetitionLimit => "noyalib::repetition_limit",
86            Self::Budget => "noyalib::budget",
87            Self::UnknownAnchor => "noyalib::unknown_anchor",
88            Self::DuplicateKey => "noyalib::duplicate_key",
89            Self::KeyCollision => "noyalib::key_collision",
90            Self::IntegerOverflow => "noyalib::integer_overflow",
91            Self::NonScalarKey => "noyalib::non_scalar_key",
92            Self::EndOfStream => "noyalib::eof",
93            Self::MoreThanOneDocument => "noyalib::multi_document",
94            Self::Io => "noyalib::io",
95            Self::Other => "noyalib::error",
96        }
97    }
98}
99
100impl fmt::Display for DiagnosticCode {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106/// Protocol-neutral diagnostic severity.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108#[non_exhaustive]
109pub enum DiagnosticSeverity {
110    /// Processing cannot continue for the affected value.
111    Error,
112    /// Processing succeeded, but the input is risky or discouraged.
113    Warning,
114    /// Informational context with no required action.
115    Information,
116    /// A low-priority suggestion.
117    Hint,
118}
119
120/// A half-open byte range in the original UTF-8 source.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub struct SourceSpan {
123    offset: usize,
124    length: usize,
125}
126
127impl SourceSpan {
128    /// Create a byte span beginning at `offset` and covering `length` bytes.
129    #[must_use]
130    pub const fn new(offset: usize, length: usize) -> Self {
131        Self { offset, length }
132    }
133
134    /// Return the zero-based byte offset.
135    #[must_use]
136    pub const fn offset(self) -> usize {
137        self.offset
138    }
139
140    /// Return the span length in bytes.
141    #[must_use]
142    pub const fn length(self) -> usize {
143        self.length
144    }
145
146    /// Return the exclusive end offset, saturating on integer overflow.
147    #[must_use]
148    pub const fn end(self) -> usize {
149        self.offset.saturating_add(self.length)
150    }
151}
152
153/// A message attached to one source span.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct DiagnosticLabel {
156    span: SourceSpan,
157    message: String,
158    primary: bool,
159}
160
161impl DiagnosticLabel {
162    /// Create the primary label for a diagnostic.
163    #[must_use]
164    pub fn primary(span: SourceSpan, message: impl Into<String>) -> Self {
165        Self {
166            span,
167            message: message.into(),
168            primary: true,
169        }
170    }
171
172    /// Create a secondary context label.
173    #[must_use]
174    pub fn secondary(span: SourceSpan, message: impl Into<String>) -> Self {
175        Self {
176            span,
177            message: message.into(),
178            primary: false,
179        }
180    }
181
182    /// Return the labelled source span.
183    #[must_use]
184    pub const fn span(&self) -> SourceSpan {
185        self.span
186    }
187
188    /// Return the label text.
189    #[must_use]
190    pub fn message(&self) -> &str {
191        &self.message
192    }
193
194    /// Return whether this is the primary label.
195    #[must_use]
196    pub const fn is_primary(&self) -> bool {
197        self.primary
198    }
199}
200
201/// Framework-neutral diagnostic payload derived from an error or validator.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Diagnostic {
204    code: DiagnosticCode,
205    severity: DiagnosticSeverity,
206    message: String,
207    help: Option<String>,
208    labels: Vec<DiagnosticLabel>,
209}
210
211impl Diagnostic {
212    /// Create a diagnostic without help text or source labels.
213    #[must_use]
214    pub fn new(
215        code: DiagnosticCode,
216        severity: DiagnosticSeverity,
217        message: impl Into<String>,
218    ) -> Self {
219        Self {
220            code,
221            severity,
222            message: message.into(),
223            help: None,
224            labels: Vec::new(),
225        }
226    }
227
228    /// Attach actionable help text.
229    #[must_use]
230    pub fn with_help(mut self, help: impl Into<String>) -> Self {
231        self.help = Some(help.into());
232        self
233    }
234
235    /// Attach one source label.
236    #[must_use]
237    pub fn with_label(mut self, label: DiagnosticLabel) -> Self {
238        self.labels.push(label);
239        self
240    }
241
242    /// Return the stable machine-readable code.
243    #[must_use]
244    pub const fn code(&self) -> DiagnosticCode {
245        self.code
246    }
247
248    /// Return the protocol-neutral severity.
249    #[must_use]
250    pub const fn severity(&self) -> DiagnosticSeverity {
251        self.severity
252    }
253
254    /// Return the rendered summary message.
255    #[must_use]
256    pub fn message(&self) -> &str {
257        &self.message
258    }
259
260    /// Return optional actionable help text.
261    #[must_use]
262    pub fn help(&self) -> Option<&str> {
263        self.help.as_deref()
264    }
265
266    /// Return all labels in rendering order, primary first.
267    #[must_use]
268    pub fn labels(&self) -> &[DiagnosticLabel] {
269        &self.labels
270    }
271
272    /// Return the primary label, when the error has a source location.
273    #[must_use]
274    pub fn primary_label(&self) -> Option<&DiagnosticLabel> {
275        self.labels.iter().find(|label| label.is_primary())
276    }
277}
278
279/// A diagnostic error tied to one or more source spans.
280///
281/// Implements [`miette::Diagnostic`] so it renders with highlighted
282/// source regions in terminals that support it.
283#[derive(Debug)]
284#[cfg(feature = "miette")]
285struct SpannedDiagnostic {
286    message: String,
287    labels: Vec<miette::LabeledSpan>,
288    source_code: String,
289}
290
291#[cfg(feature = "miette")]
292impl fmt::Display for SpannedDiagnostic {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        f.write_str(&self.message)
295    }
296}
297
298#[cfg(feature = "miette")]
299impl std::error::Error for SpannedDiagnostic {}
300
301#[cfg(feature = "miette")]
302impl miette::Diagnostic for SpannedDiagnostic {
303    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
304        Some(Box::new("noyalib::validation"))
305    }
306
307    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
308        Some(&self.source_code)
309    }
310
311    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
312        if self.labels.is_empty() {
313            None
314        } else {
315            Some(Box::new(self.labels.iter().cloned()))
316        }
317    }
318}
319
320/// Create a [`miette::Report`] pointing at the source span of a
321/// [`Spanned<T>`](crate::Spanned) value.
322///
323/// This bridges noyalib's source-location tracking with miette's rich
324/// terminal diagnostic output, letting users validate deserialized
325/// config and report errors that point back to the exact YAML source.
326///
327/// # Examples
328///
329/// ```
330/// use noyalib::{from_str, Spanned, diagnostic::spanned_error};
331///
332/// #[derive(serde::Deserialize)]
333/// struct Cfg { port: Spanned<u16> }
334/// let yaml = "port: 80\n";
335/// let cfg: Cfg = from_str(yaml).unwrap();
336/// let report = spanned_error(yaml, &cfg.port, "too low");
337/// assert!(format!("{report}").contains("too low"));
338/// ```
339#[cfg(feature = "miette")]
340pub fn spanned_error<T, M: fmt::Display>(
341    source: &str,
342    span: &Spanned<T>,
343    message: M,
344) -> miette::Report {
345    let msg = message.to_string();
346    let start = span.start.index();
347    let end = span.end.index();
348    let len = if end > start { end - start } else { 1 };
349
350    miette::Report::new(SpannedDiagnostic {
351        message: msg.clone(),
352        labels: vec![miette::LabeledSpan::new(Some(msg), start, len)],
353        source_code: source.to_owned(),
354    })
355}
356
357/// Create a [`miette::Report`] pointing at a primary span with additional
358/// context from a secondary span.
359///
360/// Useful for errors involving two locations, such as an alias error that
361/// points to both the alias usage and the original anchor definition.
362///
363/// # Examples
364///
365/// ```rust,no_run
366/// # use noyalib::{from_str, Spanned, diagnostic::spanned_error_with_context};
367/// #[derive(serde::Deserialize)]
368/// struct Cfg {
369///     anchor: Spanned<String>,
370///     alias: Spanned<String>,
371/// }
372/// let yaml = "anchor: &a 1\nalias: *a";
373/// let cfg: Cfg = from_str(yaml).unwrap();
374///
375/// let report = spanned_error_with_context(
376///     yaml,
377///     &cfg.alias,
378///     "circular reference",
379///     &cfg.anchor,
380///     "defined here",
381/// );
382/// ```
383#[cfg(feature = "miette")]
384pub fn spanned_error_with_context<T, U, M: fmt::Display, C: fmt::Display>(
385    source: &str,
386    primary_span: &Spanned<T>,
387    primary_message: M,
388    context_span: &Spanned<U>,
389    context_message: C,
390) -> miette::Report {
391    let p_msg = primary_message.to_string();
392    let p_start = primary_span.start.index();
393    let p_end = primary_span.end.index();
394    let p_len = if p_end > p_start { p_end - p_start } else { 1 };
395
396    let c_msg = context_message.to_string();
397    let c_start = context_span.start.index();
398    let c_end = context_span.end.index();
399    let c_len = if c_end > c_start { c_end - c_start } else { 1 };
400
401    miette::Report::new(SpannedDiagnostic {
402        message: p_msg.clone(),
403        labels: vec![
404            miette::LabeledSpan::new(Some(p_msg), p_start, p_len),
405            miette::LabeledSpan::new(Some(c_msg), c_start, c_len),
406        ],
407        source_code: source.to_owned(),
408    })
409}
410
411#[cfg(all(test, feature = "miette"))]
412mod tests {
413    use super::*;
414    use crate::Spanned;
415
416    #[test]
417    fn spanned_error_creates_report() {
418        let yaml = "port: 80\n";
419        #[derive(serde::Deserialize)]
420        struct Cfg {
421            port: Spanned<u16>,
422        }
423        let cfg: Cfg = crate::from_str(yaml).unwrap();
424        let report = spanned_error(yaml, &cfg.port, "port must be >= 1024");
425        let msg = format!("{report}");
426        assert!(msg.contains("port must be >= 1024"));
427    }
428
429    #[test]
430    fn spanned_error_diagnostic_has_labels() {
431        use miette::Diagnostic;
432
433        let yaml = "value: 42\n";
434        #[derive(serde::Deserialize)]
435        struct Cfg {
436            value: Spanned<i32>,
437        }
438        let cfg: Cfg = crate::from_str(yaml).unwrap();
439        let report = spanned_error(yaml, &cfg.value, "too small");
440
441        // The underlying diagnostic should have labels.
442        let diag: &dyn Diagnostic = report.as_ref();
443        assert!(diag.labels().is_some());
444        assert!(diag.code().is_some());
445        assert!(diag.source_code().is_some());
446    }
447}