Skip to main content

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}