mos_core/diagnostics.rs
1//! User-facing diagnostics: severities, sub-message annotations, and
2//! machine-actionable fix-it suggestions.
3//!
4//! A [`Diagnostic`] pairs a `'static` [`DiagnosticDef`] (identity, from
5//! [`crate::codes`]) with a resolved [`Severity`], a primary message and an
6//! optional [`SourceSpan`], plus [`DiagnosticAnnotation`] rows and
7//! [`Suggestion`] fixes.
8
9use crate::SourceSpan;
10use crate::codes::DiagnosticDef;
11
12/// Diagnostic severity (manifest §31).
13///
14/// Three runtime severities. `Error` marks a *failing* diagnostic (the CLI
15/// exits non-zero at the next phase barrier): it does **not** mean "abort
16/// the phase right now". `Notice` is informational and non-failing
17/// (substitutions, auto-decisions). Sub-message kinds (`note`/`help`/
18/// `hint`) live on [`DiagnosticAnnotation`], never here.
19///
20/// # Examples
21///
22/// ```
23/// use mos_core::Severity;
24///
25/// assert_ne!(Severity::Error, Severity::Notice);
26/// ```
27#[derive(Copy, Clone, Eq, PartialEq, Debug)]
28pub enum Severity {
29 /// Failing diagnostic; non-zero exit at the next phase barrier.
30 Error,
31 /// Surfaced, but the build continues.
32 Warning,
33 /// Informational only; the build continues.
34 Notice,
35}
36
37/// A sub-message attached to a [`Diagnostic`].
38///
39/// The diagnostic's *primary* span lives on [`Diagnostic::span`]; these are
40/// only secondary spans (`Related`) and textual rows. There is intentionally
41/// no `Primary` variant; that would be a second home for the primary span.
42///
43/// # Examples
44///
45/// ```
46/// use mos_core::DiagnosticAnnotation;
47///
48/// let help = DiagnosticAnnotation::Help("try `#set text(...)`".to_owned());
49/// assert!(matches!(help, DiagnosticAnnotation::Help(_)));
50/// ```
51#[derive(Clone, Debug)]
52pub enum DiagnosticAnnotation {
53 /// Another source location that helps explain the primary cause
54 /// (e.g. the first declaration of a duplicated label).
55 Related {
56 /// Where the related span points.
57 span: SourceSpan,
58 /// What that location contributes.
59 message: String,
60 },
61 /// Attached explanation, rendered as `note:`.
62 Note(String),
63 /// Attached suggestion, rendered as `help:`.
64 Help(String),
65 /// Attached hint, rendered as `hint:`.
66 Hint(String),
67}
68
69/// A machine-actionable fix for a [`Diagnostic`].
70///
71/// A `Suggestion` says "replace the bytes at this [`SourceSpan`] with this
72/// text": it is structured data a tool can apply automatically, as opposed
73/// to the prose advice carried by [`DiagnosticAnnotation::Help`]. Backends
74/// consume it without re-parsing: the CLI can print a fix-it diff and an LSP
75/// can surface it as a code action keyed on the same span.
76///
77/// Two edge cases fall out of the replace-the-span model and are intentional:
78///
79/// - an empty `replacement` **deletes** the bytes covered by `span`;
80/// - a zero-length `span` (`start == end`) **inserts** `replacement` at that
81/// offset without removing anything.
82///
83/// # Examples
84///
85/// ```
86/// use std::path::PathBuf;
87///
88/// use mos_core::{SourceSpan, Suggestion};
89///
90/// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
91/// let fix = Suggestion::new(span, "@intro");
92///
93/// assert_eq!(fix.replacement, "@intro");
94/// ```
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct Suggestion {
97 /// The source range the fix replaces. A zero-length span
98 /// (`start == end`) marks a pure insertion point.
99 pub span: SourceSpan,
100 /// The text to substitute for the bytes covered by `span`. An empty
101 /// string deletes that range.
102 pub replacement: String,
103}
104
105impl Suggestion {
106 /// Construct a suggestion replacing `span` with `replacement`.
107 ///
108 /// # Examples
109 ///
110 /// ```
111 /// use std::path::PathBuf;
112 ///
113 /// use mos_core::{SourceSpan, Suggestion};
114 ///
115 /// let span = SourceSpan::new(PathBuf::from("main.mos"), 0, 3);
116 /// let fix = Suggestion::new(span, "set".to_owned());
117 ///
118 /// assert_eq!(fix.span.start(), 0);
119 /// ```
120 #[must_use]
121 pub fn new(span: SourceSpan, replacement: impl Into<String>) -> Self {
122 Self {
123 span,
124 replacement: replacement.into(),
125 }
126 }
127}
128
129/// A user-facing diagnostic (manifest §16, §31).
130///
131/// Identity and default severity come from a `'static` [`DiagnosticDef`] in
132/// [`crate::codes`]; the instance carries the *resolved* severity (today always
133/// the def's default, later a config override) so rendering never has to
134/// consult the def. Fields are private; construct via [`Diagnostic::simple`]
135/// or [`Diagnostic::new`].
136///
137/// # Examples
138///
139/// ```
140/// use mos_core::{Diagnostic, Severity, codes};
141///
142/// let diagnostic = Diagnostic::simple(&codes::MOS0010, None, "boom");
143///
144/// assert_eq!(diagnostic.severity(), Severity::Error);
145/// assert_eq!(diagnostic.def().code().to_string(), "MOS0010");
146/// ```
147#[derive(Clone, Debug)]
148pub struct Diagnostic {
149 def: &'static DiagnosticDef,
150 severity: Severity,
151 span: Option<SourceSpan>,
152 message: String,
153 annotations: Vec<DiagnosticAnnotation>,
154 suggestions: Vec<Suggestion>,
155}
156
157impl Diagnostic {
158 /// Full constructor: the caller supplies the resolved severity. The
159 /// future config resolver uses this; nothing has to crack open the
160 /// struct.
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// use mos_core::{Diagnostic, Severity, codes};
166 ///
167 /// // Promote a warning-by-default code to an error.
168 /// let d = Diagnostic::new(&codes::MOS0028, Severity::Error, None, "promoted");
169 /// assert_eq!(d.severity(), Severity::Error);
170 /// ```
171 pub fn new(
172 def: &'static DiagnosticDef,
173 severity: Severity,
174 span: Option<SourceSpan>,
175 message: impl Into<String>,
176 ) -> Self {
177 Self {
178 def,
179 severity,
180 span,
181 message: message.into(),
182 annotations: Vec::new(),
183 suggestions: Vec::new(),
184 }
185 }
186
187 /// Convenience: severity defaults to `def.default_severity()`.
188 ///
189 /// # Examples
190 ///
191 /// ```
192 /// use mos_core::{Diagnostic, Severity, codes};
193 ///
194 /// let d = Diagnostic::simple(&codes::MOS0018, None, "substituted Noto Sans");
195 /// assert_eq!(d.severity(), Severity::Notice);
196 /// ```
197 pub fn simple(
198 def: &'static DiagnosticDef,
199 span: Option<SourceSpan>,
200 message: impl Into<String>,
201 ) -> Self {
202 Self::new(def, def.default_severity(), span, message)
203 }
204
205 /// Attach a sub-message annotation, builder-style.
206 ///
207 /// # Examples
208 ///
209 /// ```
210 /// use mos_core::{Diagnostic, DiagnosticAnnotation, codes};
211 ///
212 /// let d = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
213 /// .with_annotation(DiagnosticAnnotation::Help("did you mean `@intro`?".to_owned()));
214 /// assert_eq!(d.annotations().len(), 1);
215 /// ```
216 #[must_use]
217 pub fn with_annotation(mut self, annotation: DiagnosticAnnotation) -> Self {
218 self.annotations.push(annotation);
219 self
220 }
221
222 /// Attach a machine-actionable [`Suggestion`], builder-style.
223 ///
224 /// # Examples
225 ///
226 /// ```
227 /// use std::path::PathBuf;
228 ///
229 /// use mos_core::{Diagnostic, SourceSpan, Suggestion, codes};
230 ///
231 /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
232 /// let d = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
233 /// .with_suggestion(Suggestion::new(span, "@intro"));
234 /// assert_eq!(d.suggestions().len(), 1);
235 /// ```
236 #[must_use]
237 pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
238 self.suggestions.push(suggestion);
239 self
240 }
241
242 /// Attach a span, builder-style.
243 ///
244 /// # Examples
245 ///
246 /// ```
247 /// use std::path::PathBuf;
248 ///
249 /// use mos_core::{Diagnostic, SourceSpan, codes};
250 ///
251 /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
252 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
253 /// .with_span(span.clone());
254 ///
255 /// assert_eq!(diagnostic.span(), Some(&span));
256 /// ```
257 #[must_use]
258 pub fn with_span(mut self, span: SourceSpan) -> Self {
259 self.span = Some(span);
260 self
261 }
262
263 /// The registry definition behind this diagnostic.
264 ///
265 /// # Examples
266 ///
267 /// ```
268 /// use mos_core::{Diagnostic, codes};
269 ///
270 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
271 ///
272 /// assert_eq!(diagnostic.def().code(), codes::MOS0033.code());
273 /// ```
274 #[must_use]
275 pub fn def(&self) -> &'static DiagnosticDef {
276 self.def
277 }
278
279 /// The resolved severity carried by this instance.
280 ///
281 /// # Examples
282 ///
283 /// ```
284 /// use mos_core::{Diagnostic, Severity, codes};
285 ///
286 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
287 ///
288 /// assert_eq!(diagnostic.severity(), Severity::Error);
289 /// ```
290 #[must_use]
291 pub fn severity(&self) -> Severity {
292 self.severity
293 }
294
295 /// The primary span, if any.
296 ///
297 /// # Examples
298 ///
299 /// ```
300 /// use mos_core::{Diagnostic, codes};
301 ///
302 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
303 ///
304 /// assert!(diagnostic.span().is_none());
305 /// ```
306 #[must_use]
307 pub fn span(&self) -> Option<&SourceSpan> {
308 self.span.as_ref()
309 }
310
311 /// The primary message.
312 ///
313 /// # Examples
314 ///
315 /// ```
316 /// use mos_core::{Diagnostic, codes};
317 ///
318 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
319 ///
320 /// assert_eq!(diagnostic.message(), "unknown label");
321 /// ```
322 #[must_use]
323 pub fn message(&self) -> &str {
324 &self.message
325 }
326
327 /// The attached sub-message annotations, in attach order.
328 ///
329 /// # Examples
330 ///
331 /// ```
332 /// use mos_core::{Diagnostic, DiagnosticAnnotation, codes};
333 ///
334 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
335 /// .with_annotation(DiagnosticAnnotation::Help("declare `<intro>` first".to_owned()));
336 ///
337 /// assert_eq!(diagnostic.annotations().len(), 1);
338 /// ```
339 #[must_use]
340 pub fn annotations(&self) -> &[DiagnosticAnnotation] {
341 &self.annotations
342 }
343
344 /// The attached machine-actionable suggestions, in attach order.
345 ///
346 /// # Examples
347 ///
348 /// ```
349 /// use std::path::PathBuf;
350 ///
351 /// use mos_core::{Diagnostic, SourceSpan, Suggestion, codes};
352 ///
353 /// let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
354 /// let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
355 /// .with_suggestion(Suggestion::new(span, "@intro"));
356 ///
357 /// assert_eq!(diagnostic.suggestions().len(), 1);
358 /// ```
359 #[must_use]
360 pub fn suggestions(&self) -> &[Suggestion] {
361 &self.suggestions
362 }
363}
364
365impl std::fmt::Display for Diagnostic {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 write!(f, "[{}] {}", self.def.code(), self.message)
368 }
369}
370
371impl std::error::Error for Diagnostic {}
372
373#[cfg(test)]
374mod tests {
375 use std::path::PathBuf;
376
377 use super::*;
378 use crate::codes;
379
380 #[test]
381 fn suggestion_new_sets_span_and_replacement() {
382 let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
383 let suggestion = Suggestion::new(span.clone(), "@intro");
384 assert_eq!(suggestion.span, span);
385 assert_eq!(suggestion.replacement, "@intro");
386 }
387
388 #[test]
389 fn diagnostic_has_no_suggestions_by_default() {
390 let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
391 assert!(diagnostic.suggestions().is_empty());
392 }
393
394 #[test]
395 fn with_suggestion_accumulates_in_order() {
396 let first = Suggestion::new(SourceSpan::new(PathBuf::from("main.mos"), 4, 10), "@intro");
397 let second = Suggestion::new(
398 SourceSpan::new(PathBuf::from("other.mos"), 12, 15),
399 "@summary",
400 );
401 let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
402 .with_suggestion(first)
403 .with_suggestion(second);
404
405 let suggestions = diagnostic.suggestions();
406 assert_eq!(suggestions.len(), 2);
407
408 assert_eq!(suggestions[0].span.file, PathBuf::from("main.mos"));
409 assert_eq!(suggestions[0].span.start(), 4);
410 assert_eq!(suggestions[0].span.end(), 10);
411 assert_eq!(suggestions[0].replacement, "@intro");
412
413 assert_eq!(suggestions[1].span.file, PathBuf::from("other.mos"));
414 assert_eq!(suggestions[1].span.start(), 12);
415 assert_eq!(suggestions[1].span.end(), 15);
416 assert_eq!(suggestions[1].replacement, "@summary");
417 }
418
419 #[test]
420 fn suggestion_new_accepts_str_and_owned_string() {
421 let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
422 let from_str = Suggestion::new(span.clone(), "@intro");
423 let from_string = Suggestion::new(span, String::from("@intro"));
424 assert_eq!(from_str, from_string);
425 }
426
427 #[test]
428 fn suggestion_clone_and_equality() {
429 let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
430 let suggestion = Suggestion::new(span.clone(), "@intro");
431
432 // A clone equals its original.
433 assert_eq!(suggestion.clone(), suggestion);
434 // Built independently from the same parts => equal.
435 assert_eq!(Suggestion::new(span.clone(), "@intro"), suggestion);
436 // Differing replacement text => unequal.
437 assert_ne!(Suggestion::new(span, "@outro"), suggestion);
438 // Differing span => unequal.
439 let wider = SourceSpan::new(PathBuf::from("main.mos"), 4, 11);
440 assert_ne!(Suggestion::new(wider, "@intro"), suggestion);
441 }
442
443 #[test]
444 fn suggestion_empty_replacement_encodes_deletion() {
445 let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
446 let deletion = Suggestion::new(span, "");
447 assert!(deletion.replacement.is_empty());
448 // A deletion still covers a real, non-empty range.
449 assert!(deletion.span.start() < deletion.span.end());
450 }
451
452 #[test]
453 fn suggestion_zero_length_span_encodes_insertion() {
454 let point = SourceSpan::new(PathBuf::from("main.mos"), 7, 7);
455 let insertion = Suggestion::new(point, "@intro");
456 assert_eq!(insertion.span.start(), insertion.span.end());
457 assert_eq!(insertion.replacement, "@intro");
458 }
459
460 #[test]
461 fn suggestions_and_annotations_are_independent_channels() {
462 let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
463
464 // A suggestion does not leak into the annotation channel.
465 let with_fix = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
466 .with_suggestion(Suggestion::new(span.clone(), "@intro"));
467 assert_eq!(with_fix.suggestions().len(), 1);
468 assert!(with_fix.annotations().is_empty());
469
470 // Prose help does not leak into the suggestion channel.
471 let with_help = Diagnostic::simple(&codes::MOS0033, None, "unknown label").with_annotation(
472 DiagnosticAnnotation::Help("did you mean `@intro`?".to_owned()),
473 );
474 assert_eq!(with_help.annotations().len(), 1);
475 assert!(with_help.suggestions().is_empty());
476
477 // Both channels populate independently and keep their own payloads.
478 let with_both = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
479 .with_annotation(DiagnosticAnnotation::Help(
480 "did you mean `@intro`?".to_owned(),
481 ))
482 .with_suggestion(Suggestion::new(span, "@intro"));
483 assert_eq!(with_both.suggestions().len(), 1);
484 assert_eq!(with_both.annotations().len(), 1);
485 assert_eq!(with_both.suggestions()[0].replacement, "@intro");
486
487 // The existing Help annotation is carried through unchanged.
488 let help_text = match &with_both.annotations()[0] {
489 DiagnosticAnnotation::Help(text) => Some(text.as_str()),
490 _ => None,
491 };
492 assert_eq!(help_text, Some("did you mean `@intro`?"));
493 }
494}