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}