Skip to main content

ronin_core/
diagnostics.rs

1//! The structured-diagnostic model for error-tolerant parsing (OBJ2).
2//!
3//! A [`Diagnostic`] records a single recovery decision the parser made while
4//! building a lossless tree over malformed or incomplete input (TR-005). It
5//! never alters the tree's byte coverage — diagnostics are a parallel,
6//! side-channel report (INV-3): removing every diagnostic leaves the round-trip
7//! identity untouched.
8//!
9//! # Stable public contract (AD-003 / TR-013)
10//!
11//! [`Severity`] and [`DiagnosticCode`] are part of `ronin-core`'s 0.x public API.
12//! Each code is a stable, namespaced string. Two namespaces exist:
13//!
14//! * `RON-Pxxxx` — *parse/recovery* diagnostics emitted by `ronin-core`'s
15//!   error-tolerant parser; their [`source`](DiagnosticCode::source) is
16//!   `"ronin-core"`.
17//! * `RON-Vxxxx` — *type/validation* diagnostics emitted by the downstream
18//!   `ronin-validate` crate (E006) over a bound `TypeModel`; their
19//!   [`source`](DiagnosticCode::source) is `"ronin-types"`. `ronin-core` itself
20//!   never produces these (it stays `rowan`-only and acquires no schema), but it
21//!   owns the stable code registry so both crates agree on the strings.
22//!
23//! Codes and their severities MUST NOT be renumbered or repurposed; new
24//! situations get new codes appended to the registry within their namespace.
25//!
26//! # One diagnostic per recovery point (TR-013)
27//!
28//! The parser emits exactly one [`Diagnostic`] per distinct recovery point, with
29//! a precise source byte [`TextRange`] inside `[0, source_len)` (TR-006). The
30//! range identifies the offending span (the unexpected token, the unclosed
31//! delimiter's open bracket, or the construct that breached the depth guard).
32
33use crate::syntax::TextRange;
34
35/// Fixed severity classification for a [`Diagnostic`] (TR-013).
36///
37/// Part of the stable public API: the set of variants is closed and their
38/// meaning does not change across 0.x releases.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum Severity {
41    /// A recovery was required: the input is malformed or incomplete at this
42    /// span. The tree still covers all input via `Error`/missing nodes.
43    Error,
44    /// A non-fatal concern: the input parsed, but something is suspect. Reserved
45    /// for future lints; the OBJ2 recovery parser emits only [`Severity::Error`].
46    Warning,
47}
48
49impl Severity {
50    /// The stable lowercase label for this severity (`"error"` / `"warning"`).
51    #[inline]
52    #[must_use]
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Severity::Error => "error",
56            Severity::Warning => "warning",
57        }
58    }
59}
60
61impl std::fmt::Display for Severity {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.write_str(self.as_str())
64    }
65}
66
67/// A stable, namespaced diagnostic code from the `RON-Pxxxx` parse registry or
68/// the `RON-Vxxxx` type/validation registry (AD-003 / TR-013, E006/FR-007).
69///
70/// Every variant maps 1:1 to a fixed `RON-Pxxxx` / `RON-Vxxxx` string via
71/// [`DiagnosticCode::code`], and to a producing crate via
72/// [`DiagnosticCode::source`] (`"ronin-core"` for `RON-P`, `"ronin-types"` for
73/// `RON-V`). The enum is `#[non_exhaustive]` so new codes can be appended
74/// without a breaking change, but **existing** variants, their code strings, and
75/// their default severities are stable across 0.x. The `RON-V` validation codes
76/// are owned here as a shared registry; `ronin-core` never emits them (it stays
77/// `rowan`-only) — the downstream `ronin-validate` crate does.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
79#[non_exhaustive]
80pub enum DiagnosticCode {
81    /// `RON-P0001` — an unexpected token at a position where a value (or other
82    /// construct) was expected; the token was wrapped in an `Error` node.
83    UnexpectedToken,
84    /// `RON-P0002` — a delimiter (`(`, `[`, `{`) was opened but never closed
85    /// before end-of-input; the matching close was synthesized as missing.
86    UnclosedDelimiter,
87    /// `RON-P0003` — nesting/recursion depth exceeded the configured guard
88    /// (default 128); descent stopped and the remaining bytes were tokenized
89    /// into `Error` nodes (no stack overflow, INV-5).
90    NestingDepthExceeded,
91    /// `RON-P0004` — a `:` separator was expected in a struct field or map
92    /// entry but was absent; recovery continued with a missing separator.
93    MissingSeparator,
94    /// `RON-P0005` — a struct field or map entry was missing its value after a
95    /// separator; an empty/missing value node was recorded.
96    MissingValue,
97    /// `RON-V0001` — a value's type does not match the bound type model (e.g. a
98    /// string where an integer is expected). Emitted by `ronin-validate`
99    /// (FR-002); [`Severity::Error`].
100    TypeMismatch,
101    /// `RON-V0002` — a field required by the bound type model is absent from a
102    /// struct/map. Emitted by `ronin-validate` (FR-002); [`Severity::Error`].
103    MissingRequiredField,
104    /// `RON-V0003` — an enum variant is not one of the variants the bound type
105    /// model allows (invalid/unknown variant). Emitted by `ronin-validate`
106    /// (FR-002); [`Severity::Error`].
107    InvalidEnumVariant,
108    /// `RON-V0004` — a tuple (or tuple-struct/tuple-variant) has the wrong
109    /// arity for the bound type model. Emitted by `ronin-validate` (FR-002);
110    /// [`Severity::Error`].
111    WrongTupleArity,
112    /// `RON-V0005` — a value violates a value constraint the bound type model
113    /// expresses (out-of-range numeric, length/pattern bound, etc.). Emitted by
114    /// `ronin-validate` (FR-002); [`Severity::Error`].
115    ValueConstraintViolation,
116    /// `RON-V0006` — an extra/unknown field is present on a struct/map the bound
117    /// type model marks `deny_unknown_fields`. Serde-faithful: only flagged for
118    /// strict types (FR-018). Emitted by `ronin-validate`; [`Severity::Warning`].
119    UnknownField,
120}
121
122impl DiagnosticCode {
123    /// The stable `RON-Pxxxx` / `RON-Vxxxx` string for this code (part of the
124    /// public API).
125    #[inline]
126    #[must_use]
127    pub fn code(self) -> &'static str {
128        match self {
129            DiagnosticCode::UnexpectedToken => "RON-P0001",
130            DiagnosticCode::UnclosedDelimiter => "RON-P0002",
131            DiagnosticCode::NestingDepthExceeded => "RON-P0003",
132            DiagnosticCode::MissingSeparator => "RON-P0004",
133            DiagnosticCode::MissingValue => "RON-P0005",
134            DiagnosticCode::TypeMismatch => "RON-V0001",
135            DiagnosticCode::MissingRequiredField => "RON-V0002",
136            DiagnosticCode::InvalidEnumVariant => "RON-V0003",
137            DiagnosticCode::WrongTupleArity => "RON-V0004",
138            DiagnosticCode::ValueConstraintViolation => "RON-V0005",
139            DiagnosticCode::UnknownField => "RON-V0006",
140        }
141    }
142
143    /// The default [`Severity`] for this code. All parse-recovery (`RON-P`)
144    /// codes are [`Severity::Error`]. Among the type/validation (`RON-V`) codes,
145    /// the hard-mismatch classes — type mismatch, missing-required, invalid
146    /// variant, wrong arity, value-constraint — are [`Severity::Error`], while
147    /// an extra/unknown field is a [`Severity::Warning`] (FR-005, FR-018). The
148    /// mapping is part of the stable contract.
149    #[inline]
150    #[must_use]
151    pub fn default_severity(self) -> Severity {
152        match self {
153            DiagnosticCode::UnexpectedToken
154            | DiagnosticCode::UnclosedDelimiter
155            | DiagnosticCode::NestingDepthExceeded
156            | DiagnosticCode::MissingSeparator
157            | DiagnosticCode::MissingValue
158            | DiagnosticCode::TypeMismatch
159            | DiagnosticCode::MissingRequiredField
160            | DiagnosticCode::InvalidEnumVariant
161            | DiagnosticCode::WrongTupleArity
162            | DiagnosticCode::ValueConstraintViolation => Severity::Error,
163            DiagnosticCode::UnknownField => Severity::Warning,
164        }
165    }
166
167    /// The stable `source` tag identifying which crate produces this code
168    /// (E006/FR-007). It is derived from the code-string namespace prefix:
169    /// `"ronin-types"` for any `RON-V` validation code and `"ronin-core"` for any
170    /// `RON-P` parse/recovery code. This tag lets a surface distinguish
171    /// type findings from structural ones when rendering or deduping. Total and
172    /// stable across 0.x.
173    #[inline]
174    #[must_use]
175    pub fn source(self) -> &'static str {
176        if self.code().starts_with("RON-V") {
177            "ronin-types"
178        } else {
179            "ronin-core"
180        }
181    }
182}
183
184impl std::fmt::Display for DiagnosticCode {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.code())
187    }
188}
189
190/// A single structured diagnostic produced during error-tolerant parsing.
191///
192/// Carries a precise source byte [`TextRange`] (TR-006), a human-readable
193/// `message`, a [`Severity`], and a stable [`DiagnosticCode`] (TR-013). One
194/// `Diagnostic` is emitted per recovery point; diagnostics never change the
195/// tree's byte coverage (INV-3).
196#[derive(Debug, Clone, PartialEq, Eq)]
197#[non_exhaustive]
198pub struct Diagnostic {
199    /// Byte range the diagnostic refers to (a sub-range of `[0, source_len)`).
200    pub range: TextRange,
201    /// Human-readable description of the recovery.
202    pub message: String,
203    /// Fixed severity classification.
204    pub severity: Severity,
205    /// Stable namespaced `RON-Pxxxx` code.
206    pub code: DiagnosticCode,
207}
208
209impl Diagnostic {
210    /// Construct a diagnostic with the [`DiagnosticCode`]'s default severity.
211    #[inline]
212    #[must_use]
213    pub fn new(code: DiagnosticCode, range: TextRange, message: impl Into<String>) -> Self {
214        Self {
215            range,
216            message: message.into(),
217            severity: code.default_severity(),
218            code,
219        }
220    }
221
222    /// This diagnostic's stable [`DiagnosticCode`].
223    #[inline]
224    #[must_use]
225    pub fn code(&self) -> DiagnosticCode {
226        self.code
227    }
228
229    /// This diagnostic's [`Severity`].
230    #[inline]
231    #[must_use]
232    pub fn severity(&self) -> Severity {
233        self.severity
234    }
235
236    /// This diagnostic's source byte [`TextRange`].
237    #[inline]
238    #[must_use]
239    pub fn range(&self) -> TextRange {
240        self.range
241    }
242
243    /// This diagnostic's human-readable message.
244    #[inline]
245    #[must_use]
246    pub fn message(&self) -> &str {
247        &self.message
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// TR-013: the two-variant severity enum is fixed and its labels stable.
256    #[test]
257    fn severity_values_are_stable() {
258        assert_eq!(Severity::Error.as_str(), "error");
259        assert_eq!(Severity::Warning.as_str(), "warning");
260        assert_eq!(Severity::Error.to_string(), "error");
261        // Ord is well-defined (Error < Warning by declaration order).
262        assert!(Severity::Error < Severity::Warning);
263    }
264
265    /// AD-003/TR-013: every registry code maps to a stable `RON-Pxxxx` string,
266    /// all codes are unique, and each has a defined default severity.
267    #[test]
268    fn codes_are_namespaced_unique_and_have_severity() {
269        let all = [
270            DiagnosticCode::UnexpectedToken,
271            DiagnosticCode::UnclosedDelimiter,
272            DiagnosticCode::NestingDepthExceeded,
273            DiagnosticCode::MissingSeparator,
274            DiagnosticCode::MissingValue,
275        ];
276        let mut seen = std::collections::BTreeSet::new();
277        for c in all {
278            let s = c.code();
279            assert!(
280                s.starts_with("RON-P"),
281                "code {s:?} must be in the RON-P parse namespace"
282            );
283            assert_eq!(s.len(), "RON-P0000".len(), "codes are RON-Pxxxx (4 digits)");
284            assert!(
285                s["RON-P".len()..].chars().all(|ch| ch.is_ascii_digit()),
286                "code {s:?} must end in 4 decimal digits"
287            );
288            assert!(seen.insert(s), "duplicate code string {s:?}");
289            // default_severity must be total (no panic).
290            let _ = c.default_severity();
291            assert_eq!(c.to_string(), s);
292        }
293    }
294
295    /// Specific code-string assertions (these strings are a public contract and
296    /// must not drift).
297    #[test]
298    fn code_strings_are_pinned() {
299        assert_eq!(DiagnosticCode::UnexpectedToken.code(), "RON-P0001");
300        assert_eq!(DiagnosticCode::UnclosedDelimiter.code(), "RON-P0002");
301        assert_eq!(DiagnosticCode::NestingDepthExceeded.code(), "RON-P0003");
302        assert_eq!(DiagnosticCode::MissingSeparator.code(), "RON-P0004");
303        assert_eq!(DiagnosticCode::MissingValue.code(), "RON-P0005");
304    }
305
306    /// The full set of `RON-P` parse codes (used by the cross-namespace tests).
307    const PARSE_CODES: [DiagnosticCode; 5] = [
308        DiagnosticCode::UnexpectedToken,
309        DiagnosticCode::UnclosedDelimiter,
310        DiagnosticCode::NestingDepthExceeded,
311        DiagnosticCode::MissingSeparator,
312        DiagnosticCode::MissingValue,
313    ];
314
315    /// The full set of `RON-V` type/validation codes (E006).
316    const VALIDATION_CODES: [DiagnosticCode; 6] = [
317        DiagnosticCode::TypeMismatch,
318        DiagnosticCode::MissingRequiredField,
319        DiagnosticCode::InvalidEnumVariant,
320        DiagnosticCode::WrongTupleArity,
321        DiagnosticCode::ValueConstraintViolation,
322        DiagnosticCode::UnknownField,
323    ];
324
325    /// E006/FR-007: every `RON-V` code is in the `RON-V` namespace, is 4-digit,
326    /// unique, and never collides with a `RON-P` parse code.
327    #[test]
328    fn validation_codes_are_namespaced_unique_and_disjoint_from_parse() {
329        let mut seen = std::collections::BTreeSet::new();
330        for c in VALIDATION_CODES {
331            let s = c.code();
332            assert!(
333                s.starts_with("RON-V"),
334                "code {s:?} must be in the RON-V validation namespace"
335            );
336            assert_eq!(s.len(), "RON-V0000".len(), "codes are RON-Vxxxx (4 digits)");
337            assert!(
338                s["RON-V".len()..].chars().all(|ch| ch.is_ascii_digit()),
339                "code {s:?} must end in 4 decimal digits"
340            );
341            assert!(seen.insert(s), "duplicate validation code string {s:?}");
342            // default_severity must be total (no panic).
343            let _ = c.default_severity();
344            assert_eq!(c.to_string(), s);
345        }
346    }
347
348    /// E006/FR-007: the combined P+V code set has no duplicates — the two
349    /// namespaces are globally unique across the registry.
350    #[test]
351    fn all_codes_are_globally_unique() {
352        let mut seen = std::collections::BTreeSet::new();
353        for c in PARSE_CODES.into_iter().chain(VALIDATION_CODES) {
354            assert!(
355                seen.insert(c.code()),
356                "duplicate code string {:?} across P+V namespaces",
357                c.code()
358            );
359        }
360        assert_eq!(
361            seen.len(),
362            PARSE_CODES.len() + VALIDATION_CODES.len(),
363            "combined registry size must equal P + V counts"
364        );
365    }
366
367    /// E006/FR-007: `source()` is `"ronin-types"` for every V code and
368    /// `"ronin-core"` for every P code, consistent with the namespace prefix.
369    #[test]
370    fn source_tag_matches_namespace() {
371        for c in PARSE_CODES {
372            assert_eq!(
373                c.source(),
374                "ronin-core",
375                "parse code {} must be ronin-core",
376                c.code()
377            );
378        }
379        for c in VALIDATION_CODES {
380            assert_eq!(
381                c.source(),
382                "ronin-types",
383                "validation code {} must be ronin-types",
384                c.code()
385            );
386        }
387    }
388
389    /// E006: the new validation code strings are a public contract — pin them.
390    #[test]
391    fn validation_code_strings_are_pinned() {
392        assert_eq!(DiagnosticCode::TypeMismatch.code(), "RON-V0001");
393        assert_eq!(DiagnosticCode::MissingRequiredField.code(), "RON-V0002");
394        assert_eq!(DiagnosticCode::InvalidEnumVariant.code(), "RON-V0003");
395        assert_eq!(DiagnosticCode::WrongTupleArity.code(), "RON-V0004");
396        assert_eq!(DiagnosticCode::ValueConstraintViolation.code(), "RON-V0005");
397        assert_eq!(DiagnosticCode::UnknownField.code(), "RON-V0006");
398    }
399
400    /// E006/FR-005/FR-018: the V severities follow the policy — the five
401    /// hard-mismatch classes are Error, the extra/unknown field is Warning.
402    #[test]
403    fn validation_default_severities_match_policy() {
404        assert_eq!(
405            DiagnosticCode::TypeMismatch.default_severity(),
406            Severity::Error
407        );
408        assert_eq!(
409            DiagnosticCode::MissingRequiredField.default_severity(),
410            Severity::Error
411        );
412        assert_eq!(
413            DiagnosticCode::InvalidEnumVariant.default_severity(),
414            Severity::Error
415        );
416        assert_eq!(
417            DiagnosticCode::WrongTupleArity.default_severity(),
418            Severity::Error
419        );
420        assert_eq!(
421            DiagnosticCode::ValueConstraintViolation.default_severity(),
422            Severity::Error
423        );
424        assert_eq!(
425            DiagnosticCode::UnknownField.default_severity(),
426            Severity::Warning
427        );
428    }
429
430    /// `Diagnostic::new` adopts the code's default severity and stores the range.
431    #[test]
432    fn new_uses_default_severity() {
433        let r = TextRange::new(2, 5);
434        let d = Diagnostic::new(DiagnosticCode::UnexpectedToken, r, "boom");
435        assert_eq!(d.code(), DiagnosticCode::UnexpectedToken);
436        assert_eq!(d.severity(), Severity::Error);
437        assert_eq!(d.range(), r);
438        assert_eq!(d.message(), "boom");
439    }
440}