Skip to main content

bhc_diagnostics/
lsp.rs

1//! Language Server Protocol (LSP) integration for BHC diagnostics.
2//!
3//! This module provides conversion from BHC's internal diagnostic format
4//! to the LSP diagnostic format, enabling rich IDE integration.
5//!
6//! ## Features
7//!
8//! - Convert diagnostics to LSP format
9//! - Generate code actions from suggestions
10//! - Provide hover information for error spans
11//!
12//! ## Example
13//!
14//! ```ignore
15//! use bhc_diagnostics::{Diagnostic, lsp};
16//!
17//! let diagnostic = Diagnostic::error("type mismatch")
18//!     .with_code("E0001");
19//!
20//! let lsp_diag = lsp::to_lsp_diagnostic(&diagnostic, &source_map);
21//! let code_actions = lsp::to_code_actions(&diagnostic, &source_map, &uri);
22//! ```
23
24use crate::{Applicability, Diagnostic, Severity, SourceMap, Suggestion};
25use bhc_span::SourceFile;
26use serde::{Deserialize, Serialize};
27
28/// LSP diagnostic severity levels.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum LspSeverity {
32    /// Error - prevents compilation.
33    Error = 1,
34    /// Warning - doesn't prevent compilation.
35    Warning = 2,
36    /// Information - general info.
37    Information = 3,
38    /// Hint - suggestion for improvement.
39    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/// LSP position (0-indexed).
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct LspPosition {
56    /// Line number (0-indexed).
57    pub line: u32,
58    /// Character offset (0-indexed, UTF-16 code units).
59    pub character: u32,
60}
61
62impl LspPosition {
63    /// Create a new position.
64    #[must_use]
65    pub fn new(line: u32, character: u32) -> Self {
66        Self { line, character }
67    }
68}
69
70/// LSP range.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
72pub struct LspRange {
73    /// Start position.
74    pub start: LspPosition,
75    /// End position.
76    pub end: LspPosition,
77}
78
79impl LspRange {
80    /// Create a new range.
81    #[must_use]
82    pub fn new(start: LspPosition, end: LspPosition) -> Self {
83        Self { start, end }
84    }
85
86    /// Create a single-point range.
87    #[must_use]
88    pub fn point(pos: LspPosition) -> Self {
89        Self {
90            start: pos,
91            end: pos,
92        }
93    }
94}
95
96/// LSP location (URI + range).
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub struct LspLocation {
99    /// Document URI.
100    pub uri: String,
101    /// Range within the document.
102    pub range: LspRange,
103}
104
105/// LSP diagnostic tag for additional categorization.
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub enum LspDiagnosticTag {
109    /// Code is unnecessary (e.g., unused variable).
110    Unnecessary = 1,
111    /// Code is deprecated.
112    Deprecated = 2,
113}
114
115/// Related information for an LSP diagnostic.
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct LspRelatedInformation {
119    /// Location of related information.
120    pub location: LspLocation,
121    /// Message describing the relationship.
122    pub message: String,
123}
124
125/// LSP diagnostic code structure.
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(untagged)]
128pub enum LspDiagnosticCode {
129    /// String code (e.g., "E0001").
130    String(String),
131    /// Numeric code.
132    Number(i32),
133}
134
135/// An LSP diagnostic.
136#[derive(Clone, Debug, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct LspDiagnostic {
139    /// Range of the diagnostic.
140    pub range: LspRange,
141    /// Severity of the diagnostic.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub severity: Option<LspSeverity>,
144    /// Diagnostic code.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub code: Option<LspDiagnosticCode>,
147    /// Human-readable code description.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub code_description: Option<LspCodeDescription>,
150    /// Source of the diagnostic (e.g., "bhc").
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub source: Option<String>,
153    /// Main diagnostic message.
154    pub message: String,
155    /// Diagnostic tags.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub tags: Option<Vec<LspDiagnosticTag>>,
158    /// Related information from other locations.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub related_information: Option<Vec<LspRelatedInformation>>,
161    /// Custom data (for code actions).
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub data: Option<serde_json::Value>,
164}
165
166/// Code description with href.
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
168pub struct LspCodeDescription {
169    /// URI for more information.
170    pub href: String,
171}
172
173/// LSP text edit.
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct LspTextEdit {
177    /// Range to replace.
178    pub range: LspRange,
179    /// New text to insert.
180    pub new_text: String,
181}
182
183/// LSP workspace edit for a single document.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct LspTextDocumentEdit {
187    /// Document identifier.
188    pub text_document: LspVersionedTextDocumentIdentifier,
189    /// Edits to apply.
190    pub edits: Vec<LspTextEdit>,
191}
192
193/// Versioned text document identifier.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct LspVersionedTextDocumentIdentifier {
196    /// Document URI.
197    pub uri: String,
198    /// Document version.
199    pub version: Option<i32>,
200}
201
202/// LSP workspace edit.
203#[derive(Clone, Debug, Default, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct LspWorkspaceEdit {
206    /// Document edits.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub document_changes: Option<Vec<LspTextDocumentEdit>>,
209}
210
211/// Code action kind.
212#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213pub struct CodeActionKind(pub String);
214
215impl CodeActionKind {
216    /// Quick fix action.
217    pub const QUICKFIX: &'static str = "quickfix";
218    /// Refactor action.
219    pub const REFACTOR: &'static str = "refactor";
220    /// Source action.
221    pub const SOURCE: &'static str = "source";
222}
223
224/// LSP code action.
225#[derive(Clone, Debug, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct LspCodeAction {
228    /// Title of the action.
229    pub title: String,
230    /// Kind of action.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub kind: Option<String>,
233    /// Diagnostics this action resolves.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub diagnostics: Option<Vec<LspDiagnostic>>,
236    /// Is this the preferred action?
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub is_preferred: Option<bool>,
239    /// Edit to apply.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub edit: Option<LspWorkspaceEdit>,
242}
243
244/// Hover information content.
245#[derive(Clone, Debug, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct LspHover {
248    /// Hover contents.
249    pub contents: LspMarkupContent,
250    /// Range this hover applies to.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub range: Option<LspRange>,
253}
254
255/// Markup content for hover/completions.
256#[derive(Clone, Debug, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct LspMarkupContent {
259    /// Kind of markup.
260    pub kind: String,
261    /// Markup value.
262    pub value: String,
263}
264
265impl LspMarkupContent {
266    /// Create plaintext content.
267    #[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    /// Create markdown content.
276    #[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// ============================================================
286// Conversion Functions
287// ============================================================
288
289/// Convert a BHC span to an LSP range using the source file.
290#[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            // LSP uses 0-indexed lines
302            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/// Convert a BHC diagnostic to an LSP diagnostic.
313///
314/// # Arguments
315///
316/// * `diagnostic` - The BHC diagnostic to convert
317/// * `source_map` - The source map for location lookup
318///
319/// # Returns
320///
321/// An LSP diagnostic suitable for sending to a language client.
322#[must_use]
323pub fn to_lsp_diagnostic(diagnostic: &Diagnostic, source_map: &SourceMap) -> Option<LspDiagnostic> {
324    // Find the primary label's range
325    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    // Build the message with notes
330    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    // Build related information from secondary labels
341    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    // Build code description for explain link
358    let code_description = diagnostic.code.as_ref().map(|code| LspCodeDescription {
359        href: format!("https://bhc.dev/errors/{code}"),
360    });
361
362    // Detect diagnostic tags
363    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
385/// Detect diagnostic tags based on error code and message.
386fn detect_diagnostic_tags(diagnostic: &Diagnostic) -> Vec<LspDiagnosticTag> {
387    let mut tags = Vec::new();
388
389    // Check for unused warnings
390    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    // Check for deprecated
397    if diagnostic.message.to_lowercase().contains("deprecated") {
398        tags.push(LspDiagnosticTag::Deprecated);
399    }
400
401    tags
402}
403
404/// Convert BHC diagnostic suggestions to LSP code actions.
405///
406/// # Arguments
407///
408/// * `diagnostic` - The BHC diagnostic with suggestions
409/// * `source_map` - The source map for location lookup
410/// * `uri` - The document URI
411/// * `version` - Optional document version
412///
413/// # Returns
414///
415/// A list of code actions that can be applied.
416#[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
432/// Convert a single suggestion to a code action.
433fn 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    // Determine if this is the preferred action
444    let is_preferred = matches!(suggestion.applicability, Applicability::MachineApplicable);
445
446    // Create the text edit
447    let edit = LspTextEdit {
448        range,
449        new_text: suggestion.replacement.clone(),
450    };
451
452    // Create workspace edit
453    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    // Convert the diagnostic for association
464    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/// Generate hover information for a diagnostic at a given position.
476///
477/// # Arguments
478///
479/// * `diagnostic` - The diagnostic to show hover for
480/// * `source_map` - The source map for location lookup
481///
482/// # Returns
483///
484/// Hover information with explanation and suggestions.
485#[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    // Build markdown content
492    let mut content = String::new();
493
494    // Header with severity and code
495    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    // Primary label message
507    if !primary_label.message.is_empty() {
508        content.push_str(&primary_label.message);
509        content.push_str("\n\n");
510    }
511
512    // Notes
513    for note in &diagnostic.notes {
514        content.push_str("*Note*: ");
515        content.push_str(note);
516        content.push_str("\n\n");
517    }
518
519    // Suggestions
520    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    // Error explanation link
536    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/// Batch convert multiple diagnostics to LSP format.
550///
551/// # Arguments
552///
553/// * `diagnostics` - The BHC diagnostics to convert
554/// * `source_map` - The source map for location lookup
555///
556/// # Returns
557///
558/// A list of LSP diagnostics.
559#[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/// Publish diagnostics notification for LSP.
571#[derive(Clone, Debug, Serialize, Deserialize)]
572#[serde(rename_all = "camelCase")]
573pub struct PublishDiagnosticsParams {
574    /// Document URI.
575    pub uri: String,
576    /// Document version (if known).
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub version: Option<i32>,
579    /// Diagnostics for this document.
580    pub diagnostics: Vec<LspDiagnostic>,
581}
582
583/// Create a publish diagnostics notification.
584#[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        // "x" at position 6-7 on first line
702        let range = span_to_range(file, Span::from_raw(6, 7));
703
704        // LSP is 0-indexed
705        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}