1use crate::{Applicability, Diagnostic, Severity, SourceMap, Suggestion};
25use bhc_span::SourceFile;
26use serde::{Deserialize, Serialize};
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum LspSeverity {
32 Error = 1,
34 Warning = 2,
36 Information = 3,
38 Hint = 4,
40}
41
42impl From<Severity> for LspSeverity {
43 fn from(severity: Severity) -> Self {
44 match severity {
45 Severity::Bug | Severity::Error => Self::Error,
46 Severity::Warning => Self::Warning,
47 Severity::Note => Self::Information,
48 Severity::Help => Self::Hint,
49 }
50 }
51}
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct LspPosition {
56 pub line: u32,
58 pub character: u32,
60}
61
62impl LspPosition {
63 #[must_use]
65 pub fn new(line: u32, character: u32) -> Self {
66 Self { line, character }
67 }
68}
69
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
72pub struct LspRange {
73 pub start: LspPosition,
75 pub end: LspPosition,
77}
78
79impl LspRange {
80 #[must_use]
82 pub fn new(start: LspPosition, end: LspPosition) -> Self {
83 Self { start, end }
84 }
85
86 #[must_use]
88 pub fn point(pos: LspPosition) -> Self {
89 Self {
90 start: pos,
91 end: pos,
92 }
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub struct LspLocation {
99 pub uri: String,
101 pub range: LspRange,
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub enum LspDiagnosticTag {
109 Unnecessary = 1,
111 Deprecated = 2,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct LspRelatedInformation {
119 pub location: LspLocation,
121 pub message: String,
123}
124
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(untagged)]
128pub enum LspDiagnosticCode {
129 String(String),
131 Number(i32),
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct LspDiagnostic {
139 pub range: LspRange,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub severity: Option<LspSeverity>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub code: Option<LspDiagnosticCode>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub code_description: Option<LspCodeDescription>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub source: Option<String>,
153 pub message: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub tags: Option<Vec<LspDiagnosticTag>>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub related_information: Option<Vec<LspRelatedInformation>>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub data: Option<serde_json::Value>,
164}
165
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
168pub struct LspCodeDescription {
169 pub href: String,
171}
172
173#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct LspTextEdit {
177 pub range: LspRange,
179 pub new_text: String,
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct LspTextDocumentEdit {
187 pub text_document: LspVersionedTextDocumentIdentifier,
189 pub edits: Vec<LspTextEdit>,
191}
192
193#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct LspVersionedTextDocumentIdentifier {
196 pub uri: String,
198 pub version: Option<i32>,
200}
201
202#[derive(Clone, Debug, Default, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct LspWorkspaceEdit {
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub document_changes: Option<Vec<LspTextDocumentEdit>>,
209}
210
211#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213pub struct CodeActionKind(pub String);
214
215impl CodeActionKind {
216 pub const QUICKFIX: &'static str = "quickfix";
218 pub const REFACTOR: &'static str = "refactor";
220 pub const SOURCE: &'static str = "source";
222}
223
224#[derive(Clone, Debug, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct LspCodeAction {
228 pub title: String,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub kind: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub diagnostics: Option<Vec<LspDiagnostic>>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub is_preferred: Option<bool>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub edit: Option<LspWorkspaceEdit>,
242}
243
244#[derive(Clone, Debug, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct LspHover {
248 pub contents: LspMarkupContent,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub range: Option<LspRange>,
253}
254
255#[derive(Clone, Debug, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct LspMarkupContent {
259 pub kind: String,
261 pub value: String,
263}
264
265impl LspMarkupContent {
266 #[must_use]
268 pub fn plaintext(value: impl Into<String>) -> Self {
269 Self {
270 kind: "plaintext".to_string(),
271 value: value.into(),
272 }
273 }
274
275 #[must_use]
277 pub fn markdown(value: impl Into<String>) -> Self {
278 Self {
279 kind: "markdown".to_string(),
280 value: value.into(),
281 }
282 }
283}
284
285#[must_use]
291pub fn span_to_range(file: &SourceFile, span: bhc_span::Span) -> LspRange {
292 if span.is_dummy() {
293 return LspRange::default();
294 }
295
296 let start_loc = file.lookup_line_col(span.lo);
297 let end_loc = file.lookup_line_col(span.hi);
298
299 LspRange {
300 start: LspPosition {
301 line: start_loc.line.saturating_sub(1) as u32,
303 character: start_loc.col.saturating_sub(1) as u32,
304 },
305 end: LspPosition {
306 line: end_loc.line.saturating_sub(1) as u32,
307 character: end_loc.col.saturating_sub(1) as u32,
308 },
309 }
310}
311
312#[must_use]
323pub fn to_lsp_diagnostic(diagnostic: &Diagnostic, source_map: &SourceMap) -> Option<LspDiagnostic> {
324 let primary_label = diagnostic.labels.iter().find(|l| l.primary)?;
326 let file = source_map.get_file(primary_label.span.file)?;
327 let range = span_to_range(file, primary_label.span.span);
328
329 let mut message = diagnostic.message.clone();
331 if !primary_label.message.is_empty() {
332 message.push_str("\n\n");
333 message.push_str(&primary_label.message);
334 }
335 for note in &diagnostic.notes {
336 message.push_str("\n\nnote: ");
337 message.push_str(note);
338 }
339
340 let related_information: Vec<LspRelatedInformation> = diagnostic
342 .labels
343 .iter()
344 .filter(|l| !l.primary)
345 .filter_map(|label| {
346 let file = source_map.get_file(label.span.file)?;
347 Some(LspRelatedInformation {
348 location: LspLocation {
349 uri: format!("file://{}", file.name),
350 range: span_to_range(file, label.span.span),
351 },
352 message: label.message.clone(),
353 })
354 })
355 .collect();
356
357 let code_description = diagnostic.code.as_ref().map(|code| LspCodeDescription {
359 href: format!("https://bhc.dev/errors/{code}"),
360 });
361
362 let tags = detect_diagnostic_tags(diagnostic);
364
365 Some(LspDiagnostic {
366 range,
367 severity: Some(diagnostic.severity.into()),
368 code: diagnostic
369 .code
370 .as_ref()
371 .map(|c| LspDiagnosticCode::String(c.clone())),
372 code_description,
373 source: Some("bhc".to_string()),
374 message,
375 tags: if tags.is_empty() { None } else { Some(tags) },
376 related_information: if related_information.is_empty() {
377 None
378 } else {
379 Some(related_information)
380 },
381 data: None,
382 })
383}
384
385fn detect_diagnostic_tags(diagnostic: &Diagnostic) -> Vec<LspDiagnosticTag> {
387 let mut tags = Vec::new();
388
389 if let Some(code) = &diagnostic.code {
391 if code == "W0001" || diagnostic.message.to_lowercase().contains("unused") {
392 tags.push(LspDiagnosticTag::Unnecessary);
393 }
394 }
395
396 if diagnostic.message.to_lowercase().contains("deprecated") {
398 tags.push(LspDiagnosticTag::Deprecated);
399 }
400
401 tags
402}
403
404#[must_use]
417pub fn to_code_actions(
418 diagnostic: &Diagnostic,
419 source_map: &SourceMap,
420 uri: &str,
421 version: Option<i32>,
422) -> Vec<LspCodeAction> {
423 diagnostic
424 .suggestions
425 .iter()
426 .filter_map(|suggestion| {
427 suggestion_to_code_action(suggestion, diagnostic, source_map, uri, version)
428 })
429 .collect()
430}
431
432fn suggestion_to_code_action(
434 suggestion: &Suggestion,
435 diagnostic: &Diagnostic,
436 source_map: &SourceMap,
437 uri: &str,
438 version: Option<i32>,
439) -> Option<LspCodeAction> {
440 let file = source_map.get_file(suggestion.span.file)?;
441 let range = span_to_range(file, suggestion.span.span);
442
443 let is_preferred = matches!(suggestion.applicability, Applicability::MachineApplicable);
445
446 let edit = LspTextEdit {
448 range,
449 new_text: suggestion.replacement.clone(),
450 };
451
452 let workspace_edit = LspWorkspaceEdit {
454 document_changes: Some(vec![LspTextDocumentEdit {
455 text_document: LspVersionedTextDocumentIdentifier {
456 uri: uri.to_string(),
457 version,
458 },
459 edits: vec![edit],
460 }]),
461 };
462
463 let lsp_diag = to_lsp_diagnostic(diagnostic, source_map);
465
466 Some(LspCodeAction {
467 title: suggestion.message.clone(),
468 kind: Some(CodeActionKind::QUICKFIX.to_string()),
469 diagnostics: lsp_diag.map(|d| vec![d]),
470 is_preferred: Some(is_preferred),
471 edit: Some(workspace_edit),
472 })
473}
474
475#[must_use]
486pub fn to_hover(diagnostic: &Diagnostic, source_map: &SourceMap) -> Option<LspHover> {
487 let primary_label = diagnostic.labels.iter().find(|l| l.primary)?;
488 let file = source_map.get_file(primary_label.span.file)?;
489 let range = span_to_range(file, primary_label.span.span);
490
491 let mut content = String::new();
493
494 content.push_str("**");
496 content.push_str(diagnostic.severity.label());
497 if let Some(code) = &diagnostic.code {
498 content.push('[');
499 content.push_str(code);
500 content.push(']');
501 }
502 content.push_str("**: ");
503 content.push_str(&diagnostic.message);
504 content.push_str("\n\n");
505
506 if !primary_label.message.is_empty() {
508 content.push_str(&primary_label.message);
509 content.push_str("\n\n");
510 }
511
512 for note in &diagnostic.notes {
514 content.push_str("*Note*: ");
515 content.push_str(note);
516 content.push_str("\n\n");
517 }
518
519 if !diagnostic.suggestions.is_empty() {
521 content.push_str("---\n\n");
522 content.push_str("**Suggestions:**\n\n");
523 for suggestion in &diagnostic.suggestions {
524 content.push_str("- ");
525 content.push_str(&suggestion.message);
526 if !suggestion.replacement.is_empty() {
527 content.push_str("\n ```haskell\n ");
528 content.push_str(&suggestion.replacement);
529 content.push_str("\n ```");
530 }
531 content.push('\n');
532 }
533 }
534
535 if let Some(code) = &diagnostic.code {
537 content.push_str("\n---\n\n");
538 content.push_str(&format!(
539 "[View explanation for {code}](https://bhc.dev/errors/{code})"
540 ));
541 }
542
543 Some(LspHover {
544 contents: LspMarkupContent::markdown(content),
545 range: Some(range),
546 })
547}
548
549#[must_use]
560pub fn to_lsp_diagnostics(
561 diagnostics: &[Diagnostic],
562 source_map: &SourceMap,
563) -> Vec<LspDiagnostic> {
564 diagnostics
565 .iter()
566 .filter_map(|d| to_lsp_diagnostic(d, source_map))
567 .collect()
568}
569
570#[derive(Clone, Debug, Serialize, Deserialize)]
572#[serde(rename_all = "camelCase")]
573pub struct PublishDiagnosticsParams {
574 pub uri: String,
576 #[serde(skip_serializing_if = "Option::is_none")]
578 pub version: Option<i32>,
579 pub diagnostics: Vec<LspDiagnostic>,
581}
582
583#[must_use]
585pub fn publish_diagnostics(
586 uri: &str,
587 diagnostics: &[Diagnostic],
588 source_map: &SourceMap,
589 version: Option<i32>,
590) -> PublishDiagnosticsParams {
591 PublishDiagnosticsParams {
592 uri: uri.to_string(),
593 version,
594 diagnostics: to_lsp_diagnostics(diagnostics, source_map),
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use crate::FullSpan;
602 use bhc_span::{FileId, Span};
603
604 fn create_test_source_map() -> SourceMap {
605 let mut sm = SourceMap::new();
606 sm.add_file("test.hs".into(), "foo = x + 1\nbar = y".into());
607 sm
608 }
609
610 #[test]
611 fn test_to_lsp_diagnostic() {
612 let sm = create_test_source_map();
613 let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
614
615 let diag = Diagnostic::error("undefined variable `x`")
616 .with_code("E0003")
617 .with_label(span, "not found in this scope");
618
619 let lsp_diag = to_lsp_diagnostic(&diag, &sm).unwrap();
620
621 assert_eq!(lsp_diag.severity, Some(LspSeverity::Error));
622 assert!(lsp_diag.message.contains("undefined variable"));
623 assert_eq!(
624 lsp_diag.code,
625 Some(LspDiagnosticCode::String("E0003".to_string()))
626 );
627 assert_eq!(lsp_diag.source, Some("bhc".to_string()));
628 }
629
630 #[test]
631 fn test_to_code_actions() {
632 let sm = create_test_source_map();
633 let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
634
635 let diag = Diagnostic::error("undefined variable")
636 .with_code("E0003")
637 .with_label(span, "not found")
638 .with_suggestion(Suggestion::new(
639 "did you mean `y`?",
640 span,
641 "y",
642 Applicability::MachineApplicable,
643 ));
644
645 let actions = to_code_actions(&diag, &sm, "file:///test.hs", Some(1));
646
647 assert_eq!(actions.len(), 1);
648 assert_eq!(actions[0].title, "did you mean `y`?");
649 assert_eq!(actions[0].is_preferred, Some(true));
650 }
651
652 #[test]
653 fn test_to_hover() {
654 let sm = create_test_source_map();
655 let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
656
657 let diag = Diagnostic::error("type mismatch")
658 .with_code("E0001")
659 .with_label(span, "expected Int")
660 .with_note("consider using `fromIntegral`");
661
662 let hover = to_hover(&diag, &sm).unwrap();
663
664 assert!(hover.contents.value.contains("type mismatch"));
665 assert!(hover.contents.value.contains("E0001"));
666 assert!(hover.contents.value.contains("fromIntegral"));
667 }
668
669 #[test]
670 fn test_diagnostic_tags_unused() {
671 let sm = create_test_source_map();
672 let span = FullSpan::new(FileId::new(0), Span::from_raw(0, 3));
673
674 let diag = Diagnostic::warning("unused variable `foo`")
675 .with_code("W0001")
676 .with_label(span, "this variable is never used");
677
678 let lsp_diag = to_lsp_diagnostic(&diag, &sm).unwrap();
679
680 assert!(lsp_diag.tags.is_some());
681 assert!(lsp_diag
682 .tags
683 .unwrap()
684 .contains(&LspDiagnosticTag::Unnecessary));
685 }
686
687 #[test]
688 fn test_severity_conversion() {
689 assert_eq!(LspSeverity::from(Severity::Error), LspSeverity::Error);
690 assert_eq!(LspSeverity::from(Severity::Bug), LspSeverity::Error);
691 assert_eq!(LspSeverity::from(Severity::Warning), LspSeverity::Warning);
692 assert_eq!(LspSeverity::from(Severity::Note), LspSeverity::Information);
693 assert_eq!(LspSeverity::from(Severity::Help), LspSeverity::Hint);
694 }
695
696 #[test]
697 fn test_span_to_range() {
698 let sm = create_test_source_map();
699 let file = sm.get_file(FileId::new(0)).unwrap();
700
701 let range = span_to_range(file, Span::from_raw(6, 7));
703
704 assert_eq!(range.start.line, 0);
706 assert_eq!(range.end.line, 0);
707 }
708
709 #[test]
710 fn test_publish_diagnostics() {
711 let sm = create_test_source_map();
712 let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
713
714 let diags = vec![Diagnostic::error("test error")
715 .with_code("E0001")
716 .with_label(span, "here")];
717
718 let params = publish_diagnostics("file:///test.hs", &diags, &sm, Some(1));
719
720 assert_eq!(params.uri, "file:///test.hs");
721 assert_eq!(params.version, Some(1));
722 assert_eq!(params.diagnostics.len(), 1);
723 }
724}