Skip to main content

proef_core/
diag.rs

1//! Source-located diagnostics with stable codes (ADR-0009, TECH-SPEC §9).
2//!
3//! The core produces structured [`Diag`]s; **only `proef-cli` renders them**
4//! (miette stays out of library crates). Every diagnostic carries a stable,
5//! greppable code (`proef::pack::adjacent_captures`, …) — the seeded error
6//! corpus names one file per code (TESTING-STRATEGY §4).
7//!
8//! Spans are 0-based **byte** offsets, end-exclusive — directly convertible to
9//! miette's `SourceSpan`. Gherkin span caveats (trailing-newline normalization,
10//! char-counted `LineCol`) are handled where spans are produced, never here.
11
12use std::sync::Arc;
13
14/// A byte span into a source text (0-based, end-exclusive).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Span {
17    /// First byte of the region.
18    pub start: usize,
19    /// One past the last byte of the region.
20    pub end: usize,
21}
22
23impl Span {
24    /// A span clamped into `len` (guards against parser spans past a
25    /// normalized/appended trailing newline).
26    pub fn clamped(start: usize, end: usize, len: usize) -> Self {
27        let start = start.min(len);
28        Self {
29            start,
30            end: end.clamp(start, len),
31        }
32    }
33
34    /// Length in bytes.
35    pub fn len(&self) -> usize {
36        self.end - self.start
37    }
38
39    /// Whether the span is empty.
40    pub fn is_empty(&self) -> bool {
41        self.start == self.end
42    }
43}
44
45/// Diagnostic severity.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Severity {
48    /// Blocks the run (exit 2 — user fault).
49    Error,
50    /// Surfaced, does not block.
51    Warning,
52}
53
54/// One structured, source-located finding.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Diag {
57    /// Stable, greppable code (`proef::pack::…`, `proef::feature::…`, …).
58    pub code: &'static str,
59    /// Severity.
60    pub severity: Severity,
61    /// Human-readable message.
62    pub message: String,
63    /// Name of the source this points into (file path as authored), if any.
64    pub source_name: Option<String>,
65    /// The (normalized) source text, shared across diags of one file.
66    pub source_text: Option<Arc<str>>,
67    /// Labeled byte region within `source_text`.
68    pub span: Option<Span>,
69    /// Remediation hint.
70    pub help: Option<String>,
71}
72
73impl Diag {
74    /// An error diagnostic.
75    pub fn error(code: &'static str, message: impl Into<String>) -> Self {
76        Self {
77            code,
78            severity: Severity::Error,
79            message: message.into(),
80            source_name: None,
81            source_text: None,
82            span: None,
83            help: None,
84        }
85    }
86
87    /// A warning diagnostic.
88    pub fn warning(code: &'static str, message: impl Into<String>) -> Self {
89        Self {
90            severity: Severity::Warning,
91            ..Self::error(code, message)
92        }
93    }
94
95    /// Attach the source this diagnostic points into.
96    #[must_use]
97    pub fn with_source(mut self, name: impl Into<String>, text: Arc<str>) -> Self {
98        self.source_name = Some(name.into());
99        self.source_text = Some(text);
100        self
101    }
102
103    /// Attach a labeled span (clamped by the caller against the source length).
104    #[must_use]
105    pub fn with_span(mut self, span: Span) -> Self {
106        self.span = Some(span);
107        self
108    }
109
110    /// Attach a remediation hint.
111    #[must_use]
112    pub fn with_help(mut self, help: impl Into<String>) -> Self {
113        self.help = Some(help.into());
114        self
115    }
116}
117
118/// Outcome of a front-end stage: diagnostics (user fault, exit 2 when any is an
119/// error) or a non-diagnostic core failure.
120#[derive(Debug, thiserror::Error)]
121pub enum FrontError {
122    /// Structured findings to render (at least one has [`Severity::Error`]).
123    #[error("{} diagnostic(s)", .0.len())]
124    Diagnostics(Vec<Diag>),
125    /// An IO or internal failure outside the diagnostics model.
126    #[error(transparent)]
127    Core(#[from] crate::error::CoreError),
128}
129
130impl FrontError {
131    /// The stable exit code for this failure (ADR-0009).
132    pub fn exit_code(&self) -> crate::error::ExitCode {
133        match self {
134            Self::Diagnostics(_) => crate::error::ExitCode::UserError,
135            Self::Core(err) => err.exit_code(),
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn spans_clamp_into_the_source() {
146        let span = Span::clamped(5, 12, 8);
147        assert_eq!((span.start, span.end), (5, 8));
148        let span = Span::clamped(10, 12, 8);
149        assert!(span.is_empty());
150    }
151
152    #[test]
153    fn front_error_maps_to_user_error() {
154        let err = FrontError::Diagnostics(vec![Diag::error("proef::test::x", "boom")]);
155        assert_eq!(err.exit_code().code(), 2);
156    }
157}