Skip to main content

cyrs_diag/
lib.rs

1//! `cyrs-diag` — diagnostics for the Cypher front-end (spec 0001 §10).
2//!
3//! Every pass in the pipeline emits into a shared [`Diagnostic`] shape
4//! with stable [`DiagCode`] identifiers. Rendering backends (plain text,
5//! JSON, LSP) live here so no downstream crate reinvents them.
6
7#![forbid(unsafe_code)]
8#![doc(html_root_url = "https://docs.rs/cyrs-diag/0.0.1")]
9
10pub mod codes;
11pub mod json;
12#[cfg(feature = "lsp")]
13pub mod lsp;
14pub mod render;
15
16pub use codes::DiagCode;
17pub use json::{to_json, to_json_string, to_ndjson};
18#[cfg(feature = "lsp")]
19pub use lsp::{to_lsp, to_lsp_all};
20pub use render::{render_text, render_text_stderr, render_text_string};
21
22use cyrs_syntax::TextRange;
23use smol_str::SmolStr;
24
25/// A single diagnostic. Spec §10.1.
26///
27/// Marked `#[non_exhaustive]` (cy-2i9.1) so fields can be added (e.g. a
28/// `documentation_url`) without forcing a SemVer-major release.
29/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and the
30/// builder methods; external crates must not use struct literals.
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33#[non_exhaustive]
34pub struct Diagnostic {
35    /// Stable numeric identifier (spec §10.2, e.g. `E0001`).
36    pub code: DiagCode,
37    /// Error / warning / note / help — drives rendering + exit codes.
38    pub severity: Severity,
39    /// Human-readable message, rustc-style (lower-case initial, no trailing period).
40    pub message: SmolStr,
41    /// The primary source span the diagnostic points at.
42    pub primary: Label,
43    /// Additional labels adding context (e.g. the site of a shadowed binding).
44    pub labels: Vec<Label>,
45    /// Trailing `note: …` lines rendered after the primary message.
46    pub notes: Vec<SmolStr>,
47    /// Cross-references to other spans (possibly in other files).
48    pub related: Vec<Related>,
49    /// Suggested edits that fix the diagnostic.  Each fix is atomic.
50    pub fixes: Vec<FixIt>,
51}
52
53/// Diagnostic severity (spec §10.3).  `Error` failures set exit code 1
54/// in `cypher check`; the others are informational.
55///
56/// Marked `#[non_exhaustive]` (cy-2i9.1) so new severities can land
57/// without forcing a SemVer-major release.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60#[allow(missing_docs)]
61#[non_exhaustive]
62pub enum Severity {
63    Error,
64    Warning,
65    Note,
66    Help,
67}
68
69/// A captioned source span attached to a diagnostic.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub struct Label {
73    /// Byte range within the source file.
74    pub range: TextRange,
75    /// Short caption rendered next to the underline (e.g. "here").
76    pub caption: SmolStr,
77}
78
79/// A cross-reference to another span, possibly in another file.
80///
81/// `file` is a no-op in v1 (§10.6) but the field is carried so future
82/// multi-file workflows don't require a breaking change.
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct Related {
86    /// Byte range in the referenced file.
87    pub range: TextRange,
88    /// Caption rendered beneath the referenced span.
89    pub message: SmolStr,
90    /// Referenced filename.  `None` means "same file as the primary
91    /// label"; v1 is single-file so this is always `None` in practice
92    /// (spec §10.6).
93    pub file: Option<SmolStr>,
94}
95
96/// A suggested edit. Multiple `edits` are applied atomically.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99pub struct FixIt {
100    /// Stable machine-readable identifier (e.g. `cy-fix.uppercase`).
101    /// Agents pass this back via `rewrite` to apply the fix.
102    pub id: SmolStr,
103    /// Short human-readable title for the quick-fix UI.
104    pub title: SmolStr,
105    /// Safety classification for automated application.
106    pub applicability: Applicability,
107    /// Edits applied as a single atomic transaction.
108    pub edits: Vec<TextEdit>,
109}
110
111/// A single replacement within a `FixIt`.
112#[derive(Debug, Clone, PartialEq, Eq)]
113#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
114pub struct TextEdit {
115    /// Byte range to replace.
116    pub range: TextRange,
117    /// Replacement text.  Empty string deletes the range.
118    pub replacement: SmolStr,
119}
120
121/// How safe it is to apply a `FixIt` automatically (mirrors rustc's
122/// `Applicability` taxonomy).
123///
124/// Marked `#[non_exhaustive]` (cy-2i9.1) so new classifications can land
125/// without forcing a SemVer-major release.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128#[non_exhaustive]
129pub enum Applicability {
130    /// Safe to apply without review.
131    MachineApplicable,
132    /// Likely correct but may miss edge cases — review recommended.
133    MaybeIncorrect,
134    /// Contains placeholder tokens the user must fill in before it
135    /// compiles.
136    HasPlaceholders,
137    /// Applicability unknown; treat as `MaybeIncorrect`.
138    Unspecified,
139}
140
141impl Diagnostic {
142    /// Construct an error diagnostic with no labels, notes, or fixes.
143    /// Use the builder methods (`with_note`, `with_label`, `with_fix`)
144    /// to attach extra context.
145    #[must_use]
146    pub fn error(code: DiagCode, range: TextRange, message: impl Into<SmolStr>) -> Self {
147        Self {
148            code,
149            severity: Severity::Error,
150            message: message.into(),
151            primary: Label {
152                range,
153                caption: SmolStr::default(),
154            },
155            labels: Vec::new(),
156            notes: Vec::new(),
157            related: Vec::new(),
158            fixes: Vec::new(),
159        }
160    }
161
162    /// Construct a warning diagnostic with no labels, notes, or fixes.
163    /// Mirror of `error` for non-error severities.
164    #[must_use]
165    pub fn warning(code: DiagCode, range: TextRange, message: impl Into<SmolStr>) -> Self {
166        Self {
167            code,
168            severity: Severity::Warning,
169            message: message.into(),
170            primary: Label {
171                range,
172                caption: SmolStr::default(),
173            },
174            labels: Vec::new(),
175            notes: Vec::new(),
176            related: Vec::new(),
177            fixes: Vec::new(),
178        }
179    }
180
181    /// Append a trailing `note:` line to the diagnostic.
182    #[must_use]
183    pub fn with_note(mut self, note: impl Into<SmolStr>) -> Self {
184        self.notes.push(note.into());
185        self
186    }
187
188    /// Attach a secondary label (context span) to the diagnostic.
189    #[must_use]
190    pub fn with_label(mut self, range: TextRange, caption: impl Into<SmolStr>) -> Self {
191        self.labels.push(Label {
192            range,
193            caption: caption.into(),
194        });
195        self
196    }
197
198    /// Attach a quick-fix suggestion.  Multiple fixes may be attached;
199    /// the client chooses which one to apply.
200    #[must_use]
201    pub fn with_fix(mut self, fix: FixIt) -> Self {
202        self.fixes.push(fix);
203        self
204    }
205}
206
207/// Accumulator. Spec §10.4 — no pass short-circuits on first error.
208#[derive(Debug, Clone, Default)]
209pub struct DiagnosticsSink {
210    items: Vec<Diagnostic>,
211}
212
213impl DiagnosticsSink {
214    /// Construct an empty sink.  Prefer `Default::default()` when a
215    /// literal default is expected at the call site.
216    #[must_use]
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Append a diagnostic to the sink.  Order is insertion order
222    /// until `into_sorted` normalises.
223    pub fn push(&mut self, d: Diagnostic) {
224        self.items.push(d);
225    }
226
227    /// Consume the sink and return a stable-sorted diagnostic vec —
228    /// primary range start first, then code.  Used at render time
229    /// so the output is deterministic regardless of pass order.
230    #[must_use]
231    pub fn into_sorted(mut self) -> Vec<Diagnostic> {
232        self.items
233            .sort_by_key(|d| (d.primary.range.start(), d.code));
234        self.items
235    }
236
237    /// `true` iff at least one error-severity diagnostic is present.
238    /// Used by `cypher check` to set the exit code.
239    #[must_use]
240    pub fn has_errors(&self) -> bool {
241        self.items.iter().any(|d| d.severity == Severity::Error)
242    }
243
244    /// Move every diagnostic out of the sink.  Leaves the sink empty.
245    pub fn drain(&mut self) -> impl Iterator<Item = Diagnostic> + '_ {
246        self.items.drain(..)
247    }
248
249    /// Absorb an iterator of diagnostics into the sink.
250    pub fn extend(&mut self, iter: impl IntoIterator<Item = Diagnostic>) {
251        self.items.extend(iter);
252    }
253
254    /// Number of diagnostics currently held.
255    #[must_use]
256    pub fn len(&self) -> usize {
257        self.items.len()
258    }
259
260    /// `true` when the sink holds no diagnostics.
261    #[must_use]
262    pub fn is_empty(&self) -> bool {
263        self.items.is_empty()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use cyrs_syntax::{TextRange, TextSize};
271
272    #[test]
273    fn sort_order_by_offset_then_code() {
274        let mut sink = DiagnosticsSink::new();
275        sink.push(Diagnostic::error(
276            DiagCode::E0001,
277            TextRange::new(TextSize::new(5), TextSize::new(6)),
278            "b",
279        ));
280        sink.push(Diagnostic::error(
281            DiagCode::E0001,
282            TextRange::new(TextSize::new(0), TextSize::new(1)),
283            "a",
284        ));
285        let sorted = sink.into_sorted();
286        assert_eq!(sorted[0].message.as_str(), "a");
287        assert_eq!(sorted[1].message.as_str(), "b");
288    }
289}