use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use lsp_types::{
CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams,
CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, CompletionParams,
CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
FormattingOptions, GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams,
InlayHintLabel, InlayHintParams, MarkedString, PartialResultParams, ReferenceContext,
ReferenceParams, RenameParams as LspRenameParams,
SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier,
TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceEdit,
WorkspaceSymbolParams as LspWorkspaceSymbolParams,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio::time::Duration;
use super::state::{ResourceLimits, detect_language, path_to_uri};
use super::{DiagnosticInfo, DocumentTracker, NotificationCache, lock_std};
use crate::bridge::encoding::mcp_to_lsp_position;
use crate::config::{ServerId, ToolKind, ToolRouter, base_language_id};
use crate::error::{Error, Result};
use crate::lsp::{LspClient, LspServer};
#[derive(Debug)]
pub struct Translator {
lsp_clients: Arc<StdMutex<HashMap<ServerId, LspClient>>>,
lsp_servers: Arc<StdMutex<HashMap<ServerId, LspServer>>>,
document_tracker: Arc<DocumentTracker>,
workspace_roots: Arc<Vec<PathBuf>>,
extension_map: Arc<HashMap<String, String>>,
expected_servers: Arc<StdMutex<HashSet<ServerId>>>,
router: Arc<StdMutex<ToolRouter>>,
}
impl Translator {
#[must_use]
pub fn new() -> Self {
Self {
lsp_clients: Arc::new(StdMutex::new(HashMap::new())),
lsp_servers: Arc::new(StdMutex::new(HashMap::new())),
document_tracker: Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
)),
workspace_roots: Arc::new(Vec::new()),
extension_map: Arc::new(HashMap::new()),
expected_servers: Arc::new(StdMutex::new(HashSet::new())),
router: Arc::new(StdMutex::new(ToolRouter::default())),
}
}
pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
self.workspace_roots = Arc::new(roots);
}
pub fn set_expected_servers(&self, servers: HashSet<ServerId>) {
*lock_std(&self.expected_servers) = servers;
}
pub fn clear_expected_servers(&self) {
lock_std(&self.expected_servers).clear();
}
#[must_use]
pub fn with_router(mut self, router: ToolRouter) -> Self {
self.router = Arc::new(StdMutex::new(router));
self
}
pub fn rebind_router(&self, registered: &HashSet<ServerId>) {
lock_std(&self.router).rebind_to_registered(registered);
}
#[must_use]
pub fn is_diagnostics_route(&self, language_id: &str, id: &ServerId) -> bool {
lock_std(&self.router).resolve(language_id, ToolKind::Diagnostics) == Some(id)
}
#[must_use]
pub fn with_extensions(mut self, extension_map: HashMap<String, String>) -> Self {
self.document_tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
extension_map.clone(),
));
self.extension_map = Arc::new(extension_map);
self
}
pub fn register_client(&self, id: impl Into<ServerId>, client: LspClient) {
lock_std(&self.lsp_clients).insert(id.into(), client);
}
pub fn register_server(&self, id: impl Into<ServerId>, server: LspServer) {
lock_std(&self.lsp_servers).insert(id.into(), server);
}
#[must_use]
pub fn open_document_paths(&self) -> Vec<PathBuf> {
self.document_tracker.open_paths()
}
#[must_use]
pub fn is_document_open(&self, path: &Path) -> bool {
self.document_tracker.is_open(path)
}
}
impl Default for Translator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DiagnosticRequestParams {
text_document: TextDocumentIdentifier,
#[serde(skip_serializing_if = "Option::is_none")]
identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
previous_result_id: Option<String>,
#[serde(flatten)]
work_done_progress_params: WorkDoneProgressParams,
#[serde(flatten)]
partial_result_params: PartialResultParams,
}
fn diagnostic_request_params(text_document: TextDocumentIdentifier) -> DiagnosticRequestParams {
DiagnosticRequestParams {
text_document,
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Position2D {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Range {
pub start: Position2D,
pub end: Position2D,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Location {
pub uri: String,
pub range: Range,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoverResult {
pub contents: String,
pub range: Option<Range>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefinitionResult {
pub locations: Vec<Location>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReferencesResult {
pub locations: Vec<Location>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticSeverity {
Error,
Warning,
Information,
Hint,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnostic {
pub range: Range,
pub severity: DiagnosticSeverity,
pub message: String,
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticsResult {
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextEdit {
pub range: Range,
pub new_text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentChanges {
pub uri: String,
pub edits: Vec<TextEdit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameResult {
pub changes: Vec<DocumentChanges>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Completion {
pub label: String,
pub kind: Option<String>,
pub detail: Option<String>,
pub documentation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionsResult {
pub items: Vec<Completion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
pub name: String,
pub kind: String,
pub range: Range,
pub selection_range: Range,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<Self>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentSymbolsResult {
pub symbols: Vec<Symbol>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatDocumentResult {
pub edits: Vec<TextEdit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSymbol {
pub name: String,
pub kind: String,
pub location: Location,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSymbolResult {
pub symbols: Vec<WorkspaceSymbol>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeAction {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub diagnostics: Vec<Diagnostic>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edit: Option<WorkspaceEditDescription>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<CommandDescription>,
#[serde(default)]
pub is_preferred: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceEditDescription {
pub changes: Vec<DocumentChanges>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDescription {
pub title: String,
pub command: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub arguments: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeActionsResult {
pub actions: Vec<CodeAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallHierarchyItemResult {
pub name: String,
pub kind: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub uri: String,
pub range: Range,
#[serde(rename = "selectionRange")]
pub selection_range: Range,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallHierarchyPrepareResult {
pub items: Vec<CallHierarchyItemResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingCall {
pub from: CallHierarchyItemResult,
pub from_ranges: Vec<Range>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingCallsResult {
pub calls: Vec<IncomingCall>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingCall {
pub to: CallHierarchyItemResult,
pub from_ranges: Vec<Range>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingCallsResult {
pub calls: Vec<OutgoingCall>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerLogsResult {
pub logs: Vec<crate::bridge::notifications::LogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerMessagesResult {
pub messages: Vec<crate::bridge::notifications::ServerMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureParameter {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub documentation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureInfo {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub documentation: Option<String>,
pub parameters: Vec<SignatureParameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureHelpResult {
pub signatures: Vec<SignatureInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_signature: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_parameter: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationsResult {
pub locations: Vec<Location>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InlayHintEntry {
pub position: Position2D,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub padding_left: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub padding_right: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tooltip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InlayHintsResult {
pub hints: Vec<InlayHintEntry>,
}
const MAX_POSITION_VALUE: u32 = 1_000_000;
const MAX_RANGE_LINES: u32 = 10_000;
pub fn validate_path_against_roots(path: &Path, workspace_roots: &[PathBuf]) -> Result<PathBuf> {
let canonical = path.canonicalize().map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
if workspace_roots.is_empty() {
return Ok(canonical);
}
for root in workspace_roots {
if let Ok(canonical_root) = root.canonicalize()
&& canonical.starts_with(&canonical_root)
{
return Ok(canonical);
}
}
Err(Error::PathOutsideWorkspace(path.to_path_buf()))
}
impl Translator {
pub(crate) fn validate_path(&self, path: &Path) -> Result<PathBuf> {
validate_path_against_roots(path, &self.workspace_roots)
}
fn get_client_for_file(&self, path: &Path, tool: ToolKind) -> Result<(ServerId, LspClient)> {
let language = detect_language(path, &self.extension_map);
let mut candidates: Vec<&str> = vec![language.as_str()];
if let Some(base) = base_language_id(&language) {
candidates.push(base);
}
for lang in &candidates {
let resolved = lock_std(&self.router).resolve(lang, tool).cloned();
let Some(id) = resolved else { continue };
let found = lock_std(&self.lsp_clients).get(&id).cloned();
if let Some(client) = found {
return Ok((id, client));
}
if lock_std(&self.expected_servers).contains(&id) {
return Err(Error::ServerInitializing { server_id: id });
}
tracing::error!(
"router route names server '{id}' for tool '{tool}' that is neither \
registered nor expected"
);
return Err(Error::NoServerForTool {
language_id: (*lang).to_string(),
tool,
});
}
let has_language = {
let router = lock_std(&self.router);
candidates.iter().any(|lang| router.has_language(lang))
};
if has_language {
Err(Error::NoServerForTool {
language_id: language,
tool,
})
} else {
Err(Error::NoServerForLanguage(language))
}
}
async fn prepare_document(
&self,
file_path: &str,
tool: ToolKind,
) -> Result<(LspClient, lsp_types::Uri)> {
let path = PathBuf::from(file_path);
let validated_path = self.validate_path(&path)?;
let (server_id, client) = self.get_client_for_file(&validated_path, tool)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &server_id, &client)
.await?;
Ok((client, uri))
}
fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<PathBuf> {
let uri_str = uri.as_str();
if !uri_str.starts_with("file://") {
return Err(Error::InvalidToolParams(format!(
"Invalid URI scheme, expected file:// but got: {uri_str}"
)));
}
let path_str = &uri_str["file://".len()..];
#[cfg(windows)]
let path_str = if path_str.len() >= 3
&& path_str.starts_with('/')
&& path_str.chars().nth(2) == Some(':')
{
&path_str[1..]
} else {
path_str
};
let path = PathBuf::from(path_str);
self.validate_path(&path)
}
pub async fn handle_hover(
&self,
file_path: String,
line: u32,
character: u32,
) -> Result<HoverResult> {
let (client, uri) = self.prepare_document(&file_path, ToolKind::Hover).await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = LspHoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Hover> = client
.request("textDocument/hover", params, timeout_duration)
.await?;
let result = match response {
Some(hover) => {
let contents = extract_hover_contents(hover.contents);
let range = hover.range.map(normalize_range);
HoverResult { contents, range }
}
None => HoverResult {
contents: "No hover information available".to_string(),
range: None,
},
};
Ok(result)
}
pub async fn handle_definition(
&self,
file_path: String,
line: u32,
character: u32,
) -> Result<DefinitionResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::Definition)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<lsp_types::GotoDefinitionResponse> = client
.request("textDocument/definition", params, timeout_duration)
.await?;
let locations = match response {
Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
.into_iter()
.map(|link| lsp_types::Location {
uri: link.target_uri,
range: link.target_selection_range,
})
.collect(),
None => vec![],
};
let result = DefinitionResult {
locations: locations
.into_iter()
.map(|loc| Location {
uri: loc.uri.to_string(),
range: normalize_range(loc.range),
})
.collect(),
};
Ok(result)
}
pub async fn handle_references(
&self,
file_path: String,
line: u32,
character: u32,
include_declaration: bool,
) -> Result<ReferencesResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::References)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = ReferenceParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: ReferenceContext {
include_declaration,
},
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<lsp_types::Location>> = client
.request("textDocument/references", params, timeout_duration)
.await?;
let locations = response.unwrap_or_default();
let result = ReferencesResult {
locations: locations
.into_iter()
.map(|loc| Location {
uri: loc.uri.to_string(),
range: normalize_range(loc.range),
})
.collect(),
};
Ok(result)
}
pub async fn handle_diagnostics(
&self,
file_path: String,
notification_cache: &Mutex<NotificationCache>,
) -> Result<DiagnosticsResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::Diagnostics)
.await?;
let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() });
let timeout_duration = Duration::from_secs(30);
let pull_response: Result<lsp_types::DocumentDiagnosticReportResult> = client
.request("textDocument/diagnostic", params, timeout_duration)
.await;
let diag_info = {
let cache = notification_cache.lock().await;
cache.get_diagnostics(uri.as_str()).cloned()
};
match pull_response {
Ok(response) => {
let items = match response {
lsp_types::DocumentDiagnosticReportResult::Report(report) => match report {
lsp_types::DocumentDiagnosticReport::Full(full) => {
full.full_document_diagnostic_report.items
}
lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![],
},
lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![],
};
let pull = DiagnosticsResult {
diagnostics: items.iter().map(diagnostic_to_mcp).collect(),
};
Ok(Self::merge_diagnostics(pull, diag_info.as_ref()))
}
Err(e) => {
let cache_only = Self::diagnostics_from_cache_entry(diag_info.as_ref());
if cache_only.diagnostics.is_empty() {
Err(e)
} else {
Ok(cache_only)
}
}
}
}
pub async fn handle_rename(
&self,
file_path: String,
line: u32,
character: u32,
new_name: String,
) -> Result<RenameResult> {
let (client, uri) = self.prepare_document(&file_path, ToolKind::Rename).await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = LspRenameParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
new_name,
work_done_progress_params: WorkDoneProgressParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<WorkspaceEdit> = client
.request("textDocument/rename", params, timeout_duration)
.await?;
let changes = if let Some(edit) = response {
let mut result_changes = Vec::new();
if let Some(changes_map) = edit.changes {
for (uri, edits) in changes_map {
result_changes.push(DocumentChanges {
uri: uri.to_string(),
edits: edits
.into_iter()
.map(|e| TextEdit {
range: normalize_range(e.range),
new_text: e.new_text,
})
.collect(),
});
}
}
if result_changes.is_empty() {
let text_doc_edits = match edit.document_changes {
Some(lsp_types::DocumentChanges::Edits(edits)) => edits,
Some(lsp_types::DocumentChanges::Operations(ops)) => ops
.into_iter()
.filter_map(|op| match op {
lsp_types::DocumentChangeOperation::Edit(e) => Some(e),
lsp_types::DocumentChangeOperation::Op(_) => None,
})
.collect(),
None => vec![],
};
for tde in text_doc_edits {
result_changes.push(DocumentChanges {
uri: tde.text_document.uri.to_string(),
edits: tde
.edits
.into_iter()
.map(|one_of| match one_of {
lsp_types::OneOf::Left(te) => TextEdit {
range: normalize_range(te.range),
new_text: te.new_text,
},
lsp_types::OneOf::Right(ate) => TextEdit {
range: normalize_range(ate.text_edit.range),
new_text: ate.text_edit.new_text,
},
})
.collect(),
});
}
}
result_changes
} else {
vec![]
};
Ok(RenameResult { changes })
}
pub async fn handle_completions(
&self,
file_path: String,
line: u32,
character: u32,
trigger: Option<String>,
) -> Result<CompletionsResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::Completions)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: Some(trigger_char),
});
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context,
};
let timeout_duration = Duration::from_secs(10);
let response: Option<lsp_types::CompletionResponse> = client
.request("textDocument/completion", params, timeout_duration)
.await?;
let items = match response {
Some(lsp_types::CompletionResponse::Array(items)) => items,
Some(lsp_types::CompletionResponse::List(list)) => list.items,
None => vec![],
};
let result = CompletionsResult {
items: items
.into_iter()
.map(|item| Completion {
label: item.label,
kind: item.kind.map(|k| format!("{k:?}")),
detail: item.detail,
documentation: item.documentation.map(|doc| match doc {
lsp_types::Documentation::String(s) => s,
lsp_types::Documentation::MarkupContent(m) => m.value,
}),
})
.collect(),
};
Ok(result)
}
pub async fn handle_document_symbols(
&self,
file_path: String,
) -> Result<DocumentSymbolsResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::DocumentSymbols)
.await?;
let params = DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<lsp_types::DocumentSymbolResponse> = client
.request("textDocument/documentSymbol", params, timeout_duration)
.await?;
let symbols = match response {
Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => symbols
.into_iter()
.map(|sym| Symbol {
name: sym.name,
kind: format!("{:?}", sym.kind),
range: normalize_range(sym.location.range),
selection_range: normalize_range(sym.location.range),
children: None,
})
.collect(),
Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
symbols.into_iter().map(convert_document_symbol).collect()
}
None => vec![],
};
Ok(DocumentSymbolsResult { symbols })
}
pub async fn handle_format_document(
&self,
file_path: String,
tab_size: u32,
insert_spaces: bool,
) -> Result<FormatDocumentResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::FormatDocument)
.await?;
let params = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri },
options: FormattingOptions {
tab_size,
insert_spaces,
..Default::default()
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<lsp_types::TextEdit>> = client
.request("textDocument/formatting", params, timeout_duration)
.await?;
let edits = response.unwrap_or_default();
let result = FormatDocumentResult {
edits: edits
.into_iter()
.map(|edit| TextEdit {
range: normalize_range(edit.range),
new_text: edit.new_text,
})
.collect(),
};
Ok(result)
}
pub async fn handle_workspace_symbol(
&self,
query: String,
kind_filter: Option<String>,
limit: u32,
) -> Result<WorkspaceSymbolResult> {
const MAX_QUERY_LENGTH: usize = 1000;
const VALID_SYMBOL_KINDS: &[&str] = &[
"File",
"Module",
"Namespace",
"Package",
"Class",
"Method",
"Property",
"Field",
"Constructor",
"Enum",
"Interface",
"Function",
"Variable",
"Constant",
"String",
"Number",
"Boolean",
"Array",
"Object",
"Key",
"Null",
"EnumMember",
"Struct",
"Event",
"Operator",
"TypeParameter",
];
if query.len() > MAX_QUERY_LENGTH {
return Err(Error::InvalidToolParams(format!(
"Query too long: {} chars (max {MAX_QUERY_LENGTH})",
query.len()
)));
}
if let Some(ref kind) = kind_filter
&& !VALID_SYMBOL_KINDS
.iter()
.any(|k| k.eq_ignore_ascii_case(kind))
{
return Err(Error::InvalidToolParams(format!(
"Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}"
)));
}
let server_id = lock_std(&self.router)
.resolve_any(ToolKind::WorkspaceSymbols)
.cloned()
.ok_or(Error::NoServerConfigured)?;
let client = lock_std(&self.lsp_clients).get(&server_id).cloned();
let client = client.ok_or_else(|| {
if lock_std(&self.expected_servers).contains(&server_id) {
Error::ServerInitializing { server_id }
} else {
Error::NoServerConfigured
}
})?;
let params = LspWorkspaceSymbolParams {
query,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<lsp_types::SymbolInformation>> = client
.request("workspace/symbol", params, timeout_duration)
.await?;
let mut symbols: Vec<WorkspaceSymbol> = response
.unwrap_or_default()
.into_iter()
.map(|sym| WorkspaceSymbol {
name: sym.name,
kind: format!("{:?}", sym.kind),
location: Location {
uri: sym.location.uri.to_string(),
range: normalize_range(sym.location.range),
},
container_name: sym.container_name,
})
.collect();
if let Some(kind) = kind_filter {
symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
}
symbols.truncate(limit as usize);
Ok(WorkspaceSymbolResult { symbols })
}
pub async fn handle_code_actions(
&self,
file_path: String,
start_line: u32,
start_character: u32,
end_line: u32,
end_character: u32,
kind_filter: Option<String>,
) -> Result<CodeActionsResult> {
validate_code_action_params(
start_line,
start_character,
end_line,
end_character,
kind_filter.as_deref(),
)?;
let (client, uri) = self
.prepare_document(&file_path, ToolKind::CodeActions)
.await?;
let range = lsp_types::Range {
start: mcp_to_lsp_position(start_line, start_character),
end: mcp_to_lsp_position(end_line, end_character),
};
let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
let params = lsp_types::CodeActionParams {
text_document: TextDocumentIdentifier { uri },
range,
context: lsp_types::CodeActionContext {
diagnostics: context_diagnostics,
only,
trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<lsp_types::CodeActionResponse> = client
.request("textDocument/codeAction", params, timeout_duration)
.await?;
let response_vec = response.unwrap_or_default();
let mut actions = Vec::with_capacity(response_vec.len());
for action_or_command in response_vec {
let action = match action_or_command {
lsp_types::CodeActionOrCommand::CodeAction(action) => convert_code_action(action),
lsp_types::CodeActionOrCommand::Command(cmd) => {
let arguments = cmd.arguments.unwrap_or_else(Vec::new);
CodeAction {
title: cmd.title.clone(),
kind: None,
diagnostics: Vec::new(),
edit: None,
command: Some(CommandDescription {
title: cmd.title,
command: cmd.command,
arguments,
}),
is_preferred: false,
}
}
};
actions.push(action);
}
Ok(CodeActionsResult { actions })
}
pub async fn handle_call_hierarchy_prepare(
&self,
file_path: String,
line: u32,
character: u32,
) -> Result<CallHierarchyPrepareResult> {
if line < 1 || character < 1 {
return Err(Error::InvalidToolParams(
"Line and character positions must be >= 1".to_string(),
));
}
if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
return Err(Error::InvalidToolParams(format!(
"Position values must be <= {MAX_POSITION_VALUE}"
)));
}
let (client, uri) = self
.prepare_document(&file_path, ToolKind::CallHierarchy)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = LspCallHierarchyPrepareParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<CallHierarchyItem>> = client
.request(
"textDocument/prepareCallHierarchy",
params,
timeout_duration,
)
.await?;
let lsp_items = response.unwrap_or_default();
let mut items = Vec::with_capacity(lsp_items.len());
for item in lsp_items {
items.push(convert_call_hierarchy_item(item));
}
Ok(CallHierarchyPrepareResult { items })
}
pub async fn handle_incoming_calls(
&self,
item: serde_json::Value,
) -> Result<IncomingCallsResult> {
let lsp_item = mcp_item_to_lsp(item)?;
let path = self.parse_file_uri(&lsp_item.uri)?;
let (_server_id, client) = self.get_client_for_file(&path, ToolKind::CallHierarchy)?;
let params = CallHierarchyIncomingCallsParams {
item: lsp_item,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<CallHierarchyIncomingCall>> = client
.request("callHierarchy/incomingCalls", params, timeout_duration)
.await?;
let lsp_calls = response.unwrap_or_default();
let mut calls = Vec::with_capacity(lsp_calls.len());
for call in lsp_calls {
let from_ranges = {
let mut ranges = Vec::with_capacity(call.from_ranges.len());
for range in call.from_ranges {
ranges.push(normalize_range(range));
}
ranges
};
calls.push(IncomingCall {
from: convert_call_hierarchy_item(call.from),
from_ranges,
});
}
Ok(IncomingCallsResult { calls })
}
pub async fn handle_outgoing_calls(
&self,
item: serde_json::Value,
) -> Result<OutgoingCallsResult> {
let lsp_item = mcp_item_to_lsp(item)?;
let path = self.parse_file_uri(&lsp_item.uri)?;
let (_server_id, client) = self.get_client_for_file(&path, ToolKind::CallHierarchy)?;
let params = CallHierarchyOutgoingCallsParams {
item: lsp_item,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<CallHierarchyOutgoingCall>> = client
.request("callHierarchy/outgoingCalls", params, timeout_duration)
.await?;
let lsp_calls = response.unwrap_or_default();
let mut calls = Vec::with_capacity(lsp_calls.len());
for call in lsp_calls {
let from_ranges = {
let mut ranges = Vec::with_capacity(call.from_ranges.len());
for range in call.from_ranges {
ranges.push(normalize_range(range));
}
ranges
};
calls.push(OutgoingCall {
to: convert_call_hierarchy_item(call.to),
from_ranges,
});
}
Ok(OutgoingCallsResult { calls })
}
pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
let path = PathBuf::from(file_path);
let validated_path = validate_path_against_roots(&path, workspace_roots)?;
Ok(path_to_uri(&validated_path).to_string())
}
#[must_use]
pub fn diagnostics_from_cache_entry(diag_info: Option<&DiagnosticInfo>) -> DiagnosticsResult {
let diagnostics = diag_info.map_or_else(Vec::new, |diag_info| {
diag_info
.diagnostics
.iter()
.map(diagnostic_to_mcp)
.collect()
});
DiagnosticsResult { diagnostics }
}
#[must_use]
pub fn merge_diagnostics(
mut pull: DiagnosticsResult,
diag_info: Option<&DiagnosticInfo>,
) -> DiagnosticsResult {
const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
fn position_le(a: &Position2D, b: &Position2D) -> bool {
(a.line, a.character) <= (b.line, b.character)
}
fn ranges_close(a: &Range, b: &Range) -> bool {
let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
}
fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
pull.iter().any(|p| match (&candidate.code, &p.code) {
(Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
ranges_close(&p.range, &candidate.range)
}
_ => p == candidate,
})
}
let cached = Self::diagnostics_from_cache_entry(diag_info).diagnostics;
let new_diagnostics: Vec<_> = cached
.into_iter()
.filter(|c| !is_duplicate(&pull.diagnostics, c))
.collect();
pull.diagnostics.extend(new_diagnostics);
pull.diagnostics
.sort_by_key(|d| (d.range.start.line, d.range.start.character));
pull
}
pub fn handle_server_logs(
cache: &NotificationCache,
limit: usize,
min_level: Option<String>,
) -> Result<ServerLogsResult> {
use crate::bridge::notifications::LogLevel;
let min_level_filter = if let Some(level_str) = min_level {
let level = match level_str.to_lowercase().as_str() {
"error" => LogLevel::Error,
"warning" => LogLevel::Warning,
"info" => LogLevel::Info,
"debug" => LogLevel::Debug,
_ => {
return Err(Error::InvalidToolParams(format!(
"Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
)));
}
};
Some(level)
} else {
None
};
let all_logs = cache.get_logs();
let logs: Vec<_> = all_logs
.iter()
.filter(|log| {
min_level_filter.is_none_or(|min| match min {
LogLevel::Error => matches!(log.level, LogLevel::Error),
LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
LogLevel::Info => !matches!(log.level, LogLevel::Debug),
LogLevel::Debug => true,
})
})
.take(limit)
.cloned()
.collect();
Ok(ServerLogsResult { logs })
}
pub fn handle_server_messages(
cache: &NotificationCache,
limit: usize,
) -> Result<ServerMessagesResult> {
let all_messages = cache.get_messages();
let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
Ok(ServerMessagesResult { messages })
}
pub async fn handle_signature_help(
&self,
file_path: String,
line: u32,
character: u32,
) -> Result<SignatureHelpResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::SignatureHelp)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = LspSignatureHelpParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
context: None,
};
let timeout_duration = Duration::from_secs(30);
let response: Option<lsp_types::SignatureHelp> = client
.request("textDocument/signatureHelp", params, timeout_duration)
.await?;
let result = match response {
Some(sig_help) => SignatureHelpResult {
signatures: sig_help
.signatures
.into_iter()
.map(|sig| SignatureInfo {
label: sig.label,
documentation: sig.documentation.map(extract_documentation),
parameters: sig
.parameters
.unwrap_or_default()
.into_iter()
.map(|p| SignatureParameter {
label: match p.label {
lsp_types::ParameterLabel::Simple(s) => s,
lsp_types::ParameterLabel::LabelOffsets([start, end]) => {
format!("[{start},{end}]")
}
},
documentation: p.documentation.map(extract_documentation),
})
.collect(),
})
.collect(),
active_signature: sig_help.active_signature,
active_parameter: sig_help.active_parameter,
},
None => SignatureHelpResult {
signatures: vec![],
active_signature: None,
active_parameter: None,
},
};
Ok(result)
}
pub async fn handle_implementation(
&self,
file_path: String,
line: u32,
character: u32,
) -> Result<LocationsResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::Implementation)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<lsp_types::GotoDefinitionResponse> = client
.request("textDocument/implementation", params, timeout_duration)
.await?;
Ok(LocationsResult {
locations: goto_response_to_locations(response),
})
}
pub async fn handle_type_definition(
&self,
file_path: String,
line: u32,
character: u32,
) -> Result<LocationsResult> {
let (client, uri) = self
.prepare_document(&file_path, ToolKind::TypeDefinition)
.await?;
let lsp_position = mcp_to_lsp_position(line, character);
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: lsp_position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<lsp_types::GotoDefinitionResponse> = client
.request("textDocument/typeDefinition", params, timeout_duration)
.await?;
Ok(LocationsResult {
locations: goto_response_to_locations(response),
})
}
pub async fn handle_inlay_hints(
&self,
file_path: String,
start_line: u32,
start_character: u32,
end_line: u32,
end_character: u32,
) -> Result<InlayHintsResult> {
use crate::bridge::encoding::lsp_to_mcp_position;
let (client, uri) = self
.prepare_document(&file_path, ToolKind::InlayHints)
.await?;
let lsp_start = mcp_to_lsp_position(start_line, start_character);
let lsp_end = mcp_to_lsp_position(end_line, end_character);
let params = InlayHintParams {
text_document: TextDocumentIdentifier { uri },
range: lsp_types::Range {
start: lsp_start,
end: lsp_end,
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: Option<Vec<lsp_types::InlayHint>> = client
.request("textDocument/inlayHint", params, timeout_duration)
.await?;
let hints = response
.unwrap_or_default()
.into_iter()
.map(|hint| {
let (mcp_line, mcp_character) = lsp_to_mcp_position(hint.position);
let label = match hint.label {
InlayHintLabel::String(s) => s,
InlayHintLabel::LabelParts(parts) => parts
.into_iter()
.map(|p| p.value)
.collect::<Vec<_>>()
.concat(),
};
let tooltip = hint.tooltip.map(|t| match t {
lsp_types::InlayHintTooltip::String(s) => s,
lsp_types::InlayHintTooltip::MarkupContent(m) => m.value,
});
InlayHintEntry {
position: Position2D {
line: mcp_line,
character: mcp_character,
},
label,
kind: hint.kind.and_then(|k| {
serde_json::to_value(k)
.ok()
.and_then(|v| v.as_i64())
.and_then(|n| u8::try_from(n).ok())
}),
padding_left: hint.padding_left,
padding_right: hint.padding_right,
tooltip,
}
})
.collect();
Ok(InlayHintsResult { hints })
}
}
fn extract_documentation(doc: lsp_types::Documentation) -> String {
match doc {
lsp_types::Documentation::String(s) => s,
lsp_types::Documentation::MarkupContent(m) => m.value,
}
}
fn goto_response_to_locations(
response: Option<lsp_types::GotoDefinitionResponse>,
) -> Vec<Location> {
let lsp_locs: Vec<lsp_types::Location> = match response {
Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
.into_iter()
.map(|link| lsp_types::Location {
uri: link.target_uri,
range: link.target_selection_range,
})
.collect(),
None => vec![],
};
lsp_locs
.into_iter()
.map(|loc| Location {
uri: loc.uri.to_string(),
range: normalize_range(loc.range),
})
.collect()
}
fn extract_hover_contents(contents: HoverContents) -> String {
match contents {
HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string),
HoverContents::Array(marked_strings) => marked_strings
.into_iter()
.map(marked_string_to_string)
.collect::<Vec<_>>()
.join("\n\n"),
HoverContents::Markup(markup) => markup.value,
}
}
fn marked_string_to_string(marked: MarkedString) -> String {
match marked {
MarkedString::String(s) => s,
MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value),
}
}
fn validate_code_action_params(
start_line: u32,
start_character: u32,
end_line: u32,
end_character: u32,
kind_filter: Option<&str>,
) -> Result<()> {
const VALID_ACTION_KINDS: &[&str] = &[
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
];
if let Some(kind) = kind_filter
&& !VALID_ACTION_KINDS
.iter()
.any(|k| k.eq_ignore_ascii_case(kind))
{
return Err(Error::InvalidToolParams(format!(
"Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
)));
}
if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
return Err(Error::InvalidToolParams(
"Line and character positions must be >= 1".to_string(),
));
}
if start_line > MAX_POSITION_VALUE
|| start_character > MAX_POSITION_VALUE
|| end_line > MAX_POSITION_VALUE
|| end_character > MAX_POSITION_VALUE
{
return Err(Error::InvalidToolParams(format!(
"Position values must be <= {MAX_POSITION_VALUE}"
)));
}
if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
return Err(Error::InvalidToolParams(format!(
"Range size must be <= {MAX_RANGE_LINES} lines"
)));
}
if start_line > end_line || (start_line == end_line && start_character > end_character) {
return Err(Error::InvalidToolParams(
"Start position must be before or equal to end position".to_string(),
));
}
Ok(())
}
fn mcp_item_to_lsp(item: serde_json::Value) -> Result<CallHierarchyItem> {
let mcp: CallHierarchyItemResult = serde_json::from_value(item)
.map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
let uri = mcp.uri.parse::<lsp_types::Uri>().map_err(|e| {
Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}"))
})?;
let detail = mcp.detail;
let data = mcp.data;
let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind))
.unwrap_or(lsp_types::SymbolKind::FUNCTION);
Ok(CallHierarchyItem {
name: mcp.name,
kind,
tags: None,
detail,
uri,
range: denormalize_range(&mcp.range),
selection_range: denormalize_range(&mcp.selection_range),
data,
})
}
const fn denormalize_range(range: &Range) -> lsp_types::Range {
lsp_types::Range {
start: lsp_types::Position {
line: range.start.line.saturating_sub(1),
character: range.start.character.saturating_sub(1),
},
end: lsp_types::Position {
line: range.end.line.saturating_sub(1),
character: range.end.character.saturating_sub(1),
},
}
}
const fn normalize_range(range: lsp_types::Range) -> Range {
Range {
start: Position2D {
line: range.start.line + 1,
character: range.start.character + 1,
},
end: Position2D {
line: range.end.line + 1,
character: range.end.character + 1,
},
}
}
fn diagnostic_to_mcp(diag: &lsp_types::Diagnostic) -> Diagnostic {
Diagnostic {
range: normalize_range(diag.range),
severity: match diag.severity {
Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
_ => DiagnosticSeverity::Information,
},
message: diag.message.clone(),
code: diag.code.as_ref().map(|c| match c {
lsp_types::NumberOrString::Number(n) => n.to_string(),
lsp_types::NumberOrString::String(s) => s.clone(),
}),
}
}
fn convert_document_symbol(symbol: DocumentSymbol) -> Symbol {
Symbol {
name: symbol.name,
kind: format!("{:?}", symbol.kind),
range: normalize_range(symbol.range),
selection_range: normalize_range(symbol.selection_range),
children: symbol
.children
.map(|children| children.into_iter().map(convert_document_symbol).collect()),
}
}
fn convert_call_hierarchy_item(item: CallHierarchyItem) -> CallHierarchyItemResult {
CallHierarchyItemResult {
name: item.name,
kind: serde_json::to_value(item.kind)
.ok()
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0),
detail: item.detail,
uri: item.uri.to_string(),
range: normalize_range(item.range),
selection_range: normalize_range(item.selection_range),
data: item.data,
}
}
fn convert_code_action(action: lsp_types::CodeAction) -> CodeAction {
let diagnostics = action.diagnostics.map_or_else(Vec::new, |diags| {
let mut result = Vec::with_capacity(diags.len());
for d in diags {
result.push(Diagnostic {
range: normalize_range(d.range),
severity: match d.severity {
Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
DiagnosticSeverity::Information
}
Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
_ => DiagnosticSeverity::Information,
},
message: d.message,
code: d.code.map(|c| match c {
lsp_types::NumberOrString::Number(n) => n.to_string(),
lsp_types::NumberOrString::String(s) => s,
}),
});
}
result
});
let edit = action.edit.map(|edit| {
let changes = edit.changes.map_or_else(Vec::new, |changes_map| {
let mut result = Vec::with_capacity(changes_map.len());
for (uri, edits) in changes_map {
let mut text_edits = Vec::with_capacity(edits.len());
for e in edits {
text_edits.push(TextEdit {
range: normalize_range(e.range),
new_text: e.new_text,
});
}
result.push(DocumentChanges {
uri: uri.to_string(),
edits: text_edits,
});
}
result
});
WorkspaceEditDescription { changes }
});
let command = action.command.map(|cmd| {
let arguments = cmd.arguments.unwrap_or_else(Vec::new);
CommandDescription {
title: cmd.title,
command: cmd.command,
arguments,
}
});
CodeAction {
title: action.title,
kind: action.kind.map(|k| k.as_str().to_string()),
diagnostics,
edit,
command,
is_preferred: action.is_preferred.unwrap_or(false),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::fs;
use tempfile::TempDir;
use url::Url;
use super::*;
#[test]
fn test_translator_new() {
let translator = Translator::new();
assert_eq!(translator.workspace_roots.len(), 0);
assert_eq!(lock_std(&translator.lsp_clients).len(), 0);
assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
}
#[test]
fn test_set_workspace_roots() {
let mut translator = Translator::new();
let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
translator.set_workspace_roots(roots.clone());
assert_eq!(*translator.workspace_roots, roots);
}
#[test]
fn test_register_server() {
let translator = Translator::new();
assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
}
#[test]
fn test_get_client_for_file_server_initializing_when_expected() {
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &HashMap::new());
let id = ServerId::from(lang.clone());
let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
let mut expected = HashSet::new();
expected.insert(id.clone());
translator.set_expected_servers(expected);
let err = translator
.get_client_for_file(&path, ToolKind::Hover)
.unwrap_err();
assert!(matches!(err, Error::ServerInitializing { server_id } if server_id == id));
}
#[test]
fn test_get_client_for_file_no_server_when_not_expected() {
let translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &translator.extension_map);
let err = translator
.get_client_for_file(&path, ToolKind::Hover)
.unwrap_err();
assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
}
#[test]
fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() {
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &HashMap::new());
let id = ServerId::from(lang.clone());
let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
let mut expected = HashSet::new();
expected.insert(id);
translator.set_expected_servers(expected);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let err = translator
.get_client_for_file(&path, ToolKind::Hover)
.unwrap_err();
assert!(matches!(err, Error::NoServerForLanguage(_)));
}
#[test]
fn test_diagnostic_request_params_omit_optional_null_fields() {
let uri = "file:///test.ts".parse().unwrap();
let params = diagnostic_request_params(TextDocumentIdentifier { uri });
let value = serde_json::to_value(params).unwrap();
assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
assert!(value.get("identifier").is_none());
assert!(value.get("previousResultId").is_none());
}
#[test]
fn test_validate_path_no_workspace_roots() {
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator.validate_path(&test_file);
assert!(result.is_ok());
}
#[test]
fn test_validate_path_within_workspace() {
let mut translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path().to_path_buf();
translator.set_workspace_roots(vec![workspace_root]);
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator.validate_path(&test_file);
assert!(result.is_ok());
}
#[test]
fn test_validate_path_outside_workspace() {
let mut translator = Translator::new();
let temp_dir1 = TempDir::new().unwrap();
let temp_dir2 = TempDir::new().unwrap();
translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
let test_file = temp_dir2.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator.validate_path(&test_file);
assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
}
#[test]
fn test_normalize_range() {
let lsp_range = lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 2,
character: 5,
},
};
let mcp_range = normalize_range(lsp_range);
assert_eq!(mcp_range.start.line, 1);
assert_eq!(mcp_range.start.character, 1);
assert_eq!(mcp_range.end.line, 3);
assert_eq!(mcp_range.end.character, 6);
}
#[test]
fn test_extract_hover_contents_string() {
let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
let contents = lsp_types::HoverContents::Scalar(marked_string);
let result = extract_hover_contents(contents);
assert_eq!(result, "Test hover");
}
#[test]
fn test_extract_hover_contents_language_string() {
let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString {
language: "rust".to_string(),
value: "fn main() {}".to_string(),
});
let contents = lsp_types::HoverContents::Scalar(marked_string);
let result = extract_hover_contents(contents);
assert_eq!(result, "```rust\nfn main() {}\n```");
}
#[test]
fn test_extract_hover_contents_markup() {
let markup = lsp_types::MarkupContent {
kind: lsp_types::MarkupKind::Markdown,
value: "# Documentation".to_string(),
};
let contents = lsp_types::HoverContents::Markup(markup);
let result = extract_hover_contents(contents);
assert_eq!(result, "# Documentation");
}
#[tokio::test]
async fn test_handle_workspace_symbol_no_server() {
let translator = Translator::new();
let result = translator
.handle_workspace_symbol("test".to_string(), None, 100)
.await;
assert!(matches!(result, Err(Error::NoServerConfigured)));
}
#[tokio::test]
async fn test_handle_code_actions_invalid_kind() {
let translator = Translator::new();
let result = translator
.handle_code_actions(
"/tmp/test.rs".to_string(),
1,
1,
1,
10,
Some("invalid_kind".to_string()),
)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_valid_kind_quickfix() {
use tempfile::TempDir;
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator
.handle_code_actions(
test_file.to_str().unwrap().to_string(),
1,
1,
1,
10,
Some("quickfix".to_string()),
)
.await;
assert!(result.is_err());
assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_valid_kind_refactor() {
use tempfile::TempDir;
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator
.handle_code_actions(
test_file.to_str().unwrap().to_string(),
1,
1,
1,
10,
Some("refactor".to_string()),
)
.await;
assert!(result.is_err());
assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_valid_kind_refactor_extract() {
use tempfile::TempDir;
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator
.handle_code_actions(
test_file.to_str().unwrap().to_string(),
1,
1,
1,
10,
Some("refactor.extract".to_string()),
)
.await;
assert!(result.is_err());
assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_valid_kind_source() {
use tempfile::TempDir;
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator
.handle_code_actions(
test_file.to_str().unwrap().to_string(),
1,
1,
1,
10,
Some("source.organizeImports".to_string()),
)
.await;
assert!(result.is_err());
assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_invalid_range_zero() {
let translator = Translator::new();
let result = translator
.handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_invalid_range_order() {
let translator = Translator::new();
let result = translator
.handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_code_actions_empty_range() {
use tempfile::TempDir;
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator
.handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
.await;
assert!(result.is_err());
assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
}
#[test]
fn test_convert_code_action_minimal() {
let lsp_action = lsp_types::CodeAction {
title: "Fix issue".to_string(),
kind: None,
diagnostics: None,
edit: None,
command: None,
is_preferred: None,
disabled: None,
data: None,
};
let result = convert_code_action(lsp_action);
assert_eq!(result.title, "Fix issue");
assert!(result.kind.is_none());
assert!(result.diagnostics.is_empty());
assert!(result.edit.is_none());
assert!(result.command.is_none());
assert!(!result.is_preferred);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_convert_code_action_with_diagnostics_all_severities() {
let lsp_diagnostics = vec![
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::ERROR),
message: "Error message".to_string(),
code: Some(lsp_types::NumberOrString::Number(1)),
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 1,
character: 0,
},
end: lsp_types::Position {
line: 1,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::WARNING),
message: "Warning message".to_string(),
code: Some(lsp_types::NumberOrString::String("W001".to_string())),
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 2,
character: 0,
},
end: lsp_types::Position {
line: 2,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
message: "Info message".to_string(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 3,
character: 0,
},
end: lsp_types::Position {
line: 3,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::HINT),
message: "Hint message".to_string(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
];
let lsp_action = lsp_types::CodeAction {
title: "Fix all issues".to_string(),
kind: Some(lsp_types::CodeActionKind::QUICKFIX),
diagnostics: Some(lsp_diagnostics),
edit: None,
command: None,
is_preferred: None,
disabled: None,
data: None,
};
let result = convert_code_action(lsp_action);
assert_eq!(result.diagnostics.len(), 4);
assert!(matches!(
result.diagnostics[0].severity,
DiagnosticSeverity::Error
));
assert!(matches!(
result.diagnostics[1].severity,
DiagnosticSeverity::Warning
));
assert!(matches!(
result.diagnostics[2].severity,
DiagnosticSeverity::Information
));
assert!(matches!(
result.diagnostics[3].severity,
DiagnosticSeverity::Hint
));
assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
}
#[test]
#[allow(clippy::mutable_key_type)]
fn test_convert_code_action_with_workspace_edit() {
use std::collections::HashMap;
use std::str::FromStr;
let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap();
let mut changes_map = HashMap::new();
changes_map.insert(
uri,
vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
new_text: "fixed".to_string(),
}],
);
let lsp_action = lsp_types::CodeAction {
title: "Apply fix".to_string(),
kind: Some(lsp_types::CodeActionKind::QUICKFIX),
diagnostics: None,
edit: Some(lsp_types::WorkspaceEdit {
changes: Some(changes_map),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
};
let result = convert_code_action(lsp_action);
assert!(result.edit.is_some());
let edit = result.edit.unwrap();
assert_eq!(edit.changes.len(), 1);
assert_eq!(edit.changes[0].uri, "file:///test.rs");
assert_eq!(edit.changes[0].edits.len(), 1);
assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
assert!(result.is_preferred);
}
#[test]
fn test_convert_code_action_with_command() {
let lsp_action = lsp_types::CodeAction {
title: "Run command".to_string(),
kind: Some(lsp_types::CodeActionKind::REFACTOR),
diagnostics: None,
edit: None,
command: Some(lsp_types::Command {
title: "Execute refactor".to_string(),
command: "refactor.extract".to_string(),
arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
}),
is_preferred: None,
disabled: None,
data: None,
};
let result = convert_code_action(lsp_action);
assert!(result.command.is_some());
let cmd = result.command.unwrap();
assert_eq!(cmd.title, "Execute refactor");
assert_eq!(cmd.command, "refactor.extract");
assert_eq!(cmd.arguments.len(), 2);
}
#[tokio::test]
async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
let translator = Translator::new();
let result = translator
.handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
let result = translator
.handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
let translator = Translator::new();
let result = translator
.handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
let result = translator
.handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001)
.await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_incoming_calls_invalid_json() {
let translator = Translator::new();
let invalid_item = serde_json::json!({"invalid": "structure"});
let result = translator.handle_incoming_calls(invalid_item).await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_handle_outgoing_calls_invalid_json() {
let translator = Translator::new();
let invalid_item = serde_json::json!({"invalid": "structure"});
let result = translator.handle_outgoing_calls(invalid_item).await;
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_parse_file_uri_invalid_scheme() {
let translator = Translator::new();
let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap();
let result = translator.parse_file_uri(&uri);
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[tokio::test]
async fn test_parse_file_uri_valid_scheme() {
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let file_url = Url::from_file_path(&test_file).unwrap();
let uri: lsp_types::Uri = file_url.as_str().parse().unwrap();
let result = translator.parse_file_uri(&uri);
assert!(result.is_ok());
}
#[test]
fn test_handle_cached_diagnostics_empty() {
let cache = NotificationCache::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let cache_key =
Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
let diag_info = cache.get_diagnostics(&cache_key).cloned();
let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
assert_eq!(diags.diagnostics.len(), 0);
}
#[test]
fn test_handle_server_logs_with_filter() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
cache.store_log(LogLevel::Warning, "warning msg".to_string());
cache.store_log(LogLevel::Info, "info msg".to_string());
cache.store_log(LogLevel::Debug, "debug msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 1);
assert_eq!(logs.logs[0].message, "error msg");
let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 2);
let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 3);
let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 4);
let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
assert!(matches!(result, Err(Error::InvalidToolParams(_))));
}
#[test]
fn test_handle_server_messages_limit() {
use crate::bridge::notifications::MessageType;
let mut cache = NotificationCache::new();
for i in 0..10 {
cache.store_message(MessageType::Info, format!("message {i}"));
}
let result = Translator::handle_server_messages(&cache, 5);
assert!(result.is_ok());
let messages = result.unwrap();
assert_eq!(messages.messages.len(), 5);
assert_eq!(messages.messages[0].message, "message 0");
assert_eq!(messages.messages[4].message, "message 4");
let result = Translator::handle_server_messages(&cache, 100);
assert!(result.is_ok());
let messages = result.unwrap();
assert_eq!(messages.messages.len(), 10);
}
#[test]
fn test_handle_cached_diagnostics_with_data() {
let mut cache = NotificationCache::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let canonical_path = test_file.canonicalize().unwrap();
let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
.unwrap()
.as_str()
.parse()
.unwrap();
let diagnostic = lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::ERROR),
message: "test error".to_string(),
code: Some(lsp_types::NumberOrString::String("E001".to_string())),
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
};
cache.store_diagnostics(&uri, Some(1), vec![diagnostic]);
let cache_key =
Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
let diag_info = cache.get_diagnostics(&cache_key).cloned();
let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
assert_eq!(diags.diagnostics.len(), 1);
assert_eq!(diags.diagnostics[0].message, "test error");
assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
assert!(matches!(
diags.diagnostics[0].severity,
DiagnosticSeverity::Error
));
assert_eq!(diags.diagnostics[0].range.start.line, 1);
assert_eq!(diags.diagnostics[0].range.start.character, 1);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_handle_cached_diagnostics_multiple_severities() {
let mut cache = NotificationCache::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let canonical_path = test_file.canonicalize().unwrap();
let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
.unwrap()
.as_str()
.parse()
.unwrap();
let diagnostics = vec![
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::ERROR),
message: "error".to_string(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 1,
character: 0,
},
end: lsp_types::Position {
line: 1,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::WARNING),
message: "warning".to_string(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 2,
character: 0,
},
end: lsp_types::Position {
line: 2,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
message: "info".to_string(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 3,
character: 0,
},
end: lsp_types::Position {
line: 3,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::HINT),
message: "hint".to_string(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
},
];
cache.store_diagnostics(&uri, Some(1), diagnostics);
let cache_key =
Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
let diag_info = cache.get_diagnostics(&cache_key).cloned();
let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
assert_eq!(diags.diagnostics.len(), 4);
assert!(matches!(
diags.diagnostics[0].severity,
DiagnosticSeverity::Error
));
assert!(matches!(
diags.diagnostics[1].severity,
DiagnosticSeverity::Warning
));
assert!(matches!(
diags.diagnostics[2].severity,
DiagnosticSeverity::Information
));
assert!(matches!(
diags.diagnostics[3].severity,
DiagnosticSeverity::Hint
));
}
#[test]
fn test_handle_cached_diagnostics_with_numeric_code() {
let mut cache = NotificationCache::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let canonical_path = test_file.canonicalize().unwrap();
let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
.unwrap()
.as_str()
.parse()
.unwrap();
let diagnostic = lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::ERROR),
message: "test error".to_string(),
code: Some(lsp_types::NumberOrString::Number(42)),
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
};
cache.store_diagnostics(&uri, Some(1), vec![diagnostic]);
let cache_key =
Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
let diag_info = cache.get_diagnostics(&cache_key).cloned();
let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
assert_eq!(diags.diagnostics.len(), 1);
assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
}
#[test]
fn test_handle_cached_diagnostics_invalid_path() {
let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs");
assert!(matches!(result, Err(Error::FileIo { .. })));
}
fn lsp_diag(
line: u32,
end_character: u32,
severity: lsp_types::DiagnosticSeverity,
message: &str,
code: Option<&str>,
) -> lsp_types::Diagnostic {
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position { line, character: 0 },
end: lsp_types::Position {
line,
character: end_character,
},
},
severity: Some(severity),
message: message.to_string(),
code: code.map(|c| lsp_types::NumberOrString::String(c.to_string())),
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
}
}
fn diag_info(diagnostics: Vec<lsp_types::Diagnostic>) -> DiagnosticInfo {
DiagnosticInfo {
uri: "file:///test.rs".parse().unwrap(),
version: Some(1),
diagnostics,
}
}
#[test]
fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
let pull = DiagnosticsResult {
diagnostics: vec![],
};
let cache = diag_info(vec![lsp_diag(
0,
10,
lsp_types::DiagnosticSeverity::WARNING,
"unused import: `std::fmt`",
None,
)]);
let merged = Translator::merge_diagnostics(pull, Some(&cache));
assert_eq!(merged.diagnostics.len(), 1);
assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
assert!(matches!(
merged.diagnostics[0].severity,
DiagnosticSeverity::Warning
));
}
#[test]
fn test_merge_diagnostics_exact_duplicate_not_repeated() {
let pull_diag = Diagnostic {
range: Range {
start: Position2D {
line: 1,
character: 1,
},
end: Position2D {
line: 1,
character: 11,
},
},
severity: DiagnosticSeverity::Error,
message: "mismatched types".to_string(),
code: Some("E0308".to_string()),
};
let pull = DiagnosticsResult {
diagnostics: vec![pull_diag.clone()],
};
let cache = diag_info(vec![lsp_diag(
0,
10,
lsp_types::DiagnosticSeverity::ERROR,
"mismatched types",
Some("E0308"),
)]);
let merged = Translator::merge_diagnostics(pull, Some(&cache));
assert_eq!(merged.diagnostics.len(), 1);
assert_eq!(merged.diagnostics[0], pull_diag);
}
#[test]
fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
let pull_diag = Diagnostic {
range: Range {
start: Position2D {
line: 1,
character: 1,
},
end: Position2D {
line: 1,
character: 5,
},
},
severity: DiagnosticSeverity::Error,
message: "syntax error".to_string(),
code: None,
};
let pull = DiagnosticsResult {
diagnostics: vec![pull_diag.clone()],
};
let merged = Translator::merge_diagnostics(pull, None);
assert_eq!(merged.diagnostics, vec![pull_diag]);
}
#[test]
fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
let pull = DiagnosticsResult {
diagnostics: vec![],
};
let cache = diag_info(vec![
lsp_diag(
0,
10,
lsp_types::DiagnosticSeverity::WARNING,
"unused import: `std::fmt`",
None,
),
lsp_diag(
5,
8,
lsp_types::DiagnosticSeverity::WARNING,
"function `helper` is never used",
None,
),
]);
let merged = Translator::merge_diagnostics(pull, Some(&cache));
assert_eq!(merged.diagnostics.len(), 2);
assert!(
merged
.diagnostics
.iter()
.any(|d| d.message == "unused import: `std::fmt`")
);
assert!(
merged
.diagnostics
.iter()
.any(|d| d.message == "function `helper` is never used")
);
}
#[test]
fn test_merge_diagnostics_same_range_different_message_not_deduped() {
let pull_diag = Diagnostic {
range: Range {
start: Position2D {
line: 1,
character: 1,
},
end: Position2D {
line: 1,
character: 11,
},
},
severity: DiagnosticSeverity::Error,
message: "mismatched types".to_string(),
code: None,
};
let pull = DiagnosticsResult {
diagnostics: vec![pull_diag],
};
let cache = diag_info(vec![lsp_diag(
0,
10,
lsp_types::DiagnosticSeverity::ERROR,
"expected `i32`, found `&str`",
None,
)]);
let merged = Translator::merge_diagnostics(pull, Some(&cache));
assert_eq!(merged.diagnostics.len(), 2);
}
#[test]
fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
let pull_diag = Diagnostic {
range: Range {
start: Position2D {
line: 96,
character: 7,
},
end: Position2D {
line: 96,
character: 12,
},
},
severity: DiagnosticSeverity::Error,
message: "not all trait items implemented, missing: `fn hello`".to_string(),
code: Some("E0046".to_string()),
};
let pull = DiagnosticsResult {
diagnostics: vec![pull_diag.clone()],
};
let cache = diag_info(vec![lsp_diag(
94,
31,
lsp_types::DiagnosticSeverity::ERROR,
"not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
Some("E0046"),
)]);
let merged = Translator::merge_diagnostics(pull, Some(&cache));
assert_eq!(merged.diagnostics.len(), 1);
assert_eq!(merged.diagnostics[0], pull_diag);
}
#[test]
fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept() {
let pull_diag = Diagnostic {
range: Range {
start: Position2D {
line: 5,
character: 9,
},
end: Position2D {
line: 5,
character: 20,
},
},
severity: DiagnosticSeverity::Error,
message: "mismatched types: expected `i32`, found `&str`".to_string(),
code: Some("E0308".to_string()),
};
let pull = DiagnosticsResult {
diagnostics: vec![pull_diag.clone()],
};
let cache = diag_info(vec![lsp_diag(
49,
22,
lsp_types::DiagnosticSeverity::ERROR,
"mismatched types: expected `String`, found `Vec<u8>`",
Some("E0308"),
)]);
let merged = Translator::merge_diagnostics(pull, Some(&cache));
assert_eq!(merged.diagnostics.len(), 2);
assert_eq!(merged.diagnostics[0], pull_diag);
assert_eq!(
merged.diagnostics[1].message,
"mismatched types: expected `String`, found `Vec<u8>`"
);
}
#[test]
fn test_handle_server_logs_no_filter() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
cache.store_log(LogLevel::Warning, "warning msg".to_string());
cache.store_log(LogLevel::Info, "info msg".to_string());
cache.store_log(LogLevel::Debug, "debug msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, None);
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 4);
}
#[test]
fn test_handle_server_logs_error_filter_strict() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
cache.store_log(LogLevel::Warning, "warning msg".to_string());
cache.store_log(LogLevel::Info, "info msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 1);
assert_eq!(logs.logs[0].message, "error msg");
}
#[test]
fn test_handle_server_logs_warning_filter_includes_errors() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
cache.store_log(LogLevel::Warning, "warning msg".to_string());
cache.store_log(LogLevel::Info, "info msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 2);
}
#[test]
fn test_handle_server_logs_info_filter_excludes_debug() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
cache.store_log(LogLevel::Info, "info msg".to_string());
cache.store_log(LogLevel::Debug, "debug msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 2);
}
#[test]
fn test_handle_server_logs_debug_filter_includes_all() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
cache.store_log(LogLevel::Warning, "warning msg".to_string());
cache.store_log(LogLevel::Info, "info msg".to_string());
cache.store_log(LogLevel::Debug, "debug msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 4);
}
#[test]
fn test_handle_server_logs_limit_applies_after_filter() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
for i in 0..10 {
cache.store_log(LogLevel::Error, format!("error {i}"));
}
let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
assert!(result.is_ok());
let logs = result.unwrap();
assert_eq!(logs.logs.len(), 5);
assert_eq!(logs.logs[0].message, "error 0");
assert_eq!(logs.logs[4].message, "error 4");
}
#[test]
fn test_handle_server_logs_case_insensitive_level() {
use crate::bridge::notifications::LogLevel;
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error msg".to_string());
let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
assert!(result.is_ok());
let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
assert!(result.is_ok());
let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_handle_server_messages_empty() {
let cache = NotificationCache::new();
let result = Translator::handle_server_messages(&cache, 10);
assert!(result.is_ok());
let messages = result.unwrap();
assert_eq!(messages.messages.len(), 0);
}
#[test]
fn test_handle_server_messages_with_different_types() {
use crate::bridge::notifications::MessageType;
let mut cache = NotificationCache::new();
cache.store_message(MessageType::Error, "error".to_string());
cache.store_message(MessageType::Warning, "warning".to_string());
cache.store_message(MessageType::Info, "info".to_string());
cache.store_message(MessageType::Log, "log".to_string());
let result = Translator::handle_server_messages(&cache, 10);
assert!(result.is_ok());
let messages = result.unwrap();
assert_eq!(messages.messages.len(), 4);
assert_eq!(messages.messages[0].message, "error");
assert_eq!(messages.messages[1].message, "warning");
assert_eq!(messages.messages[2].message, "info");
assert_eq!(messages.messages[3].message, "log");
}
#[test]
fn test_handle_server_messages_zero_limit() {
use crate::bridge::notifications::MessageType;
let mut cache = NotificationCache::new();
cache.store_message(MessageType::Info, "test".to_string());
let result = Translator::handle_server_messages(&cache, 0);
assert!(result.is_ok());
let messages = result.unwrap();
assert_eq!(messages.messages.len(), 0);
}
#[test]
fn test_handle_cached_diagnostics_path_outside_workspace() {
let temp_dir1 = TempDir::new().unwrap();
let temp_dir2 = TempDir::new().unwrap();
let workspace_roots = vec![temp_dir1.path().to_path_buf()];
let test_file = temp_dir2.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result =
Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
}
#[test]
fn test_translator_with_custom_extensions() {
let mut extension_map = HashMap::new();
extension_map.insert("nu".to_string(), "nushell".to_string());
extension_map.insert("customext".to_string(), "customlang".to_string());
let translator = Translator::new().with_extensions(extension_map.clone());
assert_eq!(translator.extension_map.len(), 2);
assert_eq!(
translator.extension_map.get("nu"),
Some(&"nushell".to_string())
);
assert_eq!(
translator.extension_map.get("customext"),
Some(&"customlang".to_string())
);
}
#[test]
fn test_get_client_for_file_uses_custom_extension() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("script.nu");
fs::write(&test_file, "echo hello").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert("nu".to_string(), "nushell".to_string());
let translator = Translator::new().with_extensions(extension_map);
let result = translator.get_client_for_file(&test_file, ToolKind::Hover);
assert!(result.is_err());
if let Err(Error::NoServerForLanguage(lang)) = result {
assert_eq!(lang, "nushell");
} else {
panic!("Expected NoServerForLanguage(nushell) error");
}
}
#[test]
fn test_get_client_for_file_falls_back_to_default() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("unknown.xyz");
fs::write(&test_file, "content").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert("rs".to_string(), "rust".to_string());
let translator = Translator::new().with_extensions(extension_map);
let result = translator.get_client_for_file(&test_file, ToolKind::Hover);
assert!(result.is_err());
if let Err(Error::NoServerForLanguage(lang)) = result {
assert_eq!(lang, "plaintext");
} else {
panic!("Expected NoServerForLanguage(plaintext) error");
}
}
#[test]
fn test_get_client_for_file_routes_tsx_to_typescript_server() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("component.tsx");
fs::write(&test_file, "export const Component = () => <div />").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert("tsx".to_string(), "typescriptreact".to_string());
let translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([(
ServerId::from("typescript"),
"typescript".to_string(),
)]));
translator.register_client(
"typescript".to_string(),
LspClient::new(crate::config::LspServerConfig::typescript()),
);
let (_id, client) = translator
.get_client_for_file(&test_file, ToolKind::Hover)
.unwrap();
assert_eq!(client.language_id(), "typescript");
}
#[test]
fn test_get_client_for_file_prefers_exact_react_server() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("component.tsx");
fs::write(&test_file, "export const Component = () => <div />").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert("tsx".to_string(), "typescriptreact".to_string());
let typescript_react_config = crate::config::LspServerConfig {
language_id: "typescriptreact".to_string(),
command: "typescript-language-server".to_string(),
args: vec!["--stdio".to_string()],
env: HashMap::new(),
file_patterns: vec!["**/*.tsx".to_string()],
initialization_options: None,
timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
};
let translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([
(ServerId::from("typescript"), "typescript".to_string()),
(
ServerId::from("typescriptreact"),
"typescriptreact".to_string(),
),
]));
translator.register_client(
"typescript".to_string(),
LspClient::new(crate::config::LspServerConfig::typescript()),
);
translator.register_client(
"typescriptreact".to_string(),
LspClient::new(typescript_react_config),
);
let (_id, client) = translator
.get_client_for_file(&test_file, ToolKind::Hover)
.unwrap();
assert_eq!(client.language_id(), "typescriptreact");
}
#[test]
fn test_get_client_for_file_routes_jsx_to_javascript_server() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("component.jsx");
fs::write(&test_file, "export const Component = () => <div />").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert("jsx".to_string(), "javascriptreact".to_string());
let javascript_config = crate::config::LspServerConfig {
language_id: "javascript".to_string(),
command: "typescript-language-server".to_string(),
args: vec!["--stdio".to_string()],
env: HashMap::new(),
file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
initialization_options: None,
timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
};
let translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([(
ServerId::from("javascript"),
"javascript".to_string(),
)]));
translator.register_client("javascript".to_string(), LspClient::new(javascript_config));
let (_id, client) = translator
.get_client_for_file(&test_file, ToolKind::Hover)
.unwrap();
assert_eq!(client.language_id(), "javascript");
}
#[tokio::test]
async fn test_serve_initializes_translator_with_extensions() {
use crate::config::{LanguageExtensionMapping, WorkspaceConfig};
let language_extensions = vec![
LanguageExtensionMapping {
extensions: vec!["nu".to_string()],
language_id: "nushell".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["rs".to_string()],
language_id: "rust".to_string(),
},
];
let config = crate::config::ServerConfig {
workspace: WorkspaceConfig {
roots: vec![PathBuf::from("/tmp/test-workspace")],
position_encodings: vec!["utf-8".to_string()],
language_extensions: language_extensions.clone(),
heuristics_max_depth: 10,
},
lsp_servers: vec![],
};
let extension_map = config.build_effective_extension_map();
assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string()));
assert_eq!(extension_map.get("rs"), Some(&"rust".to_string()));
let result = crate::serve(config).await;
if let Err(ref err) = result {
assert!(
!matches!(err, crate::error::Error::NoServersAvailable(_)),
"serve() must not return NoServersAvailable for empty lsp_servers config"
);
}
}
#[test]
fn test_convert_call_hierarchy_item_kind_is_numeric() {
let item = lsp_types::CallHierarchyItem {
name: "my_fn".to_string(),
kind: lsp_types::SymbolKind::FUNCTION,
tags: None,
detail: None,
uri: "file:///tmp/test.rs".parse().unwrap(),
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
selection_range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
data: None,
};
let result = convert_call_hierarchy_item(item);
assert_eq!(result.kind, 12u32);
assert_eq!(result.name, "my_fn");
}
use std::process::Stdio;
use serde_json::Value as JsonValue;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::time::timeout;
use crate::config::LspServerConfig;
use crate::lsp::LspTransport;
struct FakeServer {
_write_half: Child,
_read_half: Child,
read_half_stdin: ChildStdin,
write_stdout: ChildStdout,
}
fn fake_lsp_client() -> (LspClient, FakeServer) {
let mut write_half = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let write_stdin = write_half.stdin.take().unwrap();
let write_stdout = write_half.stdout.take().unwrap();
let mut read_half = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let read_stdout = read_half.stdout.take().unwrap();
let read_stdin = read_half.stdin.take().unwrap();
let transport = LspTransport::new(write_stdin, read_stdout);
let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
(
client,
FakeServer {
_write_half: write_half,
_read_half: read_half,
read_half_stdin: read_stdin,
write_stdout,
},
)
}
async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> JsonValue {
let mut content_length = None;
let mut line = String::new();
loop {
line.clear();
reader.read_line(&mut line).await.unwrap();
if line == "\r\n" || line == "\n" {
break;
}
if let Some((key, value)) = line.trim_end().split_once(':')
&& key.trim().eq_ignore_ascii_case("content-length")
{
content_length = Some(value.trim().parse::<usize>().unwrap());
}
}
let mut buf = vec![0u8; content_length.unwrap()];
reader.read_exact(&mut buf).await.unwrap();
serde_json::from_slice(&buf).unwrap()
}
async fn write_response(stdin: &mut ChildStdin, id: &JsonValue, result: JsonValue) {
let message = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
});
let content = serde_json::to_string(&message).unwrap();
let header = format!("Content-Length: {}\r\n\r\n", content.len());
stdin.write_all(header.as_bytes()).await.unwrap();
stdin.write_all(content.as_bytes()).await.unwrap();
stdin.flush().await.unwrap();
}
async fn write_error_response(
stdin: &mut ChildStdin,
id: &JsonValue,
code: i64,
message: &str,
) {
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": code,
"message": message,
},
});
let content = serde_json::to_string(&response).unwrap();
let header = format!("Content-Length: {}\r\n\r\n", content.len());
stdin.write_all(header.as_bytes()).await.unwrap();
stdin.write_all(content.as_bytes()).await.unwrap();
stdin.flush().await.unwrap();
}
#[tokio::test]
async fn test_concurrent_handlers_on_different_files_do_not_serialize() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert("aa".to_string(), "lang_a".to_string());
extensions.insert("bb".to_string(), "lang_b".to_string());
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([
(ServerId::from("lang_a"), "lang_a".to_string()),
(ServerId::from("lang_b"), "lang_b".to_string()),
]));
translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
let (client_a, mut server_a) = fake_lsp_client();
let (client_b, mut server_b) = fake_lsp_client();
translator.register_client("lang_a".to_string(), client_a);
translator.register_client("lang_b".to_string(), client_b);
let path_a = dir.path().join("file.aa");
let path_b = dir.path().join("file.bb");
fs::write(&path_a, "content a").unwrap();
fs::write(&path_b, "content b").unwrap();
let translator = Arc::new(translator);
let slow = {
let translator = Arc::clone(&translator);
let path = path_a.to_string_lossy().to_string();
tokio::spawn(async move { translator.handle_hover(path, 1, 1).await })
};
let mut wire_a = BufReader::new(&mut server_a.write_stdout);
let opened_a = read_framed_message(&mut wire_a).await;
assert_eq!(opened_a["method"], "textDocument/didOpen");
let hover_request_a = read_framed_message(&mut wire_a).await;
assert_eq!(hover_request_a["method"], "textDocument/hover");
let fast = {
let translator = Arc::clone(&translator);
let path = path_b.to_string_lossy().to_string();
tokio::spawn(async move { translator.handle_hover(path, 1, 1).await })
};
let mut wire_b = BufReader::new(&mut server_b.write_stdout);
let opened_b = read_framed_message(&mut wire_b).await;
assert_eq!(opened_b["method"], "textDocument/didOpen");
let hover_request_b = read_framed_message(&mut wire_b).await;
assert_eq!(hover_request_b["method"], "textDocument/hover");
write_response(
&mut server_b.read_half_stdin,
&hover_request_b["id"],
JsonValue::Null,
)
.await;
let fast_result = timeout(Duration::from_secs(2), fast)
.await
.expect("fast call must not be blocked by the slow in-flight request")
.unwrap();
assert!(fast_result.is_ok());
assert!(
!slow.is_finished(),
"slow call should still be waiting on its (never-sent) response"
);
slow.abort();
}
#[tokio::test]
async fn test_concurrent_ensure_open_same_path_sends_single_did_open() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert("aa".to_string(), "lang_a".to_string());
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([(
ServerId::from("lang_a"),
"lang_a".to_string(),
)]));
translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
let (client, mut server) = fake_lsp_client();
translator.register_client("lang_a".to_string(), client);
let path = dir.path().join("file.aa");
fs::write(&path, "content").unwrap();
let concurrent_calls = 4;
let translator = Arc::new(translator);
let path_str = path.to_string_lossy().to_string();
let handles: Vec<_> = (0..concurrent_calls)
.map(|_| {
let translator = Arc::clone(&translator);
let path_str = path_str.clone();
tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await })
})
.collect();
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
for _ in 0..concurrent_calls {
let request = read_framed_message(&mut wire).await;
assert_eq!(
request["method"], "textDocument/hover",
"no second didOpen must appear ahead of the hover requests"
);
write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await;
}
for handle in handles {
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("handler call should not hang")
.unwrap();
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert("py".to_string(), "python".to_string());
let pyright_id = ServerId::from("pyright");
let pylsp_id = ServerId::from("pylsp");
let configs = vec![
LspServerConfig {
language_id: "python".to_string(),
command: "pyright-langserver".to_string(),
args: vec![],
env: HashMap::new(),
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 30,
heuristics: None,
name: Some("pyright".to_string()),
handles: Some(vec![ToolKind::Hover]),
},
LspServerConfig {
language_id: "python".to_string(),
command: "pylsp".to_string(),
args: vec![],
env: HashMap::new(),
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 30,
heuristics: None,
name: Some("pylsp".to_string()),
handles: Some(vec![ToolKind::Diagnostics]),
},
];
let router = ToolRouter::from_configs(&configs).unwrap();
let mut translator = Translator::new()
.with_extensions(extensions)
.with_router(router);
translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
let (client_pyright, mut server_pyright) = fake_lsp_client();
let (client_pylsp, mut server_pylsp) = fake_lsp_client();
translator.register_client(pyright_id, client_pyright);
translator.register_client(pylsp_id, client_pylsp);
let path = dir.path().join("main.py");
fs::write(&path, "x = 1").unwrap();
let path_str = path.to_string_lossy().to_string();
let translator = Arc::new(translator);
let rename_result = translator
.handle_rename(path_str.clone(), 1, 1, "renamed".to_string())
.await;
assert!(
matches!(
rename_result,
Err(Error::NoServerForTool {
tool: ToolKind::Rename,
..
})
),
"expected NoServerForTool for rename, got {rename_result:?}"
);
let hover = {
let translator = Arc::clone(&translator);
let path_str = path_str.clone();
tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await })
};
let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout);
let opened = read_framed_message(&mut wire_pyright).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let hover_request = read_framed_message(&mut wire_pyright).await;
assert_eq!(hover_request["method"], "textDocument/hover");
write_response(
&mut server_pyright.read_half_stdin,
&hover_request["id"],
JsonValue::Null,
)
.await;
hover
.await
.unwrap()
.expect("hover routed to pyright must succeed");
let diagnostics = {
let translator = Arc::clone(&translator);
let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
tokio::spawn(async move {
translator
.handle_diagnostics(path_str, ¬ification_cache)
.await
})
};
let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout);
let opened = read_framed_message(&mut wire_pylsp).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let diag_request = read_framed_message(&mut wire_pylsp).await;
assert_eq!(diag_request["method"], "textDocument/diagnostic");
diagnostics.abort();
}
#[tokio::test]
async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert("rs".to_string(), "rust".to_string());
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([(
ServerId::from("rust"),
"rust".to_string(),
)]));
translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
let (client, mut server) = fake_lsp_client();
translator.register_client("rust".to_string(), client);
let path = dir.path().join("lib.rs");
fs::write(&path, "fn main() {}").unwrap();
let path_str = path.to_string_lossy().to_string();
let canonical = path.canonicalize().unwrap();
let uri = path_to_uri(&canonical);
let notification_cache = Mutex::new(NotificationCache::new());
{
let mut cache = notification_cache.lock().await;
cache.store_diagnostics(
&uri,
Some(1),
vec![lsp_diag(
0,
4,
lsp_types::DiagnosticSeverity::WARNING,
"unused import: `std::fmt`",
None,
)],
);
}
let translator = Arc::new(translator);
let handle = {
let translator = Arc::clone(&translator);
tokio::spawn(async move {
translator
.handle_diagnostics(path_str, ¬ification_cache)
.await
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let diag_request = read_framed_message(&mut wire).await;
assert_eq!(diag_request["method"], "textDocument/diagnostic");
write_error_response(
&mut server.read_half_stdin,
&diag_request["id"],
-32601,
"method not found",
)
.await;
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("handler call should not hang")
.unwrap();
let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
assert_eq!(diagnostics.diagnostics.len(), 1);
assert_eq!(
diagnostics.diagnostics[0].message,
"unused import: `std::fmt`"
);
}
#[tokio::test]
async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert("rs".to_string(), "rust".to_string());
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([(
ServerId::from("rust"),
"rust".to_string(),
)]));
translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
let (client, mut server) = fake_lsp_client();
translator.register_client("rust".to_string(), client);
let path = dir.path().join("lib.rs");
fs::write(&path, "fn main() {}").unwrap();
let path_str = path.to_string_lossy().to_string();
let notification_cache = Mutex::new(NotificationCache::new());
let translator = Arc::new(translator);
let handle = {
let translator = Arc::clone(&translator);
tokio::spawn(async move {
translator
.handle_diagnostics(path_str, ¬ification_cache)
.await
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let diag_request = read_framed_message(&mut wire).await;
assert_eq!(diag_request["method"], "textDocument/diagnostic");
write_error_response(
&mut server.read_half_stdin,
&diag_request["id"],
-32601,
"method not found",
)
.await;
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("handler call should not hang")
.unwrap();
assert!(
result.is_err(),
"pull error with no cache data must propagate, got {result:?}"
);
}
}