#![allow(clippy::mutable_key_type)]
use crate::Displayed;
use crate::lsp::common::truncate_results;
use crate::lsp::diagnostics::{DiagnosticCounts, FormattedDiagnostic, Severity, count_by_severity};
use crate::lsp::error::LspError;
use crate::lsp::registry::LspRegistry;
use crate::workspace_paths::relative_path;
use lsp_types::Diagnostic;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use utils::display_meta::{ToolDisplayMeta, basename};
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LspDiagnosticsRequest {
#[serde(default, alias = "file_path")]
pub file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LspDiagnosticsOutput {
pub scope: Scope,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
pub diagnostics: BTreeMap<String, Vec<String>>,
pub summary: DiagnosticCounts,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncated: Option<bool>,
}
pub const MAX_LISTED_DIAGNOSTICS: usize = 200;
#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
Workspace,
File,
}
impl LspDiagnosticsOutput {
pub fn new(
request: &LspDiagnosticsRequest,
root_path: &Path,
diagnostics_cache: &HashMap<lsp_types::Uri, Vec<Diagnostic>>,
) -> Self {
let mut diagnostics: Vec<FormattedDiagnostic> = diagnostics_cache
.iter()
.flat_map(|(uri, diagnostics)| {
diagnostics.iter().map(move |diagnostic| FormattedDiagnostic::from_diagnostic(uri, diagnostic))
})
.collect();
let summary = count_by_severity(&diagnostics);
diagnostics.retain(|diagnostic| diagnostic.severity <= Severity::Warning);
diagnostics
.sort_by(|a, b| (a.severity, &a.file, a.line, a.column).cmp(&(b.severity, &b.file, b.line, b.column)));
let truncation = truncate_results(&mut diagnostics, MAX_LISTED_DIAGNOSTICS);
let mut by_file: BTreeMap<String, Vec<String>> = BTreeMap::new();
for diagnostic in &diagnostics {
by_file.entry(relative_path(root_path, &diagnostic.file)).or_default().push(diagnostic.to_string());
}
let file_path = request.file_path.clone();
let is_workspace = file_path.is_none();
Self {
scope: if is_workspace { Scope::Workspace } else { Scope::File },
workspace_root: if is_workspace { Some(root_path.to_string_lossy().to_string()) } else { None },
file_path,
diagnostics: by_file,
summary,
truncated: truncation.truncated,
}
}
}
pub async fn execute_lsp_diagnostics(
mut request: LspDiagnosticsRequest,
registry: &LspRegistry,
) -> Result<Displayed<LspDiagnosticsOutput>, LspError> {
request.file_path = request.file_path.map(|path| registry.resolve_file(&path)).transpose()?;
if let Some(file_path) = request.file_path.as_deref().filter(|path| !Path::new(path).is_file()) {
return Err(LspError::InvalidPath(format!("filePath must point to an existing file, got: {file_path}")));
}
let diagnostics_cache = registry.collect_diagnostics(request.file_path.as_deref()).await?;
let output = LspDiagnosticsOutput::new(&request, registry.root_path(), &diagnostics_cache);
let detail = if output.summary.errors == 0 && output.summary.warnings == 0 {
"no issues".to_string()
} else {
format!("{} errors, {} warnings", output.summary.errors, output.summary.warnings)
};
let value = match &output.file_path {
Some(fp) => format!("{}, {detail}", basename(fp)),
None => detail,
};
Ok(Displayed::new(output, ToolDisplayMeta::new("LSP errors", value)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp::test_fixtures::{diagnostic, uri};
use lsp_types::DiagnosticSeverity;
use tempfile::TempDir;
fn workspace_request() -> LspDiagnosticsRequest {
LspDiagnosticsRequest { file_path: None }
}
fn file_request(path: &str) -> LspDiagnosticsRequest {
LspDiagnosticsRequest { file_path: Some(path.to_string()) }
}
fn workspace_output(cache: &HashMap<lsp_types::Uri, Vec<Diagnostic>>) -> LspDiagnosticsOutput {
LspDiagnosticsOutput::new(&workspace_request(), Path::new("/project"), cache)
}
fn parse_request(json: &str) -> Result<LspDiagnosticsRequest, serde_json::Error> {
serde_json::from_str(json)
}
fn assert_parses_file_scope(json: &str, expected_path: &str) {
let request: LspDiagnosticsRequest = parse_request(json).unwrap();
assert_eq!(request.file_path.as_deref(), Some(expected_path));
}
#[test]
fn test_get_all_diagnostics() {
let mut cache = HashMap::new();
cache.insert(
uri("/project/src/main.rs"),
vec![
diagnostic(DiagnosticSeverity::ERROR, "type mismatch", 10),
diagnostic(DiagnosticSeverity::WARNING, "unused variable", 20),
],
);
cache.insert(uri("/project/src/lib.rs"), vec![diagnostic(DiagnosticSeverity::ERROR, "missing field", 5)]);
let result = workspace_output(&cache);
assert_eq!(
result.diagnostics["src/main.rs"],
["11:1 error[E0308]: type mismatch", "21:1 warning[E0308]: unused variable"]
);
assert_eq!(result.diagnostics["src/lib.rs"], ["6:1 error[E0308]: missing field"]);
assert_eq!(result.summary.errors, 2);
assert_eq!(result.summary.warnings, 1);
assert_eq!(result.summary.infos, 0);
assert_eq!(result.summary.hints, 0);
assert_eq!(result.summary.total, 3);
}
#[test]
fn test_get_diagnostics_for_file() {
let mut cache = HashMap::new();
cache.insert(uri("/project/src/main.rs"), vec![diagnostic(DiagnosticSeverity::ERROR, "type mismatch", 10)]);
let input = file_request("/project/src/main.rs");
let result = LspDiagnosticsOutput::new(&input, Path::new("/project"), &cache);
assert_eq!(result.diagnostics["src/main.rs"], ["11:1 error[E0308]: type mismatch"]);
assert_eq!(result.summary.total, 1);
}
#[test]
fn multiline_messages_are_listed_on_one_line() {
let cache = HashMap::from([(
uri("/project/src/main.rs"),
vec![diagnostic(DiagnosticSeverity::ERROR, "mismatched types\nexpected `u32`, found `&str`", 1)],
)]);
let result = workspace_output(&cache);
assert_eq!(
result.diagnostics["src/main.rs"],
["2:1 error[E0308]: mismatched types expected `u32`, found `&str`"]
);
}
#[test]
fn test_empty_diagnostics() {
let result = workspace_output(&HashMap::new());
assert!(result.diagnostics.is_empty());
assert_eq!(result.summary.total, 0);
}
#[test]
fn test_diagnostics_sorted() {
let mut cache = HashMap::new();
cache.insert(uri("/project/src/b.rs"), vec![diagnostic(DiagnosticSeverity::ERROR, "error in b", 5)]);
cache.insert(uri("/project/src/a.rs"), vec![diagnostic(DiagnosticSeverity::ERROR, "error in a", 10)]);
let result = workspace_output(&cache);
let files: Vec<&str> = result.diagnostics.keys().map(String::as_str).collect();
assert_eq!(files, ["src/a.rs", "src/b.rs"]);
}
#[test]
fn test_deserialize_workspace_scope() {
let request: LspDiagnosticsRequest = parse_request(r"{}").unwrap();
assert!(request.file_path.is_none());
}
#[test]
fn test_deserialize_file_scope() {
let cases = [r#"{"filePath":"/some/path.rs"}"#, r#"{"file_path":"/some/path.rs"}"#];
for json in cases {
assert_parses_file_scope(json, "/some/path.rs");
}
}
#[test]
fn test_reject_invalid_json_payloads() {
let invalid_jsons = [r#"{"scope":"workspace"}"#, r#"{"scope":"file"}"#, r#"{"unknown":true}"#];
for json in invalid_jsons {
assert!(parse_request(json).is_err(), "should reject: {json}");
}
}
#[tokio::test]
async fn rejects_file_paths_that_are_not_existing_files() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join("src")).unwrap();
let registry = LspRegistry::new(temp_dir.path().to_path_buf());
let cases = [
("", "file path is required"),
("src", "filePath must point to an existing file"),
("src/missing.rs", "filePath must point to an existing file"),
];
for (path, expected_msg) in cases {
let err = execute_lsp_diagnostics(file_request(path), ®istry).await.unwrap_err().to_string();
assert!(err.contains(expected_msg), "path={path:?}: expected {expected_msg:?}, got {err:?}");
}
}
#[test]
fn test_output_workspace_metadata() {
let output = LspDiagnosticsOutput::new(&workspace_request(), Path::new("/home/user/project"), &HashMap::new());
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains(r#""scope":"workspace""#));
assert!(json.contains(r#""workspaceRoot":"/home/user/project""#));
assert!(!json.contains("filePath"));
}
#[test]
fn test_output_file_metadata() {
let input = file_request("/home/user/project/src/main.rs");
let output = LspDiagnosticsOutput::new(&input, Path::new("/home/user/project"), &HashMap::new());
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains(r#""scope":"file""#));
assert!(json.contains(r#""filePath":"/home/user/project/src/main.rs""#));
assert!(!json.contains("workspaceRoot"));
assert!(output.workspace_root.is_none());
}
#[test]
fn test_output_summary_totals() {
let mut cache = HashMap::new();
cache.insert(
uri("/project/src/main.rs"),
vec![
diagnostic(DiagnosticSeverity::ERROR, "error1", 1),
diagnostic(DiagnosticSeverity::ERROR, "error2", 2),
diagnostic(DiagnosticSeverity::WARNING, "warn1", 3),
diagnostic(DiagnosticSeverity::INFORMATION, "info1", 4),
diagnostic(DiagnosticSeverity::HINT, "hint1", 5),
],
);
let result = workspace_output(&cache);
assert_eq!(result.summary.errors, 2);
assert_eq!(result.summary.warnings, 1);
assert_eq!(result.summary.infos, 1);
assert_eq!(result.summary.hints, 1);
assert_eq!(result.summary.total, 5);
assert_eq!(
result.diagnostics["src/main.rs"],
["2:1 error[E0308]: error1", "3:1 error[E0308]: error2", "4:1 warning[E0308]: warn1"]
);
}
#[test]
fn test_listed_diagnostics_are_capped_with_errors_first() {
let line_count = u32::try_from(MAX_LISTED_DIAGNOSTICS).unwrap();
let mut diagnostics: Vec<Diagnostic> =
(0..line_count).map(|line| diagnostic(DiagnosticSeverity::WARNING, "warn", line)).collect();
diagnostics.push(diagnostic(DiagnosticSeverity::ERROR, "late error", line_count + 10));
let cache = HashMap::from([(uri("/project/src/main.rs"), diagnostics)]);
let result = workspace_output(&cache);
let listed = &result.diagnostics["src/main.rs"];
assert_eq!(listed.len(), MAX_LISTED_DIAGNOSTICS);
assert_eq!(listed[0], format!("{}:1 error[E0308]: late error", line_count + 11));
assert_eq!(result.truncated, Some(true));
assert_eq!(result.summary.total, MAX_LISTED_DIAGNOSTICS + 1);
}
}