Skip to main content

bhc_diagnostics/
json.rs

1//! JSON diagnostic output format.
2//!
3//! This module provides machine-readable JSON output for diagnostics,
4//! compatible with IDE tooling and CI systems.
5//!
6//! ## Format
7//!
8//! The output format is designed to be compatible with common conventions:
9//!
10//! ```json
11//! {
12//!   "message": "error message",
13//!   "code": "E0001",
14//!   "severity": "error",
15//!   "spans": [
16//!     {
17//!       "file": "src/main.hs",
18//!       "line_start": 10,
19//!       "line_end": 10,
20//!       "column_start": 5,
21//!       "column_end": 15,
22//!       "is_primary": true,
23//!       "label": "expected Int, found String"
24//!     }
25//!   ],
26//!   "notes": ["consider using show"],
27//!   "suggestions": [
28//!     {
29//!       "message": "try this",
30//!       "replacement": "show x"
31//!     }
32//!   ]
33//! }
34//! ```
35
36use serde::{Deserialize, Serialize};
37
38use crate::{Applicability, Diagnostic, Severity, SourceMap};
39
40/// A diagnostic in JSON format.
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct JsonDiagnostic {
43    /// The main error message.
44    pub message: String,
45
46    /// The error code, if any.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub code: Option<String>,
49
50    /// The severity level.
51    pub severity: JsonSeverity,
52
53    /// The source spans associated with this diagnostic.
54    pub spans: Vec<JsonSpan>,
55
56    /// Additional notes.
57    #[serde(skip_serializing_if = "Vec::is_empty")]
58    pub notes: Vec<String>,
59
60    /// Suggested fixes.
61    #[serde(skip_serializing_if = "Vec::is_empty")]
62    pub suggestions: Vec<JsonSuggestion>,
63
64    /// Child diagnostics (for related information).
65    #[serde(skip_serializing_if = "Vec::is_empty")]
66    pub children: Vec<JsonDiagnostic>,
67}
68
69/// Severity level in JSON format.
70#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(rename_all = "lowercase")]
72pub enum JsonSeverity {
73    /// Internal compiler error.
74    Bug,
75    /// A fatal error.
76    Error,
77    /// A warning.
78    Warning,
79    /// Informational note.
80    Note,
81    /// Help message.
82    Help,
83}
84
85impl From<Severity> for JsonSeverity {
86    fn from(severity: Severity) -> Self {
87        match severity {
88            Severity::Bug => Self::Bug,
89            Severity::Error => Self::Error,
90            Severity::Warning => Self::Warning,
91            Severity::Note => Self::Note,
92            Severity::Help => Self::Help,
93        }
94    }
95}
96
97/// A source span in JSON format.
98#[derive(Clone, Debug, Serialize, Deserialize)]
99pub struct JsonSpan {
100    /// The file path.
101    pub file: String,
102
103    /// The starting line (1-indexed).
104    pub line_start: usize,
105
106    /// The ending line (1-indexed).
107    pub line_end: usize,
108
109    /// The starting column (1-indexed).
110    pub column_start: usize,
111
112    /// The ending column (1-indexed).
113    pub column_end: usize,
114
115    /// Whether this is the primary span.
116    pub is_primary: bool,
117
118    /// The label for this span, if any.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub label: Option<String>,
121
122    /// The source text at this span.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub text: Option<String>,
125}
126
127/// A suggested fix in JSON format.
128#[derive(Clone, Debug, Serialize, Deserialize)]
129pub struct JsonSuggestion {
130    /// The suggestion message.
131    pub message: String,
132
133    /// The replacement text.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub replacement: Option<String>,
136
137    /// The span to replace.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub span: Option<JsonSpan>,
140
141    /// How applicable this suggestion is.
142    pub applicability: JsonApplicability,
143}
144
145/// Applicability in JSON format.
146#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum JsonApplicability {
149    /// The fix is definitely correct.
150    MachineApplicable,
151    /// The fix might be correct.
152    MaybeIncorrect,
153    /// The fix has placeholders.
154    HasPlaceholders,
155    /// The fix is just a hint.
156    Unspecified,
157}
158
159impl From<Applicability> for JsonApplicability {
160    fn from(applicability: Applicability) -> Self {
161        match applicability {
162            Applicability::MachineApplicable => Self::MachineApplicable,
163            Applicability::MaybeIncorrect => Self::MaybeIncorrect,
164            Applicability::HasPlaceholders => Self::HasPlaceholders,
165            Applicability::Unspecified => Self::Unspecified,
166        }
167    }
168}
169
170/// Convert a diagnostic to JSON format.
171pub fn diagnostic_to_json(diagnostic: &Diagnostic, source_map: &SourceMap) -> JsonDiagnostic {
172    let spans = diagnostic
173        .labels
174        .iter()
175        .filter_map(|label| {
176            let file = source_map.get_file(label.span.file)?;
177            let span_info = file.span_lines(label.span.span);
178
179            Some(JsonSpan {
180                file: file.name.clone(),
181                line_start: span_info.start_line,
182                line_end: span_info.end_line,
183                column_start: span_info.start_col,
184                column_end: span_info.end_col,
185                is_primary: label.primary,
186                label: if label.message.is_empty() {
187                    None
188                } else {
189                    Some(label.message.clone())
190                },
191                text: if label.span.span.is_dummy() {
192                    None
193                } else {
194                    Some(file.source_text(label.span.span).to_string())
195                },
196            })
197        })
198        .collect();
199
200    let suggestions = diagnostic
201        .suggestions
202        .iter()
203        .map(|s| {
204            let span = source_map.get_file(s.span.file).map(|file| {
205                let span_info = file.span_lines(s.span.span);
206                JsonSpan {
207                    file: file.name.clone(),
208                    line_start: span_info.start_line,
209                    line_end: span_info.end_line,
210                    column_start: span_info.start_col,
211                    column_end: span_info.end_col,
212                    is_primary: true,
213                    label: None,
214                    text: None,
215                }
216            });
217
218            JsonSuggestion {
219                message: s.message.clone(),
220                replacement: if s.replacement.is_empty() {
221                    None
222                } else {
223                    Some(s.replacement.clone())
224                },
225                span,
226                applicability: s.applicability.into(),
227            }
228        })
229        .collect();
230
231    JsonDiagnostic {
232        message: diagnostic.message.clone(),
233        code: diagnostic.code.clone(),
234        severity: diagnostic.severity.into(),
235        spans,
236        notes: diagnostic.notes.clone(),
237        suggestions,
238        children: Vec::new(),
239    }
240}
241
242/// Convert multiple diagnostics to JSON format.
243#[must_use]
244pub fn diagnostics_to_json(
245    diagnostics: &[Diagnostic],
246    source_map: &SourceMap,
247) -> Vec<JsonDiagnostic> {
248    diagnostics
249        .iter()
250        .map(|d| diagnostic_to_json(d, source_map))
251        .collect()
252}
253
254/// Serialize diagnostics to a JSON string.
255pub fn to_json_string(
256    diagnostics: &[Diagnostic],
257    source_map: &SourceMap,
258) -> Result<String, serde_json::Error> {
259    let json_diags = diagnostics_to_json(diagnostics, source_map);
260    serde_json::to_string_pretty(&json_diags)
261}
262
263/// Serialize diagnostics to a JSON string (compact, one per line).
264pub fn to_json_lines(
265    diagnostics: &[Diagnostic],
266    source_map: &SourceMap,
267) -> Result<String, serde_json::Error> {
268    let mut output = String::new();
269    for diag in diagnostics {
270        let json_diag = diagnostic_to_json(diag, source_map);
271        output.push_str(&serde_json::to_string(&json_diag)?);
272        output.push('\n');
273    }
274    Ok(output)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use bhc_span::{FileId, FullSpan, Span};
281
282    fn create_test_source_map() -> SourceMap {
283        let mut sm = SourceMap::new();
284        sm.add_file(
285            "test.hs".to_string(),
286            "module Test where\n\nfoo :: Int -> Int\nfoo x = x + y\n".to_string(),
287        );
288        sm
289    }
290
291    #[test]
292    fn test_json_diagnostic() {
293        let sm = create_test_source_map();
294
295        let diag = Diagnostic::error("undefined variable `y`")
296            .with_code("E0001")
297            .with_label(
298                FullSpan::new(FileId::new(0), Span::from_raw(50, 51)),
299                "not found in this scope",
300            )
301            .with_note("consider defining `y`");
302
303        let json_diag = diagnostic_to_json(&diag, &sm);
304
305        assert_eq!(json_diag.message, "undefined variable `y`");
306        assert_eq!(json_diag.code, Some("E0001".to_string()));
307        assert_eq!(json_diag.severity, JsonSeverity::Error);
308        assert_eq!(json_diag.spans.len(), 1);
309        assert_eq!(json_diag.spans[0].file, "test.hs");
310        assert!(json_diag.spans[0].is_primary);
311        assert_eq!(json_diag.notes.len(), 1);
312    }
313
314    #[test]
315    fn test_json_serialization() {
316        let sm = create_test_source_map();
317
318        let diag = Diagnostic::error("test error")
319            .with_code("E0001")
320            .with_label(FullSpan::new(FileId::new(0), Span::from_raw(0, 6)), "here");
321
322        let json_str = to_json_string(&[diag], &sm).unwrap();
323
324        assert!(json_str.contains("\"message\": \"test error\""));
325        assert!(json_str.contains("\"severity\": \"error\""));
326        assert!(json_str.contains("\"code\": \"E0001\""));
327    }
328
329    #[test]
330    fn test_json_lines() {
331        let sm = create_test_source_map();
332
333        let diag1 = Diagnostic::error("error 1");
334        let diag2 = Diagnostic::warning("warning 1");
335
336        let output = to_json_lines(&[diag1, diag2], &sm).unwrap();
337        let lines: Vec<&str> = output.lines().collect();
338
339        assert_eq!(lines.len(), 2);
340        assert!(lines[0].contains("error 1"));
341        assert!(lines[1].contains("warning 1"));
342    }
343}