Skip to main content

oneyaml_core/
diagnostic.rs

1//! Tier-1 source diagnostics.
2
3use crate::{Code, Path, Span};
4
5/// How serious a [`Diagnostic`] is.
6#[non_exhaustive]
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
8pub enum Severity {
9    /// The input is invalid; strict entry points fail.
10    Error,
11    /// The input is valid but something deserves attention.
12    Warning,
13}
14
15impl core::fmt::Display for Severity {
16    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
17        f.write_str(match self {
18            Self::Error => "error",
19            Self::Warning => "warning",
20        })
21    }
22}
23
24/// A span with a message, pointing at source text.
25#[non_exhaustive]
26#[derive(Clone, Debug, PartialEq, Eq, Hash)]
27pub struct Label {
28    /// The source range.
29    pub span: Span,
30    /// What this label says about the range (may be empty).
31    pub message: String,
32}
33
34impl Label {
35    /// Creates a label.
36    #[must_use]
37    pub fn new(span: Span, message: impl Into<String>) -> Self {
38        Self {
39            span,
40            message: message.into(),
41        }
42    }
43}
44
45/// How safely a [`Suggestion`] can be applied without a human looking at it.
46#[non_exhaustive]
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48pub enum Applicability {
49    /// Applying the edits fixes the problem and keeps the evident intent; an
50    /// editor or LSP may apply it automatically (e.g. as a quick fix).
51    MachineApplicable,
52    /// The edits are probably right but should be reviewed before applying.
53    MaybeIncorrect,
54}
55
56/// One text edit of a [`Suggestion`]: replace the source bytes in `span`
57/// with `text`. An empty span inserts; an empty `text` deletes.
58#[non_exhaustive]
59#[derive(Clone, Debug, PartialEq, Eq, Hash)]
60pub struct Replacement {
61    /// The UTF-8 byte range of the source to replace.
62    pub span: Span,
63    /// The new text.
64    pub text: String,
65}
66
67impl Replacement {
68    /// Creates a replacement.
69    #[must_use]
70    pub fn new(span: Span, text: impl Into<String>) -> Self {
71        Self {
72            span,
73            text: text.into(),
74        }
75    }
76}
77
78/// A suggested fix: a message and a set of non-overlapping source edits,
79/// applied together. Maps directly onto an LSP code action or a
80/// rustc-style suggestion.
81#[non_exhaustive]
82#[derive(Clone, Debug, PartialEq, Eq, Hash)]
83pub struct Suggestion {
84    /// What the fix does, e.g. "quote the value".
85    pub message: String,
86    /// The edits, sorted by position and non-overlapping.
87    pub edits: Vec<Replacement>,
88    /// Whether the fix may be applied without review.
89    pub applicability: Applicability,
90}
91
92impl Suggestion {
93    /// Creates a suggestion.
94    #[must_use]
95    pub fn new(
96        message: impl Into<String>,
97        edits: Vec<Replacement>,
98        applicability: Applicability,
99    ) -> Self {
100        Self {
101            message: message.into(),
102            edits,
103            applicability,
104        }
105    }
106
107    /// Applies the edits to `source` and returns the new text, or `None` if
108    /// an edit's span is out of bounds, not on `char` boundaries, or overlaps
109    /// (or precedes) the previous edit.
110    #[must_use]
111    pub fn apply(&self, source: &str) -> Option<String> {
112        let mut out = String::with_capacity(source.len());
113        let mut at = 0usize;
114        for edit in &self.edits {
115            if edit.span.start < at || edit.span.end < edit.span.start {
116                return None;
117            }
118            out.push_str(source.get(at..edit.span.start)?);
119            source.get(edit.span.start..edit.span.end)?;
120            out.push_str(&edit.text);
121            at = edit.span.end;
122        }
123        out.push_str(source.get(at..)?);
124        Some(out)
125    }
126}
127
128/// A source diagnostic: a stable code, severity, message, labels, optional
129/// help, optional path to the affected node, and suggested fixes.
130///
131/// Built by the parser and readers; applications that check YAML
132/// themselves (a linter, a schema check) can build their own:
133///
134/// ```
135/// use oneyaml_core::{
136///     code, path, Applicability, Diagnostic, Label, Replacement, Severity, Span, Suggestion,
137/// };
138///
139/// let src = "replicas: '3'\n";
140/// let value = Span::new(10, 13);
141/// let fix = Suggestion::new(
142///     "write the number unquoted",
143///     vec![Replacement::new(value, "3")],
144///     Applicability::MaybeIncorrect,
145/// );
146/// let d = Diagnostic::warning(code::CUSTOM, "`replicas` is a string", Label::new(value, "quoted"))
147///     .with_secondary(Label::new(Span::new(0, 8), "this key"))
148///     .with_help("Kubernetes reads `replicas` as an integer")
149///     .with_path(path!["replicas"])
150///     .with_suggestion(fix);
151/// assert_eq!(d.severity, Severity::Warning);
152/// assert_eq!(d.secondary.len(), 1);
153/// assert_eq!(d.path.as_ref().map(ToString::to_string).as_deref(), Some("replicas"));
154/// assert_eq!(d.suggestions[0].apply(src).as_deref(), Some("replicas: 3\n"));
155/// let e = Diagnostic::error(code::CUSTOM, "same, as an error", Label::new(value, ""));
156/// assert_eq!(e.severity, Severity::Error);
157/// ```
158#[non_exhaustive]
159#[derive(Clone, Debug, PartialEq, Eq, Hash)]
160pub struct Diagnostic {
161    /// Stable `Y####` code.
162    pub code: Code,
163    /// Error or warning.
164    pub severity: Severity,
165    /// Main message.
166    pub message: String,
167    /// The main location.
168    pub primary: Label,
169    /// Related locations, e.g. "first defined here".
170    pub secondary: Vec<Label>,
171    /// How to fix it; for limit diagnostics, names the setting to raise.
172    pub help: Option<String>,
173    /// Path to the affected node, when known.
174    pub path: Option<Path>,
175    /// Suggested fixes, most preferred first (may be empty).
176    pub suggestions: Vec<Suggestion>,
177}
178
179impl Diagnostic {
180    /// Creates a diagnostic with no secondary labels, help, or path.
181    #[must_use]
182    pub fn new(code: Code, severity: Severity, message: impl Into<String>, primary: Label) -> Self {
183        Self {
184            code,
185            severity,
186            message: message.into(),
187            primary,
188            secondary: Vec::new(),
189            help: None,
190            path: None,
191            suggestions: Vec::new(),
192        }
193    }
194
195    /// Shorthand for [`Diagnostic::new`] with [`Severity::Error`].
196    #[must_use]
197    pub fn error(code: Code, message: impl Into<String>, primary: Label) -> Self {
198        Self::new(code, Severity::Error, message, primary)
199    }
200
201    /// Shorthand for [`Diagnostic::new`] with [`Severity::Warning`].
202    #[must_use]
203    pub fn warning(code: Code, message: impl Into<String>, primary: Label) -> Self {
204        Self::new(code, Severity::Warning, message, primary)
205    }
206
207    /// Adds a secondary label.
208    #[must_use]
209    pub fn with_secondary(mut self, label: Label) -> Self {
210        self.secondary.push(label);
211        self
212    }
213
214    /// Sets the help text.
215    #[must_use]
216    pub fn with_help(mut self, help: impl Into<String>) -> Self {
217        self.help = Some(help.into());
218        self
219    }
220
221    /// Sets the path.
222    #[must_use]
223    pub fn with_path(mut self, path: Path) -> Self {
224        self.path = Some(path);
225        self
226    }
227
228    /// Adds a suggested fix.
229    #[must_use]
230    pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
231        self.suggestions.push(suggestion);
232        self
233    }
234}
235
236impl core::fmt::Display for Diagnostic {
237    /// Compact one-line form: `error[Y0001]: message`, plus ` at <path>` when
238    /// a path is known.
239    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
240        write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?;
241        if let Some(path) = &self.path {
242            write!(f, " at {path}")?;
243        }
244        Ok(())
245    }
246}
247
248impl core::error::Error for Diagnostic {}
249
250#[cfg(test)]
251mod tests {
252    #![allow(clippy::unwrap_used)]
253
254    use super::*;
255
256    #[test]
257    fn builders_and_display() {
258        let code = Code::new(1003).unwrap();
259        let d = Diagnostic::warning(code, "ignored", Label::new(Span::new(0, 4), "here"))
260            .with_secondary(Label::new(Span::new(5, 6), "also"))
261            .with_help("remove it")
262            .with_path(Path::new().with_key("a").with_index(2));
263        assert_eq!(d.severity, Severity::Warning);
264        assert_eq!(d.secondary.len(), 1);
265        assert_eq!(d.help.as_deref(), Some("remove it"));
266        assert_eq!(d.to_string(), "warning[Y1003]: ignored at a[2]");
267
268        let e = Diagnostic::error(code, "bad", Label::new(Span::empty(0), ""));
269        assert_eq!(e.severity, Severity::Error);
270        assert_eq!(e.path, None);
271        assert_eq!(e.to_string(), "error[Y1003]: bad");
272        assert_eq!(
273            e,
274            Diagnostic::new(code, Severity::Error, "bad", Label::new(Span::empty(0), ""))
275        );
276        assert!(e.suggestions.is_empty());
277    }
278
279    #[test]
280    fn suggestions_apply() {
281        let src = "a: b: c\nd: é\n";
282        let quote = Suggestion::new(
283            "quote the value",
284            vec![Replacement::new(Span::new(3, 7), "'b: c'")],
285            Applicability::MachineApplicable,
286        );
287        assert_eq!(quote.apply(src).unwrap(), "a: 'b: c'\nd: é\n");
288        let multi = Suggestion::new(
289            "insert and delete",
290            vec![
291                Replacement::new(Span::empty(0), "# x\n"),
292                Replacement::new(Span::new(8, 13), ""),
293            ],
294            Applicability::MaybeIncorrect,
295        );
296        assert_eq!(multi.apply(src).unwrap(), "# x\na: b: c\n\n");
297        // Overlapping, out-of-order, inverted, out-of-bounds, and
298        // non-boundary edits are refused, never a panic.
299        for bad in [
300            vec![
301                Replacement::new(Span::new(0, 4), ""),
302                Replacement::new(Span::new(3, 5), ""),
303            ],
304            vec![Replacement::new(Span::new(5, 4), "")],
305            vec![Replacement::new(Span::new(0, 99), "")],
306            vec![Replacement::new(Span::new(0, 12), "")],
307        ] {
308            let s = Suggestion::new("bad", bad, Applicability::MaybeIncorrect);
309            assert_eq!(s.apply(src), None);
310        }
311        let d = Diagnostic::error(Code::new(6).unwrap(), "x", Label::new(Span::empty(0), ""))
312            .with_suggestion(quote.clone());
313        assert_eq!(d.suggestions, vec![quote]);
314    }
315}