use crate::{Applicability, Diagnostic, Severity, SourceMap, Suggestion};
use bhc_span::SourceFile;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LspSeverity {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4,
}
impl From<Severity> for LspSeverity {
fn from(severity: Severity) -> Self {
match severity {
Severity::Bug | Severity::Error => Self::Error,
Severity::Warning => Self::Warning,
Severity::Note => Self::Information,
Severity::Help => Self::Hint,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LspPosition {
pub line: u32,
pub character: u32,
}
impl LspPosition {
#[must_use]
pub fn new(line: u32, character: u32) -> Self {
Self { line, character }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LspRange {
pub start: LspPosition,
pub end: LspPosition,
}
impl LspRange {
#[must_use]
pub fn new(start: LspPosition, end: LspPosition) -> Self {
Self { start, end }
}
#[must_use]
pub fn point(pos: LspPosition) -> Self {
Self {
start: pos,
end: pos,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LspLocation {
pub uri: String,
pub range: LspRange,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LspDiagnosticTag {
Unnecessary = 1,
Deprecated = 2,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspRelatedInformation {
pub location: LspLocation,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum LspDiagnosticCode {
String(String),
Number(i32),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspDiagnostic {
pub range: LspRange,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<LspSeverity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<LspDiagnosticCode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_description: Option<LspCodeDescription>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<LspDiagnosticTag>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related_information: Option<Vec<LspRelatedInformation>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LspCodeDescription {
pub href: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspTextEdit {
pub range: LspRange,
pub new_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspTextDocumentEdit {
pub text_document: LspVersionedTextDocumentIdentifier,
pub edits: Vec<LspTextEdit>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LspVersionedTextDocumentIdentifier {
pub uri: String,
pub version: Option<i32>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspWorkspaceEdit {
#[serde(skip_serializing_if = "Option::is_none")]
pub document_changes: Option<Vec<LspTextDocumentEdit>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeActionKind(pub String);
impl CodeActionKind {
pub const QUICKFIX: &'static str = "quickfix";
pub const REFACTOR: &'static str = "refactor";
pub const SOURCE: &'static str = "source";
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspCodeAction {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostics: Option<Vec<LspDiagnostic>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_preferred: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edit: Option<LspWorkspaceEdit>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspHover {
pub contents: LspMarkupContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub range: Option<LspRange>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspMarkupContent {
pub kind: String,
pub value: String,
}
impl LspMarkupContent {
#[must_use]
pub fn plaintext(value: impl Into<String>) -> Self {
Self {
kind: "plaintext".to_string(),
value: value.into(),
}
}
#[must_use]
pub fn markdown(value: impl Into<String>) -> Self {
Self {
kind: "markdown".to_string(),
value: value.into(),
}
}
}
#[must_use]
pub fn span_to_range(file: &SourceFile, span: bhc_span::Span) -> LspRange {
if span.is_dummy() {
return LspRange::default();
}
let start_loc = file.lookup_line_col(span.lo);
let end_loc = file.lookup_line_col(span.hi);
LspRange {
start: LspPosition {
line: start_loc.line.saturating_sub(1),
character: start_loc.col.saturating_sub(1),
},
end: LspPosition {
line: end_loc.line.saturating_sub(1),
character: end_loc.col.saturating_sub(1),
},
}
}
#[must_use]
pub fn to_lsp_diagnostic(diagnostic: &Diagnostic, source_map: &SourceMap) -> Option<LspDiagnostic> {
let primary_label = diagnostic.labels.iter().find(|l| l.primary)?;
let file = source_map.get_file(primary_label.span.file)?;
let range = span_to_range(file, primary_label.span.span);
let mut message = diagnostic.message.clone();
if !primary_label.message.is_empty() {
message.push_str("\n\n");
message.push_str(&primary_label.message);
}
for note in &diagnostic.notes {
message.push_str("\n\nnote: ");
message.push_str(note);
}
let related_information: Vec<LspRelatedInformation> = diagnostic
.labels
.iter()
.filter(|l| !l.primary)
.filter_map(|label| {
let file = source_map.get_file(label.span.file)?;
Some(LspRelatedInformation {
location: LspLocation {
uri: format!("file://{}", file.name),
range: span_to_range(file, label.span.span),
},
message: label.message.clone(),
})
})
.collect();
let code_description = diagnostic.code.as_ref().map(|code| LspCodeDescription {
href: format!("https://bhc.dev/errors/{code}"),
});
let tags = detect_diagnostic_tags(diagnostic);
Some(LspDiagnostic {
range,
severity: Some(diagnostic.severity.into()),
code: diagnostic
.code
.as_ref()
.map(|c| LspDiagnosticCode::String(c.clone())),
code_description,
source: Some("bhc".to_string()),
message,
tags: if tags.is_empty() { None } else { Some(tags) },
related_information: if related_information.is_empty() {
None
} else {
Some(related_information)
},
data: None,
})
}
fn detect_diagnostic_tags(diagnostic: &Diagnostic) -> Vec<LspDiagnosticTag> {
let mut tags = Vec::new();
if let Some(code) = &diagnostic.code {
if code == "W0001" || diagnostic.message.to_lowercase().contains("unused") {
tags.push(LspDiagnosticTag::Unnecessary);
}
}
if diagnostic.message.to_lowercase().contains("deprecated") {
tags.push(LspDiagnosticTag::Deprecated);
}
tags
}
#[must_use]
pub fn to_code_actions(
diagnostic: &Diagnostic,
source_map: &SourceMap,
uri: &str,
version: Option<i32>,
) -> Vec<LspCodeAction> {
diagnostic
.suggestions
.iter()
.filter_map(|suggestion| {
suggestion_to_code_action(suggestion, diagnostic, source_map, uri, version)
})
.collect()
}
fn suggestion_to_code_action(
suggestion: &Suggestion,
diagnostic: &Diagnostic,
source_map: &SourceMap,
uri: &str,
version: Option<i32>,
) -> Option<LspCodeAction> {
let file = source_map.get_file(suggestion.span.file)?;
let range = span_to_range(file, suggestion.span.span);
let is_preferred = matches!(suggestion.applicability, Applicability::MachineApplicable);
let edit = LspTextEdit {
range,
new_text: suggestion.replacement.clone(),
};
let workspace_edit = LspWorkspaceEdit {
document_changes: Some(vec![LspTextDocumentEdit {
text_document: LspVersionedTextDocumentIdentifier {
uri: uri.to_string(),
version,
},
edits: vec![edit],
}]),
};
let lsp_diag = to_lsp_diagnostic(diagnostic, source_map);
Some(LspCodeAction {
title: suggestion.message.clone(),
kind: Some(CodeActionKind::QUICKFIX.to_string()),
diagnostics: lsp_diag.map(|d| vec![d]),
is_preferred: Some(is_preferred),
edit: Some(workspace_edit),
})
}
#[must_use]
pub fn to_hover(diagnostic: &Diagnostic, source_map: &SourceMap) -> Option<LspHover> {
let primary_label = diagnostic.labels.iter().find(|l| l.primary)?;
let file = source_map.get_file(primary_label.span.file)?;
let range = span_to_range(file, primary_label.span.span);
let mut content = String::new();
content.push_str("**");
content.push_str(diagnostic.severity.label());
if let Some(code) = &diagnostic.code {
content.push('[');
content.push_str(code);
content.push(']');
}
content.push_str("**: ");
content.push_str(&diagnostic.message);
content.push_str("\n\n");
if !primary_label.message.is_empty() {
content.push_str(&primary_label.message);
content.push_str("\n\n");
}
for note in &diagnostic.notes {
content.push_str("*Note*: ");
content.push_str(note);
content.push_str("\n\n");
}
if !diagnostic.suggestions.is_empty() {
content.push_str("---\n\n");
content.push_str("**Suggestions:**\n\n");
for suggestion in &diagnostic.suggestions {
content.push_str("- ");
content.push_str(&suggestion.message);
if !suggestion.replacement.is_empty() {
content.push_str("\n ```haskell\n ");
content.push_str(&suggestion.replacement);
content.push_str("\n ```");
}
content.push('\n');
}
}
if let Some(code) = &diagnostic.code {
content.push_str("\n---\n\n");
content.push_str(&format!(
"[View explanation for {code}](https://bhc.dev/errors/{code})"
));
}
Some(LspHover {
contents: LspMarkupContent::markdown(content),
range: Some(range),
})
}
#[must_use]
pub fn to_lsp_diagnostics(
diagnostics: &[Diagnostic],
source_map: &SourceMap,
) -> Vec<LspDiagnostic> {
diagnostics
.iter()
.filter_map(|d| to_lsp_diagnostic(d, source_map))
.collect()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishDiagnosticsParams {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<i32>,
pub diagnostics: Vec<LspDiagnostic>,
}
#[must_use]
pub fn publish_diagnostics(
uri: &str,
diagnostics: &[Diagnostic],
source_map: &SourceMap,
version: Option<i32>,
) -> PublishDiagnosticsParams {
PublishDiagnosticsParams {
uri: uri.to_string(),
version,
diagnostics: to_lsp_diagnostics(diagnostics, source_map),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FullSpan;
use bhc_span::{FileId, Span};
fn create_test_source_map() -> SourceMap {
let mut sm = SourceMap::new();
sm.add_file("test.hs".into(), "foo = x + 1\nbar = y".into());
sm
}
#[test]
fn test_to_lsp_diagnostic() {
let sm = create_test_source_map();
let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
let diag = Diagnostic::error("undefined variable `x`")
.with_code("E0003")
.with_label(span, "not found in this scope");
let lsp_diag = to_lsp_diagnostic(&diag, &sm).unwrap();
assert_eq!(lsp_diag.severity, Some(LspSeverity::Error));
assert!(lsp_diag.message.contains("undefined variable"));
assert_eq!(
lsp_diag.code,
Some(LspDiagnosticCode::String("E0003".to_string()))
);
assert_eq!(lsp_diag.source, Some("bhc".to_string()));
}
#[test]
fn test_to_code_actions() {
let sm = create_test_source_map();
let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
let diag = Diagnostic::error("undefined variable")
.with_code("E0003")
.with_label(span, "not found")
.with_suggestion(Suggestion::new(
"did you mean `y`?",
span,
"y",
Applicability::MachineApplicable,
));
let actions = to_code_actions(&diag, &sm, "file:///test.hs", Some(1));
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].title, "did you mean `y`?");
assert_eq!(actions[0].is_preferred, Some(true));
}
#[test]
fn test_to_hover() {
let sm = create_test_source_map();
let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
let diag = Diagnostic::error("type mismatch")
.with_code("E0001")
.with_label(span, "expected Int")
.with_note("consider using `fromIntegral`");
let hover = to_hover(&diag, &sm).unwrap();
assert!(hover.contents.value.contains("type mismatch"));
assert!(hover.contents.value.contains("E0001"));
assert!(hover.contents.value.contains("fromIntegral"));
}
#[test]
fn test_diagnostic_tags_unused() {
let sm = create_test_source_map();
let span = FullSpan::new(FileId::new(0), Span::from_raw(0, 3));
let diag = Diagnostic::warning("unused variable `foo`")
.with_code("W0001")
.with_label(span, "this variable is never used");
let lsp_diag = to_lsp_diagnostic(&diag, &sm).unwrap();
assert!(lsp_diag.tags.is_some());
assert!(lsp_diag
.tags
.unwrap()
.contains(&LspDiagnosticTag::Unnecessary));
}
#[test]
fn test_severity_conversion() {
assert_eq!(LspSeverity::from(Severity::Error), LspSeverity::Error);
assert_eq!(LspSeverity::from(Severity::Bug), LspSeverity::Error);
assert_eq!(LspSeverity::from(Severity::Warning), LspSeverity::Warning);
assert_eq!(LspSeverity::from(Severity::Note), LspSeverity::Information);
assert_eq!(LspSeverity::from(Severity::Help), LspSeverity::Hint);
}
#[test]
fn test_span_to_range() {
let sm = create_test_source_map();
let file = sm.get_file(FileId::new(0)).unwrap();
let range = span_to_range(file, Span::from_raw(6, 7));
assert_eq!(range.start.line, 0);
assert_eq!(range.end.line, 0);
}
#[test]
fn test_publish_diagnostics() {
let sm = create_test_source_map();
let span = FullSpan::new(FileId::new(0), Span::from_raw(6, 7));
let diags = vec![Diagnostic::error("test error")
.with_code("E0001")
.with_label(span, "here")];
let params = publish_diagnostics("file:///test.hs", &diags, &sm, Some(1));
assert_eq!(params.uri, "file:///test.hs");
assert_eq!(params.version, Some(1));
assert_eq!(params.diagnostics.len(), 1);
}
}