use std::collections::HashMap;
use std::path::{Path, PathBuf};
use lsp_types::{
CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams,
CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, CompletionParams,
CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
FormattingOptions, GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams,
MarkedString, PartialResultParams, ReferenceContext, ReferenceParams,
RenameParams as LspRenameParams, TextDocumentIdentifier, TextDocumentPositionParams,
WorkDoneProgressParams, WorkspaceEdit, WorkspaceSymbolParams as LspWorkspaceSymbolParams,
};
use serde::{Deserialize, Serialize};
use tokio::time::Duration;
use url::Url;
use super::state::{ResourceLimits, detect_language};
use super::{DocumentTracker, NotificationCache};
use crate::bridge::encoding::mcp_to_lsp_position;
use crate::error::{Error, Result};
use crate::lsp::{LspClient, LspServer};
#[derive(Debug)]
pub struct Translator {
lsp_clients: HashMap<String, LspClient>,
lsp_servers: HashMap<String, LspServer>,
document_tracker: DocumentTracker,
notification_cache: NotificationCache,
workspace_roots: Vec<PathBuf>,
extension_map: HashMap<String, String>,
}
impl Translator {
#[must_use]
pub fn new() -> Self {
Self {
lsp_clients: HashMap::new(),
lsp_servers: HashMap::new(),
document_tracker: DocumentTracker::new(ResourceLimits::default(), HashMap::new()),
notification_cache: NotificationCache::new(),
workspace_roots: vec![],
extension_map: HashMap::new(),
}
}
pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
self.workspace_roots = roots;
}
#[must_use]
pub fn with_extensions(mut self, extension_map: HashMap<String, String>) -> Self {
self.document_tracker =
DocumentTracker::new(ResourceLimits::default(), extension_map.clone());
self.extension_map = extension_map;
self
}
pub fn register_client(&mut self, language_id: String, client: LspClient) {
self.lsp_clients.insert(language_id, client);
}
pub fn register_server(&mut self, language_id: String, server: LspServer) {
self.lsp_servers.insert(language_id, server);
}
#[must_use]
pub const fn document_tracker(&self) -> &DocumentTracker {
&self.document_tracker
}
pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
&mut self.document_tracker
}
#[must_use]
pub const fn notification_cache(&self) -> &NotificationCache {
&self.notification_cache
}
pub const fn notification_cache_mut(&mut self) -> &mut NotificationCache {
&mut self.notification_cache
}
}
impl Default for Translator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Position2D {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, 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, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticSeverity {
Error,
Warning,
Information,
Hint,
}
#[derive(Debug, Clone, 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: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub uri: String,
pub range: Range,
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>,
}
const MAX_POSITION_VALUE: u32 = 1_000_000;
const MAX_RANGE_LINES: u32 = 10_000;
impl Translator {
fn validate_path(&self, path: &Path) -> Result<PathBuf> {
let canonical = path.canonicalize().map_err(|e| Error::FileIo {
path: path.to_path_buf(),
source: e,
})?;
if self.workspace_roots.is_empty() {
return Ok(canonical);
}
for root in &self.workspace_roots {
if let Ok(canonical_root) = root.canonicalize() {
if canonical.starts_with(&canonical_root) {
return Ok(canonical);
}
}
}
Err(Error::PathOutsideWorkspace(path.to_path_buf()))
}
fn get_client_for_file(&self, path: &Path) -> Result<LspClient> {
let language_id = detect_language(path, &self.extension_map);
self.lsp_clients
.get(&language_id)
.cloned()
.ok_or(Error::NoServerForLanguage(language_id))
}
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(
&mut self,
file_path: String,
line: u32,
character: u32,
) -> Result<HoverResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(
&mut self,
file_path: String,
line: u32,
character: u32,
) -> Result<DefinitionResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(
&mut self,
file_path: String,
line: u32,
character: u32,
include_declaration: bool,
) -> Result<ReferencesResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(&mut self, file_path: String) -> Result<DiagnosticsResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.await?;
let params = lsp_types::DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let timeout_duration = Duration::from_secs(30);
let response: lsp_types::DocumentDiagnosticReportResult = client
.request("textDocument/diagnostic", params, timeout_duration)
.await?;
let diagnostics = 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 result = DiagnosticsResult {
diagnostics: diagnostics
.into_iter()
.map(|diag| 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::INFORMATION) => {
DiagnosticSeverity::Information
}
Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
_ => DiagnosticSeverity::Information,
},
message: diag.message,
code: diag.code.map(|c| match c {
lsp_types::NumberOrString::Number(n) => n.to_string(),
lsp_types::NumberOrString::String(s) => s,
}),
})
.collect(),
};
Ok(result)
}
pub async fn handle_rename(
&mut self,
file_path: String,
line: u32,
character: u32,
new_name: String,
) -> Result<RenameResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(|edit| TextEdit {
range: normalize_range(edit.range),
new_text: edit.new_text,
})
.collect(),
});
}
}
result_changes
} else {
vec![]
};
Ok(RenameResult { changes })
}
pub async fn handle_completions(
&mut self,
file_path: String,
line: u32,
character: u32,
trigger: Option<String>,
) -> Result<CompletionsResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(
&mut self,
file_path: String,
) -> Result<DocumentSymbolsResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(
&mut self,
file_path: String,
tab_size: u32,
insert_spaces: bool,
) -> Result<FormatDocumentResult> {
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(
&mut 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 {
if !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 client = self
.lsp_clients
.values()
.next()
.cloned()
.ok_or(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(
&mut self,
file_path: String,
start_line: u32,
start_character: u32,
end_line: u32,
end_character: u32,
kind_filter: Option<String>,
) -> Result<CodeActionsResult> {
const VALID_ACTION_KINDS: &[&str] = &[
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
];
if let Some(ref kind) = kind_filter {
if !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(),
));
}
let path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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 params = lsp_types::CodeActionParams {
text_document: TextDocumentIdentifier { uri },
range,
context: lsp_types::CodeActionContext {
diagnostics: vec![],
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(
&mut 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 path = PathBuf::from(&file_path);
let validated_path = self.validate_path(&path)?;
let client = self.get_client_for_file(&validated_path)?;
let uri = self
.document_tracker
.ensure_open(&validated_path, &client)
.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(
&mut self,
item: serde_json::Value,
) -> Result<IncomingCallsResult> {
let lsp_item: CallHierarchyItem = serde_json::from_value(item)
.map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
let path = self.parse_file_uri(&lsp_item.uri)?;
let client = self.get_client_for_file(&path)?;
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(
&mut self,
item: serde_json::Value,
) -> Result<OutgoingCallsResult> {
let lsp_item: CallHierarchyItem = serde_json::from_value(item)
.map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
let path = self.parse_file_uri(&lsp_item.uri)?;
let client = self.get_client_for_file(&path)?;
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 handle_cached_diagnostics(&mut self, file_path: &str) -> Result<DiagnosticsResult> {
let path = PathBuf::from(file_path);
let validated_path = self.validate_path(&path)?;
let uri = Url::from_file_path(&validated_path)
.map_err(|()| Error::InvalidUri(validated_path.display().to_string()))?
.to_string();
let diagnostics =
self.notification_cache
.get_diagnostics(&uri)
.map_or_else(Vec::new, |diag_info| {
diag_info
.diagnostics
.iter()
.map(|diag| 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::INFORMATION) => {
DiagnosticSeverity::Information
}
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(),
}),
})
.collect()
});
Ok(DiagnosticsResult { diagnostics })
}
pub fn handle_server_logs(
&mut self,
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 = self.notification_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(&mut self, limit: usize) -> Result<ServerMessagesResult> {
let all_messages = self.notification_cache.get_messages();
let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
Ok(ServerMessagesResult { messages })
}
}
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),
}
}
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 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: format!("{:?}", item.kind),
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)]
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!(translator.lsp_clients.len(), 0);
assert_eq!(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!(translator.lsp_servers.len(), 0);
}
#[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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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_cached_diagnostics(test_file.to_str().unwrap());
assert!(result.is_ok());
let diags = result.unwrap();
assert_eq!(diags.diagnostics.len(), 0);
}
#[test]
fn test_handle_server_logs_with_filter() {
use crate::bridge::notifications::LogLevel;
let mut translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Warning, "warning msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Info, "info msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Debug, "debug msg".to_string());
let result = translator.handle_server_logs(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(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(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(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(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 translator = Translator::new();
for i in 0..10 {
translator
.notification_cache_mut()
.store_message(MessageType::Info, format!("message {i}"));
}
let result = translator.handle_server_messages(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(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 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 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,
};
translator
.notification_cache_mut()
.store_diagnostics(&uri, Some(1), vec![diagnostic]);
let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
assert!(result.is_ok());
let diags = result.unwrap();
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 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 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,
},
];
translator
.notification_cache_mut()
.store_diagnostics(&uri, Some(1), diagnostics);
let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
assert!(result.is_ok());
let diags = result.unwrap();
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 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 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,
};
translator
.notification_cache_mut()
.store_diagnostics(&uri, Some(1), vec![diagnostic]);
let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
assert!(result.is_ok());
let diags = result.unwrap();
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 mut translator = Translator::new();
let result = translator.handle_cached_diagnostics("/nonexistent/path/file.rs");
assert!(matches!(result, Err(Error::FileIo { .. })));
}
#[test]
fn test_handle_server_logs_no_filter() {
use crate::bridge::notifications::LogLevel;
let mut translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Warning, "warning msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Info, "info msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Debug, "debug msg".to_string());
let result = translator.handle_server_logs(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Warning, "warning msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Info, "info msg".to_string());
let result = translator.handle_server_logs(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Warning, "warning msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Info, "info msg".to_string());
let result = translator.handle_server_logs(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Info, "info msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Debug, "debug msg".to_string());
let result = translator.handle_server_logs(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Warning, "warning msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Info, "info msg".to_string());
translator
.notification_cache_mut()
.store_log(LogLevel::Debug, "debug msg".to_string());
let result = translator.handle_server_logs(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 translator = Translator::new();
for i in 0..10 {
translator
.notification_cache_mut()
.store_log(LogLevel::Error, format!("error {i}"));
}
let result = translator.handle_server_logs(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_log(LogLevel::Error, "error msg".to_string());
let result = translator.handle_server_logs(10, Some("ERROR".to_string()));
assert!(result.is_ok());
let result = translator.handle_server_logs(10, Some("Error".to_string()));
assert!(result.is_ok());
let result = translator.handle_server_logs(10, Some("eRrOr".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_handle_server_messages_empty() {
let mut translator = Translator::new();
let result = translator.handle_server_messages(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_message(MessageType::Error, "error".to_string());
translator
.notification_cache_mut()
.store_message(MessageType::Warning, "warning".to_string());
translator
.notification_cache_mut()
.store_message(MessageType::Info, "info".to_string());
translator
.notification_cache_mut()
.store_message(MessageType::Log, "log".to_string());
let result = translator.handle_server_messages(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 translator = Translator::new();
translator
.notification_cache_mut()
.store_message(MessageType::Info, "test".to_string());
let result = translator.handle_server_messages(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 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.handle_cached_diagnostics(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);
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);
assert!(result.is_err());
if let Err(Error::NoServerForLanguage(lang)) = result {
assert_eq!(lang, "plaintext");
} else {
panic!("Expected NoServerForLanguage(plaintext) error");
}
}
#[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;
assert!(result.is_err());
if let Err(crate::error::Error::NoServersAvailable(msg)) = result {
assert!(msg.contains("none configured"));
} else {
panic!("Expected NoServersAvailable error for empty server config");
}
}
}