Skip to main content

aver/diagnostics/
model.rs

1//! Canonical diagnostic data model.
2//!
3//! Runtime-neutral and wasm-safe — no `colored`, no file IO, no VM.
4//! Shared between CLI (`src/main/tty_render.rs`), LSP (`aver-lsp`),
5//! and playground (`src/playground.rs`).
6
7use serde::Serialize;
8
9pub const SCHEMA_VERSION: u32 = 1;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Severity {
14    Error,
15    Warning,
16    Fail,
17    Hint,
18}
19
20#[derive(Clone, Debug, Serialize)]
21pub struct Span {
22    pub file: String,
23    pub line: usize,
24    pub col: usize,
25}
26
27#[derive(Clone, Debug, Serialize)]
28pub struct SourceLine {
29    pub line_num: usize,
30    pub text: String,
31}
32
33#[derive(Clone, Debug, Serialize)]
34pub struct Underline {
35    pub col: usize,
36    pub len: usize,
37    pub label: String,
38}
39
40#[derive(Clone, Debug, Serialize)]
41pub struct AnnotatedRegion {
42    pub source_lines: Vec<SourceLine>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub underline: Option<Underline>,
45}
46
47impl AnnotatedRegion {
48    pub fn single(source_lines: Vec<SourceLine>, underline: Option<Underline>) -> Vec<Self> {
49        vec![Self {
50            source_lines,
51            underline,
52        }]
53    }
54}
55
56#[derive(Clone, Debug, Serialize)]
57pub struct RelatedSpan {
58    pub span: Span,
59    pub label: String,
60}
61
62#[derive(Clone, Debug, Default, Serialize)]
63pub struct Repair {
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub primary: Option<String>,
66    #[serde(skip_serializing_if = "Vec::is_empty")]
67    pub alternatives: Vec<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub example: Option<String>,
70}
71
72impl Repair {
73    pub fn primary(text: impl Into<String>) -> Self {
74        Self {
75            primary: Some(text.into()),
76            alternatives: Vec::new(),
77            example: None,
78        }
79    }
80
81    pub fn is_empty(&self) -> bool {
82        self.primary.is_none() && self.alternatives.is_empty() && self.example.is_none()
83    }
84}
85
86#[derive(Clone, Debug, Serialize)]
87pub struct Diagnostic {
88    pub severity: Severity,
89    pub slug: &'static str,
90    pub summary: String,
91    pub span: Span,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub fn_name: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub intent: Option<String>,
96    #[serde(skip_serializing_if = "Vec::is_empty")]
97    pub fields: Vec<(&'static str, String)>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub conflict: Option<String>,
100    #[serde(skip_serializing_if = "Repair::is_empty")]
101    pub repair: Repair,
102    #[serde(skip_serializing_if = "Vec::is_empty")]
103    pub regions: Vec<AnnotatedRegion>,
104    #[serde(skip_serializing_if = "Vec::is_empty")]
105    pub related: Vec<RelatedSpan>,
106}
107
108impl Diagnostic {
109    pub fn is_warning(&self) -> bool {
110        matches!(self.severity, Severity::Warning)
111    }
112
113    pub fn is_error(&self) -> bool {
114        matches!(self.severity, Severity::Error | Severity::Fail)
115    }
116}
117
118#[derive(Clone, Debug, Serialize)]
119pub struct AnalysisReport {
120    pub schema_version: u32,
121    pub kind: &'static str,
122    pub file_label: String,
123    #[serde(skip_serializing_if = "Vec::is_empty")]
124    pub diagnostics: Vec<Diagnostic>,
125    /// File-local justification summary — present when the caller opts
126    /// in via `AnalyzeOptions::include_why_summary`.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub why_summary: Option<crate::diagnostics::why::WhySummary>,
129    /// File-local context summary (module shape, functions, types,
130    /// decisions) — present when `include_context_summary` is set.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub context_summary: Option<crate::diagnostics::context::ContextSummary>,
133    /// Per-verify-block pass/fail/skip counts — present when
134    /// `include_verify_run` is set. Diagnostics list carries the failing
135    /// case details; `verify_summary` gives the scorecard.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub verify_summary: Option<VerifySummary>,
138}
139
140/// Per-block verify results. Mirrors what `aver verify` used to emit as
141/// `block-result` NDJSON events — now folded into the analysis bundle
142/// so a single record per file carries failures + scorecard.
143#[derive(Clone, Debug, Serialize)]
144pub struct VerifySummary {
145    pub blocks: Vec<VerifyBlockResult>,
146}
147
148/// One formatter rule firing on one location. Emitted by
149/// `try_format_source` alongside the rewritten text, then folded into a
150/// canonical `needs-format` [`Diagnostic`] by the factory.
151///
152/// `rule` is a stable slug ("bad-operator-spacing", "effect-order",
153/// "verify-block-order", …) consumed by LSP `code`, docs, and CI rules.
154/// `before`/`after` are optional short snippets for teaching; long
155/// rewrites can omit them.
156#[derive(Clone, Debug, Serialize)]
157pub struct FormatViolation {
158    pub line: usize,
159    pub col: usize,
160    pub rule: &'static str,
161    pub message: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub before: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub after: Option<String>,
166}
167
168#[derive(Clone, Debug, Serialize)]
169pub struct VerifyBlockResult {
170    pub name: String,
171    pub passed: usize,
172    pub failed: usize,
173    pub skipped: usize,
174    pub total: usize,
175}
176
177impl AnalysisReport {
178    pub fn new(file_label: impl Into<String>) -> Self {
179        Self {
180            schema_version: SCHEMA_VERSION,
181            kind: "analysis",
182            file_label: file_label.into(),
183            diagnostics: Vec::new(),
184            why_summary: None,
185            context_summary: None,
186            verify_summary: None,
187        }
188    }
189
190    pub fn with_diagnostics(file_label: impl Into<String>, diagnostics: Vec<Diagnostic>) -> Self {
191        Self {
192            schema_version: SCHEMA_VERSION,
193            kind: "analysis",
194            file_label: file_label.into(),
195            diagnostics,
196            why_summary: None,
197            context_summary: None,
198            verify_summary: None,
199        }
200    }
201
202    pub fn to_json(&self) -> String {
203        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
204    }
205}
206
207/// Minimal JSON string escaping. Kept as a standalone helper so legacy
208/// per-record CLI JSON (bit-for-bit parity with 0.9.x) can build output
209/// without pulling serde into every format string.
210pub fn json_escape(s: &str) -> String {
211    let mut out = String::with_capacity(s.len() + 2);
212    out.push('"');
213    for ch in s.chars() {
214        match ch {
215            '"' => out.push_str("\\\""),
216            '\\' => out.push_str("\\\\"),
217            '\n' => out.push_str("\\n"),
218            '\r' => out.push_str("\\r"),
219            '\t' => out.push_str("\\t"),
220            c => out.push(c),
221        }
222    }
223    out.push('"');
224    out
225}