use std::collections::{HashMap, HashSet};
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,
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::time::Duration;
use super::state::{ResourceLimits, detect_language, path_to_uri};
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>,
expected_languages: HashSet<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(),
expected_languages: HashSet::new(),
}
}
pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
self.workspace_roots = roots;
}
pub fn set_expected_languages(&mut self, languages: HashSet<String>) {
self.expected_languages = languages;
}
pub fn clear_expected_languages(&mut self) {
self.expected_languages.clear();
}
#[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, 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, 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: 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;
impl Translator {
pub(crate) 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()
&& 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_else(|| {
if self.expected_languages.contains(&language_id) {
Error::ServerInitializing(language_id)
} else {
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 = diagnostic_request_params(TextDocumentIdentifier { uri });
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(|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(
&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
&& !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_else(|| {
self.expected_languages
.iter()
.next()
.map_or(Error::NoServerConfigured, |lang| {
Error::ServerInitializing(lang.clone())
})
})?;
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> {
validate_code_action_params(
start_line,
start_character,
end_line,
end_character,
kind_filter.as_deref(),
)?;
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 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(
&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 = mcp_item_to_lsp(item)?;
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 = mcp_item_to_lsp(item)?;
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 = path_to_uri(&validated_path).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 })
}
pub async fn handle_signature_help(
&mut self,
file_path: String,
line: u32,
character: u32,
) -> Result<SignatureHelpResult> {
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 = 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(
&mut self,
file_path: String,
line: u32,
character: u32,
) -> Result<LocationsResult> {
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/implementation", params, timeout_duration)
.await?;
Ok(LocationsResult {
locations: goto_response_to_locations(response),
})
}
pub async fn handle_type_definition(
&mut self,
file_path: String,
line: u32,
character: u32,
) -> Result<LocationsResult> {
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/typeDefinition", params, timeout_duration)
.await?;
Ok(LocationsResult {
locations: goto_response_to_locations(response),
})
}
pub async fn handle_inlay_hints(
&mut 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 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_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 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)]
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_get_client_for_file_server_initializing_when_expected() {
let mut translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &translator.extension_map);
let mut expected = HashSet::new();
expected.insert(lang.clone());
translator.set_expected_languages(expected);
let err = translator.get_client_for_file(&path).unwrap_err();
assert!(matches!(err, Error::ServerInitializing(ref l) if *l == lang));
}
#[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).unwrap_err();
assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
}
#[test]
fn test_clear_expected_languages_reverts_to_no_server() {
let mut translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &translator.extension_map);
let mut expected = HashSet::new();
expected.insert(lang);
translator.set_expected_languages(expected);
translator.clear_expected_languages();
let err = translator.get_client_for_file(&path).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 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;
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");
}
}