Skip to main content

hx_core/
diagnostic.rs

1//! Structured diagnostics for GHC compiler output.
2//!
3//! This module provides types for representing GHC diagnostics with proper
4//! source locations, enabling LSP integration and rich error display.
5
6use crate::Fix;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11/// A source location span in a file.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SourceSpan {
14    /// File path
15    pub file: PathBuf,
16    /// Start line (1-indexed)
17    pub start_line: u32,
18    /// Start column (1-indexed)
19    pub start_col: u32,
20    /// End line (1-indexed)
21    pub end_line: u32,
22    /// End column (1-indexed)
23    pub end_col: u32,
24}
25
26impl SourceSpan {
27    /// Create a new source span.
28    pub fn new(
29        file: impl Into<PathBuf>,
30        start_line: u32,
31        start_col: u32,
32        end_line: u32,
33        end_col: u32,
34    ) -> Self {
35        Self {
36            file: file.into(),
37            start_line,
38            start_col,
39            end_line,
40            end_col,
41        }
42    }
43
44    /// Create a span from GHC-style point location (file:line:col).
45    ///
46    /// GHC often gives point locations rather than ranges, so this creates
47    /// a minimal span at that point.
48    pub fn from_point(file: impl Into<PathBuf>, line: u32, col: u32) -> Self {
49        Self {
50            file: file.into(),
51            start_line: line,
52            start_col: col,
53            end_line: line,
54            end_col: col.saturating_add(1),
55        }
56    }
57
58    /// Check if this span contains a position.
59    pub fn contains(&self, line: u32, col: u32) -> bool {
60        if line < self.start_line || line > self.end_line {
61            return false;
62        }
63        if line == self.start_line && col < self.start_col {
64            return false;
65        }
66        if line == self.end_line && col > self.end_col {
67            return false;
68        }
69        true
70    }
71
72    /// Check if this span overlaps with another.
73    pub fn overlaps(&self, other: &SourceSpan) -> bool {
74        if self.file != other.file {
75            return false;
76        }
77        // Check if ranges overlap
78        !(self.end_line < other.start_line
79            || (self.end_line == other.start_line && self.end_col < other.start_col)
80            || other.end_line < self.start_line
81            || (other.end_line == self.start_line && other.end_col < self.start_col))
82    }
83}
84
85impl std::fmt::Display for SourceSpan {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(
88            f,
89            "{}:{}:{}",
90            self.file.display(),
91            self.start_line,
92            self.start_col
93        )
94    }
95}
96
97/// Diagnostic severity level.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum DiagnosticSeverity {
101    /// Compilation error - prevents successful build
102    Error,
103    /// Warning - build succeeds but indicates potential issues
104    Warning,
105    /// Informational message
106    Info,
107    /// Hint or suggestion
108    Hint,
109}
110
111impl DiagnosticSeverity {
112    /// Check if this severity prevents a successful build.
113    pub fn is_error(&self) -> bool {
114        matches!(self, DiagnosticSeverity::Error)
115    }
116}
117
118impl std::fmt::Display for DiagnosticSeverity {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            DiagnosticSeverity::Error => write!(f, "error"),
122            DiagnosticSeverity::Warning => write!(f, "warning"),
123            DiagnosticSeverity::Info => write!(f, "info"),
124            DiagnosticSeverity::Hint => write!(f, "hint"),
125        }
126    }
127}
128
129/// A text edit (replacement) for quick fixes.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TextEdit {
132    /// The range to replace
133    pub range: SourceSpan,
134    /// New text to insert
135    pub new_text: String,
136}
137
138impl TextEdit {
139    /// Create a new text edit.
140    pub fn new(range: SourceSpan, new_text: impl Into<String>) -> Self {
141        Self {
142            range,
143            new_text: new_text.into(),
144        }
145    }
146
147    /// Create an insertion at a point.
148    pub fn insert(file: impl Into<PathBuf>, line: u32, col: u32, text: impl Into<String>) -> Self {
149        Self {
150            range: SourceSpan::from_point(file, line, col),
151            new_text: text.into(),
152        }
153    }
154
155    /// Create a deletion of a range.
156    pub fn delete(range: SourceSpan) -> Self {
157        Self {
158            range,
159            new_text: String::new(),
160        }
161    }
162}
163
164/// A quick fix suggestion for a diagnostic.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct QuickFix {
167    /// Human-readable title for the fix
168    pub title: String,
169    /// Text edit to apply (if available)
170    pub edit: Option<TextEdit>,
171    /// Shell command alternative (if no direct edit)
172    pub command: Option<String>,
173    /// Whether this is the preferred fix
174    pub is_preferred: bool,
175}
176
177impl QuickFix {
178    /// Create a quick fix with a text edit.
179    pub fn with_edit(title: impl Into<String>, edit: TextEdit) -> Self {
180        Self {
181            title: title.into(),
182            edit: Some(edit),
183            command: None,
184            is_preferred: false,
185        }
186    }
187
188    /// Create a quick fix with a command.
189    pub fn with_command(title: impl Into<String>, command: impl Into<String>) -> Self {
190        Self {
191            title: title.into(),
192            edit: None,
193            command: Some(command.into()),
194            is_preferred: false,
195        }
196    }
197
198    /// Create a quick fix with just a description.
199    pub fn suggestion(title: impl Into<String>) -> Self {
200        Self {
201            title: title.into(),
202            edit: None,
203            command: None,
204            is_preferred: false,
205        }
206    }
207
208    /// Mark this fix as preferred.
209    pub fn preferred(mut self) -> Self {
210        self.is_preferred = true;
211        self
212    }
213
214    /// Convert to the error module's Fix type.
215    pub fn to_fix(&self) -> Fix {
216        if let Some(ref cmd) = self.command {
217            Fix::with_command(&self.title, cmd)
218        } else {
219            Fix::new(&self.title)
220        }
221    }
222}
223
224/// A structured GHC diagnostic.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct GhcDiagnostic {
227    /// Location in source (None for general diagnostics)
228    pub span: Option<SourceSpan>,
229    /// Severity level
230    pub severity: DiagnosticSeverity,
231    /// GHC diagnostic code (e.g., "GHC-12345" for GHC 9.6+)
232    pub code: Option<String>,
233    /// Warning flag (e.g., "-Wunused-imports")
234    pub warning_flag: Option<String>,
235    /// Main diagnostic message
236    pub message: String,
237    /// Additional context/hints from GHC
238    pub hints: Vec<String>,
239    /// Quick fix suggestions
240    pub fixes: Vec<QuickFix>,
241}
242
243impl GhcDiagnostic {
244    /// Create a new error diagnostic.
245    pub fn error(message: impl Into<String>) -> Self {
246        Self {
247            span: None,
248            severity: DiagnosticSeverity::Error,
249            code: None,
250            warning_flag: None,
251            message: message.into(),
252            hints: Vec::new(),
253            fixes: Vec::new(),
254        }
255    }
256
257    /// Create a new warning diagnostic.
258    pub fn warning(message: impl Into<String>) -> Self {
259        Self {
260            span: None,
261            severity: DiagnosticSeverity::Warning,
262            code: None,
263            warning_flag: None,
264            message: message.into(),
265            hints: Vec::new(),
266            fixes: Vec::new(),
267        }
268    }
269
270    /// Set the source span.
271    pub fn with_span(mut self, span: SourceSpan) -> Self {
272        self.span = Some(span);
273        self
274    }
275
276    /// Set the diagnostic code.
277    pub fn with_code(mut self, code: impl Into<String>) -> Self {
278        self.code = Some(code.into());
279        self
280    }
281
282    /// Set the warning flag.
283    pub fn with_warning_flag(mut self, flag: impl Into<String>) -> Self {
284        self.warning_flag = Some(flag.into());
285        self
286    }
287
288    /// Add a hint.
289    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
290        self.hints.push(hint.into());
291        self
292    }
293
294    /// Add a quick fix.
295    pub fn with_fix(mut self, fix: QuickFix) -> Self {
296        self.fixes.push(fix);
297        self
298    }
299
300    /// Check if this is an error.
301    pub fn is_error(&self) -> bool {
302        self.severity.is_error()
303    }
304
305    /// Check if this diagnostic has the "unused" tag.
306    pub fn is_unused(&self) -> bool {
307        if let Some(ref flag) = self.warning_flag {
308            flag.contains("unused") || flag.contains("redundant")
309        } else {
310            self.message.contains("not used")
311                || self.message.contains("redundant")
312                || self.message.contains("Defined but not used")
313        }
314    }
315
316    /// Check if this diagnostic has the "deprecated" tag.
317    pub fn is_deprecated(&self) -> bool {
318        if let Some(ref flag) = self.warning_flag {
319            flag.contains("deprecated")
320        } else {
321            self.message.contains("deprecated")
322        }
323    }
324
325    /// Get the file path if available.
326    pub fn file(&self) -> Option<&PathBuf> {
327        self.span.as_ref().map(|s| &s.file)
328    }
329
330    /// Format the diagnostic for display.
331    pub fn format(&self) -> String {
332        let mut output = String::new();
333
334        // Location prefix
335        if let Some(ref span) = self.span {
336            output.push_str(&format!("{}: ", span));
337        }
338
339        // Severity and code
340        output.push_str(&format!("{}", self.severity));
341        if let Some(ref code) = self.code {
342            output.push_str(&format!(" [{}]", code));
343        }
344        if let Some(ref flag) = self.warning_flag {
345            output.push_str(&format!(" [{}]", flag));
346        }
347        output.push_str(": ");
348
349        // Message
350        output.push_str(&self.message);
351
352        // Hints
353        for hint in &self.hints {
354            output.push_str("\n    ");
355            output.push_str(hint);
356        }
357
358        output
359    }
360}
361
362impl std::fmt::Display for GhcDiagnostic {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        write!(f, "{}", self.format())
365    }
366}
367
368/// Collection of diagnostics from a build.
369#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370pub struct DiagnosticReport {
371    /// Diagnostics grouped by file
372    pub by_file: HashMap<PathBuf, Vec<GhcDiagnostic>>,
373    /// Diagnostics without a file location
374    pub general: Vec<GhcDiagnostic>,
375}
376
377impl DiagnosticReport {
378    /// Create a new empty report.
379    pub fn new() -> Self {
380        Self::default()
381    }
382
383    /// Add a diagnostic to the report.
384    pub fn add(&mut self, diagnostic: GhcDiagnostic) {
385        if let Some(ref span) = diagnostic.span {
386            self.by_file
387                .entry(span.file.clone())
388                .or_default()
389                .push(diagnostic);
390        } else {
391            self.general.push(diagnostic);
392        }
393    }
394
395    /// Add multiple diagnostics.
396    pub fn extend(&mut self, diagnostics: impl IntoIterator<Item = GhcDiagnostic>) {
397        for diag in diagnostics {
398            self.add(diag);
399        }
400    }
401
402    /// Merge another report into this one.
403    pub fn merge(&mut self, other: DiagnosticReport) {
404        for (file, diagnostics) in other.by_file {
405            self.by_file.entry(file).or_default().extend(diagnostics);
406        }
407        self.general.extend(other.general);
408    }
409
410    /// Get all diagnostics for a file.
411    pub fn for_file(&self, file: &PathBuf) -> Option<&Vec<GhcDiagnostic>> {
412        self.by_file.get(file)
413    }
414
415    /// Iterate over all diagnostics.
416    pub fn iter(&self) -> impl Iterator<Item = &GhcDiagnostic> {
417        self.by_file.values().flatten().chain(self.general.iter())
418    }
419
420    /// Count of error diagnostics.
421    pub fn error_count(&self) -> usize {
422        self.iter().filter(|d| d.is_error()).count()
423    }
424
425    /// Count of warning diagnostics.
426    pub fn warning_count(&self) -> usize {
427        self.iter()
428            .filter(|d| d.severity == DiagnosticSeverity::Warning)
429            .count()
430    }
431
432    /// Total count of all diagnostics.
433    pub fn total_count(&self) -> usize {
434        self.by_file.values().map(|v| v.len()).sum::<usize>() + self.general.len()
435    }
436
437    /// Check if there are any errors.
438    pub fn has_errors(&self) -> bool {
439        self.iter().any(|d| d.is_error())
440    }
441
442    /// Check if the report is empty.
443    pub fn is_empty(&self) -> bool {
444        self.by_file.is_empty() && self.general.is_empty()
445    }
446
447    /// Get all files with diagnostics.
448    pub fn files(&self) -> impl Iterator<Item = &PathBuf> {
449        self.by_file.keys()
450    }
451
452    /// Clear all diagnostics for a file.
453    pub fn clear_file(&mut self, file: &PathBuf) {
454        self.by_file.remove(file);
455    }
456
457    /// Clear all diagnostics.
458    pub fn clear(&mut self) {
459        self.by_file.clear();
460        self.general.clear();
461    }
462
463    /// Convert errors to `Vec<String>` for compatibility.
464    pub fn errors_as_strings(&self) -> Vec<String> {
465        self.iter()
466            .filter(|d| d.is_error())
467            .map(|d| d.format())
468            .collect()
469    }
470
471    /// Convert warnings to `Vec<String>` for compatibility.
472    pub fn warnings_as_strings(&self) -> Vec<String> {
473        self.iter()
474            .filter(|d| d.severity == DiagnosticSeverity::Warning)
475            .map(|d| d.format())
476            .collect()
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn test_source_span_from_point() {
486        let span = SourceSpan::from_point("src/Main.hs", 10, 5);
487        assert_eq!(span.start_line, 10);
488        assert_eq!(span.start_col, 5);
489        assert_eq!(span.end_line, 10);
490        assert_eq!(span.end_col, 6);
491    }
492
493    #[test]
494    fn test_source_span_contains() {
495        let span = SourceSpan::new("test.hs", 10, 5, 10, 15);
496        assert!(span.contains(10, 5));
497        assert!(span.contains(10, 10));
498        assert!(span.contains(10, 15));
499        assert!(!span.contains(10, 4));
500        assert!(!span.contains(10, 16));
501        assert!(!span.contains(9, 10));
502        assert!(!span.contains(11, 10));
503    }
504
505    #[test]
506    fn test_source_span_overlaps() {
507        let span1 = SourceSpan::new("test.hs", 10, 5, 10, 15);
508        let span2 = SourceSpan::new("test.hs", 10, 10, 10, 20);
509        let span3 = SourceSpan::new("test.hs", 10, 20, 10, 30);
510        let span4 = SourceSpan::new("other.hs", 10, 5, 10, 15);
511
512        assert!(span1.overlaps(&span2));
513        assert!(!span1.overlaps(&span3));
514        assert!(!span1.overlaps(&span4)); // Different file
515    }
516
517    #[test]
518    fn test_diagnostic_severity_display() {
519        assert_eq!(format!("{}", DiagnosticSeverity::Error), "error");
520        assert_eq!(format!("{}", DiagnosticSeverity::Warning), "warning");
521    }
522
523    #[test]
524    fn test_ghc_diagnostic_builder() {
525        let diag = GhcDiagnostic::error("Variable not in scope: foo")
526            .with_span(SourceSpan::from_point("src/Main.hs", 10, 5))
527            .with_code("GHC-88464")
528            .with_hint("Perhaps you meant 'fooBar'");
529
530        assert!(diag.is_error());
531        assert_eq!(diag.code, Some("GHC-88464".to_string()));
532        assert_eq!(diag.hints.len(), 1);
533    }
534
535    #[test]
536    fn test_diagnostic_report() {
537        let mut report = DiagnosticReport::new();
538
539        report.add(GhcDiagnostic::error("Error 1").with_span(SourceSpan::from_point("a.hs", 1, 1)));
540        report.add(
541            GhcDiagnostic::warning("Warning 1").with_span(SourceSpan::from_point("a.hs", 2, 1)),
542        );
543        report.add(GhcDiagnostic::error("Error 2").with_span(SourceSpan::from_point("b.hs", 1, 1)));
544        report.add(GhcDiagnostic::error("General error"));
545
546        assert_eq!(report.error_count(), 3);
547        assert_eq!(report.warning_count(), 1);
548        assert_eq!(report.total_count(), 4);
549        assert!(report.has_errors());
550        assert_eq!(report.files().count(), 2);
551    }
552
553    #[test]
554    fn test_quick_fix() {
555        let fix = QuickFix::with_command("Add import", "hx add text").preferred();
556        assert!(fix.is_preferred);
557        assert_eq!(fix.command, Some("hx add text".to_string()));
558
559        let core_fix = fix.to_fix();
560        assert_eq!(core_fix.command, Some("hx add text".to_string()));
561    }
562
563    #[test]
564    fn test_diagnostic_is_unused() {
565        let diag1 = GhcDiagnostic::warning("Defined but not used: 'x'");
566        assert!(diag1.is_unused());
567
568        let diag2 =
569            GhcDiagnostic::warning("Import is redundant").with_warning_flag("-Wunused-imports");
570        assert!(diag2.is_unused());
571
572        let diag3 = GhcDiagnostic::error("Type mismatch");
573        assert!(!diag3.is_unused());
574    }
575}