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}