zerodds_idl_parse/diagnostic.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Diagnostics. Spec §7.
4
5use alloc::string::String;
6use alloc::vec::Vec;
7use core::fmt;
8use zerodds_idl_cst::Span;
9
10/// How serious a diagnostic is.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub enum Severity {
13 /// The file is not valid IDL. Parsing continues anyway.
14 Error,
15 /// Accepted, but worth saying.
16 Warning,
17}
18
19/// A stable identifier for a diagnostic.
20///
21/// Stable across releases on purpose: a build that greps for a code has
22/// to keep working. Codes are appended, never renumbered or reused.
23/// Spec §7.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25#[non_exhaustive]
26pub enum DiagnosticCode {
27 /// A token appeared where the grammar did not allow it.
28 UnexpectedToken,
29 /// A token the grammar requires was not there.
30 MissingToken,
31 /// A declaration ran out before it was complete.
32 UnterminatedDeclaration,
33 /// The lexer could not make sense of a range.
34 InvalidToken,
35 /// A construct only a vendor dialect admits, under the strict
36 /// dialect.
37 VendorExtension,
38 /// A construct the grammar has but this parser does not implement
39 /// yet. Reported rather than skipped, so a gap cannot pass for
40 /// acceptance.
41 Unsupported,
42 /// An annotation was written with an argument the specification does
43 /// not define for it.
44 InvalidAnnotation,
45 /// A name is declared twice in one scope, case-insensitively.
46 DuplicateName,
47 /// A scoped name resolves to more than one declaration.
48 AmbiguousName,
49 /// A name resolves to nothing.
50 UnresolvedName,
51 /// A union discriminator is not of a type IDL allows.
52 InvalidDiscriminator,
53 /// A case label repeats, or lies outside the discriminator's range.
54 InvalidCaseLabel,
55 /// Two annotations on one node contradict each other.
56 ConflictingAnnotations,
57 /// A bound, dimension or precision is outside its legal range.
58 InvalidBound,
59 /// A forward declaration was never defined.
60 UndefinedForward,
61 /// Two members of one aggregate resolve to the same id.
62 DuplicateMemberId,
63 /// An identifier differs from a keyword only in case, which
64 /// OMG IDL 4.2 §7.2.4 makes illegal.
65 KeywordCollision,
66 /// A choice the specification permits and something downstream pays
67 /// for.
68 ///
69 /// Never produced by the parser or by lowering — both answer "is
70 /// this IDL", and the answer here is always yes. It is the code
71 /// `zerodds-idlc lint` reports under, so a rule about a *choice* is
72 /// distinguishable from a rule about the language, by code and not
73 /// by which tool printed it.
74 PolicyAdvice,
75 /// A value is not of the type it was declared with.
76 ///
77 /// IDL 4.2 §7.4.1.4.3 requires a constant's value expression to be
78 /// "consistent with the type declared for the constant", and
79 /// §7.2.6.3 states the wide/narrow string pair as a "compile-time
80 /// diagnostic" in so many words.
81 TypeMismatch,
82 /// A `#error` directive was reached.
83 ///
84 /// ISO/IEC 14882:2003 §16.5, which IDL 4.2 §7.3 adopts: "#error
85 /// causes the implementation to produce a diagnostic message that
86 /// includes the specified sequence of preprocessing tokens." Its own
87 /// code because it is the one diagnostic the *source* asked for.
88 ErrorDirective,
89 /// A derived type breaks a rule inheritance places on it.
90 ///
91 /// DDS-XTypes 1.3 §7.2.2.4.5: a derived structure has its base's
92 /// extensibility or none of its own, and defines no key fields — its
93 /// keys are its root base's. Duplicate names and member ids across the
94 /// hierarchy keep their own codes, [`Self::DuplicateName`] and
95 /// [`Self::DuplicateMemberId`].
96 InvalidInheritance,
97 /// A rule of the preprocessor ISO/IEC 14882:2003 §16 describes, which
98 /// IDL 4.2 §7.3 adopts: a macro invoked with the wrong number of
99 /// arguments, a `#` not followed by a parameter, a `##` at either
100 /// end of a replacement list, a paste that does not form one token,
101 /// an `#if` expression that does not evaluate. As a warning: an
102 /// identifier in an `#if` that names no macro, which §16.1 evaluates
103 /// as 0.
104 Preprocessing,
105}
106
107impl DiagnosticCode {
108 /// The greppable form, `IDL0001` and so on.
109 #[must_use]
110 pub const fn as_str(self) -> &'static str {
111 match self {
112 Self::UnexpectedToken => "IDL0001",
113 Self::MissingToken => "IDL0002",
114 Self::UnterminatedDeclaration => "IDL0003",
115 Self::InvalidToken => "IDL0004",
116 Self::VendorExtension => "IDL0005",
117 Self::Unsupported => "IDL0006",
118 Self::InvalidAnnotation => "IDL0007",
119 Self::DuplicateName => "IDL0008",
120 Self::AmbiguousName => "IDL0009",
121 Self::UnresolvedName => "IDL0010",
122 Self::InvalidDiscriminator => "IDL0011",
123 Self::InvalidCaseLabel => "IDL0012",
124 Self::ConflictingAnnotations => "IDL0013",
125 Self::InvalidBound => "IDL0014",
126 Self::UndefinedForward => "IDL0015",
127 Self::DuplicateMemberId => "IDL0016",
128 Self::KeywordCollision => "IDL0017",
129 Self::PolicyAdvice => "IDL0018",
130 Self::TypeMismatch => "IDL0019",
131 Self::ErrorDirective => "IDL0020",
132 Self::InvalidInheritance => "IDL0021",
133 Self::Preprocessing => "IDL0022",
134 }
135 }
136
137 /// Every code, so a test can assert the set is collision-free.
138 pub const ALL: [Self; 22] = [
139 Self::UnexpectedToken,
140 Self::MissingToken,
141 Self::UnterminatedDeclaration,
142 Self::InvalidToken,
143 Self::VendorExtension,
144 Self::Unsupported,
145 Self::InvalidAnnotation,
146 Self::DuplicateName,
147 Self::AmbiguousName,
148 Self::UnresolvedName,
149 Self::InvalidDiscriminator,
150 Self::InvalidCaseLabel,
151 Self::ConflictingAnnotations,
152 Self::InvalidBound,
153 Self::UndefinedForward,
154 Self::DuplicateMemberId,
155 Self::KeywordCollision,
156 Self::PolicyAdvice,
157 Self::TypeMismatch,
158 Self::ErrorDirective,
159 Self::InvalidInheritance,
160 Self::Preprocessing,
161 ];
162}
163
164/// One thing wrong with the input.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Diagnostic {
167 /// How serious.
168 pub severity: Severity,
169 /// The stable code.
170 pub code: DiagnosticCode,
171 /// What is wrong, in a sentence.
172 pub message: String,
173 /// Where it is.
174 pub primary: Span,
175 /// Further places worth pointing at, with a note each.
176 pub labels: Vec<(Span, String)>,
177 /// The file `primary` and `labels` point into, when that is not the
178 /// unit itself — an included file. `None` is the unit.
179 ///
180 /// The spans index the file's own text once a caller has mapped
181 /// them out of the preprocessed stream; see
182 /// `Preprocessed::locate`.
183 pub file: Option<String>,
184 /// The paragraph the rule comes from.
185 ///
186 /// A diagnostic without one is ZeroDDS policy and says so in the
187 /// string rather than leaving it empty. Spec §7.
188 pub spec_reference: &'static str,
189}
190
191impl Diagnostic {
192 /// An error at `primary`.
193 #[must_use]
194 pub fn error(
195 code: DiagnosticCode,
196 primary: Span,
197 message: String,
198 spec_reference: &'static str,
199 ) -> Self {
200 Self {
201 severity: Severity::Error,
202 code,
203 message,
204 primary,
205 labels: Vec::new(),
206 file: None,
207 spec_reference,
208 }
209 }
210
211 /// Whether the rule is a clause of a specification rather than
212 /// ZeroDDS policy.
213 ///
214 /// A clause reference carries the section sign and names the
215 /// specification (`OMG IDL 4.2 §7.5.2`, `DDS-XTypes 1.3 §7.2.2.4.5`);
216 /// a policy diagnostic names the ZeroDDS document that states the
217 /// policy (`zerodds-ast-1.0 §8.1`), as [`Diagnostic::spec_reference`]
218 /// asks of it.
219 #[must_use]
220 pub fn cites_a_clause(&self) -> bool {
221 self.spec_reference.contains('§') && !self.spec_reference.starts_with("zerodds")
222 }
223
224 /// A warning at `primary`.
225 ///
226 /// For what is accepted and is still worth a caller's attention: a
227 /// construct admitted only because the dialect was asked to admit
228 /// it, and which no other vendor's compiler will read.
229 #[must_use]
230 pub fn warning(
231 code: DiagnosticCode,
232 primary: Span,
233 message: String,
234 spec_reference: &'static str,
235 ) -> Self {
236 Self {
237 severity: Severity::Warning,
238 code,
239 message,
240 primary,
241 labels: Vec::new(),
242 file: None,
243 spec_reference,
244 }
245 }
246
247 /// Add a secondary place worth pointing at.
248 #[must_use]
249 pub fn with_label(mut self, span: Span, note: String) -> Self {
250 self.labels.push((span, note));
251 self
252 }
253
254 /// Whether this is an error rather than a warning.
255 #[must_use]
256 pub const fn is_error(&self) -> bool {
257 matches!(self.severity, Severity::Error)
258 }
259}
260
261impl fmt::Display for Diagnostic {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 write!(
264 f,
265 "{} [{}] at {}: {} ({})",
266 match self.severity {
267 Severity::Error => "error",
268 Severity::Warning => "warning",
269 },
270 self.code.as_str(),
271 self.primary,
272 self.message,
273 self.spec_reference
274 )
275 }
276}
277
278impl core::error::Error for Diagnostic {}
279
280/// Whether `diagnostics` are the specification refusing the input: at
281/// least one error, and every error cites a clause
282/// ([`Diagnostic::cites_a_clause`]). Warnings count neither way.
283///
284/// What the probe generators answer with: a refusal by a clause is the
285/// verdict `refused` (exit 1), and anything else stopping the front end
286/// is a gap of this tree (exit 6), `docs/specs/zerodds-verdicts-1.0.md`.
287#[must_use]
288pub fn refused_by_a_clause(diagnostics: &[Diagnostic]) -> bool {
289 let mut errors = diagnostics
290 .iter()
291 .filter(|d| d.severity == Severity::Error)
292 .peekable();
293 errors.peek().is_some() && errors.all(Diagnostic::cites_a_clause)
294}
295
296#[cfg(test)]
297mod clause_tests {
298 use super::*;
299
300 fn at(severity: Severity, spec_reference: &'static str) -> Diagnostic {
301 Diagnostic {
302 severity,
303 code: DiagnosticCode::InvalidInheritance,
304 message: String::new(),
305 primary: Span::new(0, 0),
306 labels: Vec::new(),
307 file: None,
308 spec_reference,
309 }
310 }
311
312 #[test]
313 fn a_clause_of_an_omg_specification_refuses_and_zerodds_policy_does_not() {
314 let clause = at(Severity::Error, "DDS-XTypes 1.3 §7.2.2.4.5");
315 let idl = at(Severity::Error, "OMG IDL 4.2 §7.5.2");
316 let policy = at(Severity::Error, "zerodds-ast-1.0 §8.1");
317 let warning = at(Severity::Warning, "zerodds-idl-frontend-1.0 §3");
318 assert!(clause.cites_a_clause() && idl.cites_a_clause());
319 assert!(!policy.cites_a_clause());
320 assert!(refused_by_a_clause(&[clause.clone(), idl.clone()]));
321 assert!(refused_by_a_clause(&[clause.clone(), warning.clone()]));
322 assert!(!refused_by_a_clause(&[clause, policy]));
323 assert!(!refused_by_a_clause(&[warning]));
324 assert!(!refused_by_a_clause(&[]));
325 }
326}