Skip to main content

adk_code/
diagnostics.rs

1//! Rust compiler diagnostic parsing.
2//!
3//! This module provides types and a parser for `rustc --error-format=json` output.
4//! Each line of stderr from `rustc` in JSON mode is a JSON object containing
5//! diagnostic information. This module parses those objects into structured
6//! [`RustDiagnostic`] values.
7//!
8//! # Example
9//!
10//! ```rust
11//! use adk_code::diagnostics::parse_diagnostics;
12//!
13//! let stderr = r#"{"message":"expected `;`","code":{"code":"E0308","explanation":null},"level":"error","spans":[{"file_name":"main.rs","byte_start":10,"byte_end":11,"line_start":1,"line_end":1,"column_start":11,"column_end":12,"is_primary":true,"text":[{"text":"let x = 1","highlight_start":11,"highlight_end":12}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error: expected `;`"}"#;
14//! let diagnostics = parse_diagnostics(stderr);
15//! assert_eq!(diagnostics.len(), 1);
16//! assert_eq!(diagnostics[0].level, "error");
17//! ```
18
19use serde::{Deserialize, Serialize};
20
21/// A parsed Rust compiler diagnostic.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RustDiagnostic {
24    /// Severity level: `"error"`, `"warning"`, `"note"`, `"help"`.
25    pub level: String,
26    /// The diagnostic message.
27    pub message: String,
28    /// Source spans where the diagnostic applies.
29    #[serde(default)]
30    pub spans: Vec<DiagnosticSpan>,
31    /// Optional error code (e.g., `"E0308"`).
32    pub code: Option<String>,
33}
34
35/// A source span within a diagnostic.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DiagnosticSpan {
38    /// The file name where the diagnostic applies.
39    pub file_name: String,
40    /// Starting line number (1-indexed).
41    pub line_start: u32,
42    /// Ending line number (1-indexed).
43    pub line_end: u32,
44    /// Starting column number (1-indexed).
45    pub column_start: u32,
46    /// Ending column number (1-indexed).
47    pub column_end: u32,
48    /// Source text with highlight information.
49    #[serde(default)]
50    pub text: Vec<SpanText>,
51}
52
53/// A line of source text with highlight markers.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SpanText {
56    /// The source text of the line.
57    pub text: String,
58    /// Column where the highlight starts (1-indexed).
59    pub highlight_start: u32,
60    /// Column where the highlight ends (1-indexed).
61    pub highlight_end: u32,
62}
63
64/// Intermediate type for deserializing rustc JSON output.
65///
66/// The rustc JSON format nests the error code inside a `code` object:
67/// `{"code": {"code": "E0308", "explanation": null}}`.
68#[derive(Debug, Deserialize)]
69struct RawDiagnostic {
70    level: String,
71    message: String,
72    #[serde(default)]
73    spans: Vec<RawSpan>,
74    code: Option<RawCode>,
75}
76
77#[derive(Debug, Deserialize)]
78struct RawCode {
79    code: String,
80}
81
82#[derive(Debug, Deserialize)]
83struct RawSpan {
84    file_name: String,
85    line_start: u32,
86    line_end: u32,
87    column_start: u32,
88    column_end: u32,
89    #[serde(default)]
90    text: Vec<RawSpanText>,
91}
92
93#[derive(Debug, Deserialize)]
94struct RawSpanText {
95    text: String,
96    highlight_start: u32,
97    highlight_end: u32,
98}
99
100/// Parse rustc JSON diagnostics from `--error-format=json` output.
101///
102/// Each line of stderr is attempted as a JSON diagnostic object. Lines that
103/// fail to parse (e.g., non-JSON rendered output) are silently skipped.
104///
105/// # Example
106///
107/// ```rust
108/// use adk_code::diagnostics::parse_diagnostics;
109///
110/// let stderr = "";
111/// let diagnostics = parse_diagnostics(stderr);
112/// assert!(diagnostics.is_empty());
113/// ```
114pub fn parse_diagnostics(stderr: &str) -> Vec<RustDiagnostic> {
115    stderr
116        .lines()
117        .filter_map(|line| {
118            let line = line.trim();
119            if line.is_empty() {
120                return None;
121            }
122            let raw: RawDiagnostic = serde_json::from_str(line).ok()?;
123            Some(RustDiagnostic {
124                level: raw.level,
125                message: raw.message,
126                spans: raw
127                    .spans
128                    .into_iter()
129                    .map(|s| DiagnosticSpan {
130                        file_name: s.file_name,
131                        line_start: s.line_start,
132                        line_end: s.line_end,
133                        column_start: s.column_start,
134                        column_end: s.column_end,
135                        text: s
136                            .text
137                            .into_iter()
138                            .map(|t| SpanText {
139                                text: t.text,
140                                highlight_start: t.highlight_start,
141                                highlight_end: t.highlight_end,
142                            })
143                            .collect(),
144                    })
145                    .collect(),
146                code: raw.code.map(|c| c.code),
147            })
148        })
149        .collect()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn parse_empty_stderr() {
158        let diagnostics = parse_diagnostics("");
159        assert!(diagnostics.is_empty());
160    }
161
162    #[test]
163    fn parse_non_json_lines_skipped() {
164        let stderr = "some random text\nnot json at all\n";
165        let diagnostics = parse_diagnostics(stderr);
166        assert!(diagnostics.is_empty());
167    }
168
169    #[test]
170    fn parse_single_error_diagnostic() {
171        let stderr = r#"{"message":"expected `;`","code":{"code":"E0308","explanation":null},"level":"error","spans":[{"file_name":"main.rs","byte_start":10,"byte_end":11,"line_start":1,"line_end":1,"column_start":11,"column_end":12,"is_primary":true,"text":[{"text":"let x = 1","highlight_start":11,"highlight_end":12}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error: expected `;`"}"#;
172        let diagnostics = parse_diagnostics(stderr);
173        assert_eq!(diagnostics.len(), 1);
174        assert_eq!(diagnostics[0].level, "error");
175        assert_eq!(diagnostics[0].message, "expected `;`");
176        assert_eq!(diagnostics[0].code.as_deref(), Some("E0308"));
177        assert_eq!(diagnostics[0].spans.len(), 1);
178        assert_eq!(diagnostics[0].spans[0].file_name, "main.rs");
179        assert_eq!(diagnostics[0].spans[0].line_start, 1);
180        assert_eq!(diagnostics[0].spans[0].column_start, 11);
181        assert_eq!(diagnostics[0].spans[0].text.len(), 1);
182        assert_eq!(diagnostics[0].spans[0].text[0].text, "let x = 1");
183    }
184
185    #[test]
186    fn parse_warning_without_code() {
187        let stderr = r#"{"message":"unused variable: `x`","code":null,"level":"warning","spans":[],"children":[],"rendered":"warning: unused variable"}"#;
188        let diagnostics = parse_diagnostics(stderr);
189        assert_eq!(diagnostics.len(), 1);
190        assert_eq!(diagnostics[0].level, "warning");
191        assert!(diagnostics[0].code.is_none());
192        assert!(diagnostics[0].spans.is_empty());
193    }
194
195    #[test]
196    fn parse_mixed_json_and_text() {
197        let stderr = format!(
198            "{}\nerror[E0308]: mismatched types\n{}",
199            r#"{"message":"type mismatch","code":{"code":"E0308","explanation":null},"level":"error","spans":[],"children":[],"rendered":"error"}"#,
200            r#"{"message":"help: consider","code":null,"level":"help","spans":[],"children":[],"rendered":"help"}"#,
201        );
202        let diagnostics = parse_diagnostics(&stderr);
203        assert_eq!(diagnostics.len(), 2);
204        assert_eq!(diagnostics[0].level, "error");
205        assert_eq!(diagnostics[1].level, "help");
206    }
207
208    #[test]
209    fn parse_diagnostic_with_multiple_spans() {
210        let stderr = r#"{"message":"mismatched types","code":{"code":"E0308","explanation":null},"level":"error","spans":[{"file_name":"main.rs","byte_start":10,"byte_end":11,"line_start":1,"line_end":1,"column_start":11,"column_end":12,"is_primary":true,"text":[{"text":"let x: i32 = \"hello\"","highlight_start":15,"highlight_end":22}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null},{"file_name":"main.rs","byte_start":20,"byte_end":25,"line_start":2,"line_end":2,"column_start":5,"column_end":10,"is_primary":false,"text":[{"text":"    x + 1","highlight_start":5,"highlight_end":10}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error"}"#;
211        let diagnostics = parse_diagnostics(stderr);
212        assert_eq!(diagnostics.len(), 1);
213        assert_eq!(diagnostics[0].spans.len(), 2);
214        assert_eq!(diagnostics[0].spans[0].line_start, 1);
215        assert_eq!(diagnostics[0].spans[1].line_start, 2);
216    }
217}