1use crate::Fix;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SourceSpan {
14 pub file: PathBuf,
16 pub start_line: u32,
18 pub start_col: u32,
20 pub end_line: u32,
22 pub end_col: u32,
24}
25
26impl SourceSpan {
27 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 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 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 pub fn overlaps(&self, other: &SourceSpan) -> bool {
74 if self.file != other.file {
75 return false;
76 }
77 !(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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum DiagnosticSeverity {
101 Error,
103 Warning,
105 Info,
107 Hint,
109}
110
111impl DiagnosticSeverity {
112 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#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TextEdit {
132 pub range: SourceSpan,
134 pub new_text: String,
136}
137
138impl TextEdit {
139 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 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 pub fn delete(range: SourceSpan) -> Self {
157 Self {
158 range,
159 new_text: String::new(),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct QuickFix {
167 pub title: String,
169 pub edit: Option<TextEdit>,
171 pub command: Option<String>,
173 pub is_preferred: bool,
175}
176
177impl QuickFix {
178 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 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 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 pub fn preferred(mut self) -> Self {
210 self.is_preferred = true;
211 self
212 }
213
214 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#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct GhcDiagnostic {
227 pub span: Option<SourceSpan>,
229 pub severity: DiagnosticSeverity,
231 pub code: Option<String>,
233 pub warning_flag: Option<String>,
235 pub message: String,
237 pub hints: Vec<String>,
239 pub fixes: Vec<QuickFix>,
241}
242
243impl GhcDiagnostic {
244 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 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 pub fn with_span(mut self, span: SourceSpan) -> Self {
272 self.span = Some(span);
273 self
274 }
275
276 pub fn with_code(mut self, code: impl Into<String>) -> Self {
278 self.code = Some(code.into());
279 self
280 }
281
282 pub fn with_warning_flag(mut self, flag: impl Into<String>) -> Self {
284 self.warning_flag = Some(flag.into());
285 self
286 }
287
288 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
290 self.hints.push(hint.into());
291 self
292 }
293
294 pub fn with_fix(mut self, fix: QuickFix) -> Self {
296 self.fixes.push(fix);
297 self
298 }
299
300 pub fn is_error(&self) -> bool {
302 self.severity.is_error()
303 }
304
305 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 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 pub fn file(&self) -> Option<&PathBuf> {
327 self.span.as_ref().map(|s| &s.file)
328 }
329
330 pub fn format(&self) -> String {
332 let mut output = String::new();
333
334 if let Some(ref span) = self.span {
336 output.push_str(&format!("{}: ", span));
337 }
338
339 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 output.push_str(&self.message);
351
352 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370pub struct DiagnosticReport {
371 pub by_file: HashMap<PathBuf, Vec<GhcDiagnostic>>,
373 pub general: Vec<GhcDiagnostic>,
375}
376
377impl DiagnosticReport {
378 pub fn new() -> Self {
380 Self::default()
381 }
382
383 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 pub fn extend(&mut self, diagnostics: impl IntoIterator<Item = GhcDiagnostic>) {
397 for diag in diagnostics {
398 self.add(diag);
399 }
400 }
401
402 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 pub fn for_file(&self, file: &PathBuf) -> Option<&Vec<GhcDiagnostic>> {
412 self.by_file.get(file)
413 }
414
415 pub fn iter(&self) -> impl Iterator<Item = &GhcDiagnostic> {
417 self.by_file.values().flatten().chain(self.general.iter())
418 }
419
420 pub fn error_count(&self) -> usize {
422 self.iter().filter(|d| d.is_error()).count()
423 }
424
425 pub fn warning_count(&self) -> usize {
427 self.iter()
428 .filter(|d| d.severity == DiagnosticSeverity::Warning)
429 .count()
430 }
431
432 pub fn total_count(&self) -> usize {
434 self.by_file.values().map(|v| v.len()).sum::<usize>() + self.general.len()
435 }
436
437 pub fn has_errors(&self) -> bool {
439 self.iter().any(|d| d.is_error())
440 }
441
442 pub fn is_empty(&self) -> bool {
444 self.by_file.is_empty() && self.general.is_empty()
445 }
446
447 pub fn files(&self) -> impl Iterator<Item = &PathBuf> {
449 self.by_file.keys()
450 }
451
452 pub fn clear_file(&mut self, file: &PathBuf) {
454 self.by_file.remove(file);
455 }
456
457 pub fn clear(&mut self) {
459 self.by_file.clear();
460 self.general.clear();
461 }
462
463 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 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)); }
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}