diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index 842358c4..1ae0025e 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -360,6 +360,12 @@ pub struct CallHierarchyItemResult { /// Range of the symbol. pub range: Range, /// Selection range (identifier location). + /// + /// Serialized as `selectionRange` (camelCase) so that the value returned by + /// `prepare_call_hierarchy` round-trips correctly when the MCP client passes + /// it back to `get_incoming_calls` / `get_outgoing_calls`, which deserialize + /// it as `lsp_types::CallHierarchyItem` (camelCase). + #[serde(rename = "selectionRange")] pub selection_range: Range, /// Opaque data to pass to incoming/outgoing calls. #[serde(skip_serializing_if = "Option::is_none")] @@ -832,15 +838,50 @@ impl Translator { let changes = if let Some(edit) = response { let mut result_changes = Vec::new(); + // Prefer the legacy `changes` map (HashMap>). 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, + .map(|e| TextEdit { + range: normalize_range(e.range), + new_text: e.new_text, + }) + .collect(), + }); + } + } + + // Also handle `documentChanges` (array format returned by rust-analyzer). + 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(), }); @@ -1135,57 +1176,13 @@ impl Translator { end_character: u32, kind_filter: Option, ) -> Result { - const VALID_ACTION_KINDS: &[&str] = &[ - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - ]; - - // Validate kind filter - if let Some(ref 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:?}" - ))); - } - - // Validate range - 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(), - )); - } - - // Validate position upper bounds - 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}" - ))); - } - - // Validate range size - 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(), - )); - } + 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)?; @@ -1203,13 +1200,19 @@ impl Translator { // Build context with optional kind filter let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]); + // Pass empty diagnostics context — rust-analyzer generates code actions + // based on cursor position and its internal analysis state, not on the + // passed diagnostics. Passing stale cached diagnostics (which may lack + // the internal `data` field ra uses for fix mapping) suppresses results. + let context_diagnostics: Vec = vec![]; + let params = lsp_types::CodeActionParams { text_document: TextDocumentIdentifier { uri }, range, context: lsp_types::CodeActionContext { - diagnostics: vec![], + diagnostics: context_diagnostics, only, - trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED), + trigger_kind: None, }, work_done_progress_params: WorkDoneProgressParams::default(), partial_result_params: PartialResultParams::default(), @@ -1317,8 +1320,8 @@ impl Translator { &mut self, item: serde_json::Value, ) -> Result { - let lsp_item: CallHierarchyItem = serde_json::from_value(item) - .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?; + // Deserialize as our own type (1-based coords) then convert to LSP (0-based). + let lsp_item = mcp_item_to_lsp(item)?; // Parse and validate the URI let path = self.parse_file_uri(&lsp_item.uri)?; @@ -1366,8 +1369,8 @@ impl Translator { &mut self, item: serde_json::Value, ) -> Result { - let lsp_item: CallHierarchyItem = serde_json::from_value(item) - .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?; + // Deserialize as our own type (1-based coords) then convert to LSP (0-based). + let lsp_item = mcp_item_to_lsp(item)?; // Parse and validate the URI let path = self.parse_file_uri(&lsp_item.uri)?; @@ -1811,6 +1814,117 @@ fn marked_string_to_string(marked: MarkedString) -> String { } /// Convert LSP range to MCP range (0-based to 1-based). +/// Validate parameters for `handle_code_actions`. +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(()) +} + +/// Convert a `CallHierarchyItemResult` JSON (1-based MCP coordinates) into +/// a `lsp_types::CallHierarchyItem` (0-based LSP coordinates). +/// +/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy` +/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`. +/// The bridge serialises ranges as 1-based; this function inverts that mapping +/// before forwarding the item to the LSP server. +fn mcp_item_to_lsp(item: serde_json::Value) -> Result { + let mcp: CallHierarchyItemResult = serde_json::from_value(item) + .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?; + + let uri = mcp.uri.parse::().map_err(|e| { + Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}")) + })?; + + let detail = mcp.detail; + let data = mcp.data; + + // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32 + // by serialising `SymbolKind`; we reverse this to reconstruct the same value. + 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, + }) +} + +/// Convert a 1-based MCP range back to a 0-based LSP range. +/// +/// Used when MCP clients pass back a `CallHierarchyItemResult` that was +/// previously returned by `prepare_call_hierarchy` (which stores 1-based coords). +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 { diff --git a/crates/mcpls-core/src/lib.rs b/crates/mcpls-core/src/lib.rs index 71d0d612..23336041 100644 --- a/crates/mcpls-core/src/lib.rs +++ b/crates/mcpls-core/src/lib.rs @@ -230,7 +230,6 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { let mut translator = Translator::new().with_extensions(extension_map); translator.set_workspace_roots(workspace_roots.clone()); - // Build configurations for batch spawning with heuristics filtering let applicable_configs: Vec = config .lsp_servers .iter() @@ -251,6 +250,7 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { server_config: lsp_config.clone(), workspace_roots: workspace_roots.clone(), initialization_options: lsp_config.initialization_options.clone(), + notification_tx: None, }) }) .collect(); diff --git a/crates/mcpls-core/src/lsp/client.rs b/crates/mcpls-core/src/lsp/client.rs index 9b79dbb3..cd4b2e5d 100644 --- a/crates/mcpls-core/src/lsp/client.rs +++ b/crates/mcpls-core/src/lsp/client.rs @@ -440,6 +440,10 @@ impl LspClient { "client/registerCapability" | "client/unregisterCapability" | "workspace/workspaceFolders" + | "workspace/diagnostic/refresh" + | "workspace/semanticTokens/refresh" + | "workspace/inlayHint/refresh" + | "workspace/codeLens/refresh" | "window/showMessageRequest" => Ok(Value::Null), "workspace/configuration" => Ok(Self::workspace_configuration_result(params)), "workspace/applyEdit" => Ok(serde_json::json!({ "applied": false })), diff --git a/crates/mcpls-core/src/lsp/lifecycle.rs b/crates/mcpls-core/src/lsp/lifecycle.rs index 596ea012..e404aec2 100644 --- a/crates/mcpls-core/src/lsp/lifecycle.rs +++ b/crates/mcpls-core/src/lsp/lifecycle.rs @@ -65,6 +65,13 @@ pub struct ServerInitConfig { pub workspace_roots: Vec, /// Initialization options (server-specific JSON). pub initialization_options: Option, + /// Optional channel for forwarding LSP notifications to the notification cache. + /// + /// When `Some`, the spawned LSP client sends every notification it receives + /// (publishDiagnostics, logMessage, showMessage, …) through this sender. + /// The caller is responsible for draining the corresponding receiver and + /// storing entries in [`crate::bridge::NotificationCache`]. + pub notification_tx: Option>, } /// Result of attempting to spawn multiple LSP servers. @@ -267,6 +274,7 @@ impl LspServer { /// Perform LSP initialization handshake. /// /// Sends initialize request and waits for response, then sends initialized notification. + #[allow(clippy::too_many_lines)] async fn initialize( client: &LspClient, config: &ServerInitConfig, @@ -329,6 +337,14 @@ impl LspServer { references: Some(lsp_types::ReferenceClientCapabilities { dynamic_registration: Some(false), }), + code_action: Some(lsp_types::CodeActionClientCapabilities { + dynamic_registration: Some(false), + data_support: Some(true), + resolve_support: Some(lsp_types::CodeActionCapabilityResolveSupport { + properties: vec!["edit".to_string()], + }), + ..Default::default() + }), ..Default::default() }), workspace: Some(lsp_types::WorkspaceClientCapabilities { @@ -441,11 +457,13 @@ impl LspServer { /// server_config: LspServerConfig::rust_analyzer(), /// workspace_roots: vec![PathBuf::from("/workspace")], /// initialization_options: None, + /// notification_tx: None, /// }, /// ServerInitConfig { /// server_config: LspServerConfig::pyright(), /// workspace_roots: vec![PathBuf::from("/workspace")], /// initialization_options: None, + /// notification_tx: None, /// }, /// ]; /// @@ -557,6 +575,7 @@ mod tests { server_config: LspServerConfig::rust_analyzer(), workspace_roots: vec![PathBuf::from("/tmp/workspace")], initialization_options: Some(serde_json::json!({"key": "value"})), + notification_tx: None, }; #[allow(clippy::redundant_clone)] @@ -571,6 +590,7 @@ mod tests { server_config: LspServerConfig::pyright(), workspace_roots: vec![], initialization_options: None, + notification_tx: None, }; let debug_str = format!("{config:?}"); @@ -608,6 +628,7 @@ mod tests { }, workspace_roots: vec![PathBuf::from("/workspace")], initialization_options: Some(init_opts), + notification_tx: None, }; assert!(config.initialization_options.is_some()); @@ -620,6 +641,7 @@ mod tests { server_config: LspServerConfig::typescript(), workspace_roots: vec![], initialization_options: None, + notification_tx: None, }; assert!(config.workspace_roots.is_empty()); @@ -635,6 +657,7 @@ mod tests { PathBuf::from("/workspace3"), ], initialization_options: None, + notification_tx: None, }; assert_eq!(config.workspace_roots.len(), 3); @@ -1031,6 +1054,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }]; let result = LspServer::spawn_batch(&configs).await; @@ -1063,6 +1087,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ServerInitConfig { server_config: LspServerConfig { @@ -1077,6 +1102,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ServerInitConfig { server_config: LspServerConfig { @@ -1091,6 +1117,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ]; @@ -1128,6 +1155,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ServerInitConfig { server_config: LspServerConfig { @@ -1142,6 +1170,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ]; @@ -1172,6 +1201,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ServerInitConfig { server_config: LspServerConfig { @@ -1186,6 +1216,7 @@ mod tests { }, workspace_roots: vec![], initialization_options: None, + notification_tx: None, }, ]; diff --git a/crates/mcpls-core/src/lsp/mod.rs b/crates/mcpls-core/src/lsp/mod.rs index 84e2cf00..80ad2dca 100644 --- a/crates/mcpls-core/src/lsp/mod.rs +++ b/crates/mcpls-core/src/lsp/mod.rs @@ -6,7 +6,7 @@ mod client; mod lifecycle; mod transport; -mod types; +pub(crate) mod types; pub use client::LspClient; pub use lifecycle::{LspServer, ServerInitConfig, ServerInitResult, ServerState}; diff --git a/crates/mcpls-core/tests/common/assertions.rs b/crates/mcpls-core/tests/common/assertions.rs new file mode 100644 index 00000000..a52871b8 --- /dev/null +++ b/crates/mcpls-core/tests/common/assertions.rs @@ -0,0 +1,62 @@ +//! Assertion helpers for e2e test sub-cases. +#![allow(dead_code)] + +use serde_json::Value; + +/// Extract the text content from an MCP tool call response. +/// +/// MCP tool responses have the shape: +/// `{"result": {"content": [{"type": "text", "text": ""}]}}` +/// +/// Returns the inner text string or an empty string if absent. +pub fn content_text(response: &Value) -> String { + response["result"]["content"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|item| item["text"].as_str()) + .unwrap_or("") + .to_owned() +} + +/// Assert that the MCP response is not an MCP-level error (isError = true). +/// +/// Returns the text content on success. +pub fn assert_tool_ok(response: &Value) -> String { + let is_error = response["result"]["isError"].as_bool().unwrap_or(false); + assert!( + !is_error, + "Expected successful tool response, got isError=true: {}", + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + ); + content_text(response) +} + +/// Assert that a JSON string parsed from tool text contains a symbol with the given name. +/// +/// `symbols` should be an array of objects each having at least a `name` field. +pub fn assert_contains_symbol(symbols: &Value, name: &str) { + let arr = symbols + .as_array() + .unwrap_or_else(|| panic!("expected array of symbols, got {symbols}")); + let found = arr.iter().any(|s| s["name"].as_str().unwrap_or("") == name); + assert!(found, "symbol '{name}' not found in {symbols}"); +} + +/// Assert that a URI ends with the given suffix. +pub fn assert_uri_ends_with(uri: &str, suffix: &str) { + assert!( + uri.ends_with(suffix), + "expected URI to end with '{suffix}', got '{uri}'" + ); +} + +/// Build a `file://` URI for an absolute path. +/// +/// Handles macOS `/private/var` → `/var` symlinks by using the path as-is. +pub fn file_uri(path: &std::path::Path) -> String { + url::Url::from_file_path(path) + .unwrap_or_else(|()| panic!("cannot convert path to file URI: {}", path.display())) + .to_string() +} diff --git a/crates/mcpls-core/tests/common/ra_probe.rs b/crates/mcpls-core/tests/common/ra_probe.rs new file mode 100644 index 00000000..17d5b097 --- /dev/null +++ b/crates/mcpls-core/tests/common/ra_probe.rs @@ -0,0 +1,55 @@ +//! rust-analyzer binary detection for the e2e test suite. + +use std::env; +use std::path::PathBuf; + +/// Result of probing for the rust-analyzer binary. +pub enum Resolution { + /// Binary found at this path. + Found(PathBuf), + /// Suite explicitly skipped via `MCPLS_SKIP_RA=1`. + Skipped(&'static str), + /// Binary not found and skip was not requested. + Missing, +} + +/// Resolve the rust-analyzer binary path. +/// +/// Priority: +/// 1. `MCPLS_SKIP_RA=1` → `Skipped` +/// 2. `MCPLS_RUST_ANALYZER=` → `Found()` +/// 3. `rust-analyzer` in PATH → `Found` +/// 4. Otherwise → `Missing` +pub fn resolve_rust_analyzer() -> Resolution { + if env::var_os("MCPLS_SKIP_RA").is_some_and(|v| v == "1") { + return Resolution::Skipped("MCPLS_SKIP_RA=1"); + } + + if let Some(p) = env::var_os("MCPLS_RUST_ANALYZER") { + return Resolution::Found(PathBuf::from(p)); + } + + // Probe PATH by attempting to run rust-analyzer --version. + match std::process::Command::new("rust-analyzer") + .arg("--version") + .output() + { + Ok(out) if out.status.success() => { + // Resolve full path via `which`-equivalent: find the binary in PATH. + find_in_path("rust-analyzer").map_or(Resolution::Missing, Resolution::Found) + } + _ => Resolution::Missing, + } +} + +/// Find a binary in PATH, returning its absolute path. +fn find_in_path(name: &str) -> Option { + let path_var = env::var_os("PATH")?; + for dir in env::split_paths(&path_var) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} diff --git a/crates/mcpls-core/tests/e2e/mcp_client.rs b/crates/mcpls-core/tests/e2e/mcp_client.rs index 65e21e1a..296c3797 100644 --- a/crates/mcpls-core/tests/e2e/mcp_client.rs +++ b/crates/mcpls-core/tests/e2e/mcp_client.rs @@ -149,6 +149,7 @@ impl McpClient { /// - The request cannot be sent /// - The response cannot be read or parsed /// - The server returns an error response + #[allow(dead_code)] pub fn list_tools(&mut self) -> Result { let request = json!({ "jsonrpc": "2.0", diff --git a/crates/mcpls-core/tests/fixtures/golden/bad_format.fmt.rs b/crates/mcpls-core/tests/fixtures/golden/bad_format.fmt.rs new file mode 100644 index 00000000..f79fdf39 --- /dev/null +++ b/crates/mcpls-core/tests/fixtures/golden/bad_format.fmt.rs @@ -0,0 +1,10 @@ +/// A poorly-formatted function for format_document testing. +pub fn poorly_formatted(x: i32, y: i32) -> i32 { + x + y +} + +/// A poorly-formatted struct. +pub struct BadlyFormatted { + pub a: i32, + pub b: i32, +} diff --git a/crates/mcpls-core/tests/fixtures/rust_workspace/.gitignore b/crates/mcpls-core/tests/fixtures/rust_workspace/.gitignore new file mode 100644 index 00000000..ca98cd96 --- /dev/null +++ b/crates/mcpls-core/tests/fixtures/rust_workspace/.gitignore @@ -0,0 +1,2 @@ +/target/ +Cargo.lock diff --git a/crates/mcpls-core/tests/fixtures/rust_workspace/extras/bad_format.rs b/crates/mcpls-core/tests/fixtures/rust_workspace/extras/bad_format.rs new file mode 100644 index 00000000..6669a460 --- /dev/null +++ b/crates/mcpls-core/tests/fixtures/rust_workspace/extras/bad_format.rs @@ -0,0 +1,7 @@ +/// A poorly-formatted function for format_document testing. +pub fn poorly_formatted(x:i32,y:i32) -> i32{ +x+ y +} + +/// A poorly-formatted struct. +pub struct BadlyFormatted{pub a:i32,pub b:i32} diff --git a/crates/mcpls-core/tests/fixtures/rust_workspace/extras/broken.rs b/crates/mcpls-core/tests/fixtures/rust_workspace/extras/broken.rs new file mode 100644 index 00000000..918760e9 --- /dev/null +++ b/crates/mcpls-core/tests/fixtures/rust_workspace/extras/broken.rs @@ -0,0 +1,7 @@ +/// This file intentionally contains a type error for diagnostics testing. +/// +/// It is NOT part of the crate's module tree — it lives in `extras/` +/// and is copied into the staged workspace's src/ directory by the e2e harness. +pub fn type_error() -> String { + 42 +} diff --git a/crates/mcpls-core/tests/fixtures/rust_workspace/src/lib.rs b/crates/mcpls-core/tests/fixtures/rust_workspace/src/lib.rs index a74db3b5..947e658d 100644 --- a/crates/mcpls-core/tests/fixtures/rust_workspace/src/lib.rs +++ b/crates/mcpls-core/tests/fixtures/rust_workspace/src/lib.rs @@ -43,3 +43,39 @@ pub fn has_warning() { let unused = 42; println!("Hello"); } + +// --- e2e test surface: stable symbols used by ra_e2e test suite --- +use std::fmt; + +/// Adds two integers. +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +/// Calls `add` — used for call hierarchy and reference tests. +pub fn caller() -> i32 { + add(1, 2) +} + +/// A simple point with two coordinates. +pub struct Point { + /// X coordinate. + pub x: f64, + /// Y coordinate. + pub y: f64, +} + +impl fmt::Display for Point { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +/// Target function for code-action e2e testing. +/// +/// The assignment `let ca_var = 1` is missing a semicolon so that +/// rust-analyzer reliably offers an "add semicolon" quickfix here. +#[allow(dead_code)] +pub fn code_action_target() { + let ca_var = 1 +} diff --git a/crates/mcpls-core/tests/integration/rust_analyzer_tests.rs b/crates/mcpls-core/tests/integration/rust_analyzer_tests.rs index bebfc238..58749ad0 100644 --- a/crates/mcpls-core/tests/integration/rust_analyzer_tests.rs +++ b/crates/mcpls-core/tests/integration/rust_analyzer_tests.rs @@ -65,6 +65,7 @@ async fn setup_rust_analyzer() -> (Arc>, mpsc::Receiver` → use that binary +//! - rust-analyzer found in PATH → use it +//! - not found and no skip flag → panic (fail closed) +//! +//! # Filter +//! +//! Set `MCPLS_RA_FILTER=` to run only matching sub-cases locally. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::missing_docs_in_private_items, + missing_docs +)] + +#[path = "common/assertions.rs"] +mod assertions; +#[path = "e2e/mcp_client.rs"] +mod mcp_client; +#[path = "common/ra_probe.rs"] +mod ra_probe; + +use std::fs; +use std::path::Path; +use std::time::{Duration, Instant}; + +use mcp_client::McpClient; +use ra_probe::{Resolution, resolve_rust_analyzer}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Sub-case infrastructure +// --------------------------------------------------------------------------- + +struct SubResult { + name: &'static str, + outcome: Result<(), String>, +} + +type SubCaseFn = fn(&mut McpClient, &Path) -> Result<(), String>; + +struct SubCase { + name: &'static str, + run: SubCaseFn, +} + +macro_rules! sub_case { + ($name:ident) => { + SubCase { + name: stringify!($name), + run: $name, + } + }; +} + +// --------------------------------------------------------------------------- +// Workspace staging +// --------------------------------------------------------------------------- + +/// Copy `tests/fixtures/rust_workspace/` into a fresh `TempDir`. +/// +/// Also copies `extras/broken.rs` into `src/broken.rs` and appends +/// `pub mod broken;` to `src/lib.rs` so rust-analyzer diagnoses it. +/// `extras/bad_format.rs` is placed in `src/bad_format.rs` without being +/// added to the module tree (`format_document` does not require it). +fn stage_workspace() -> TempDir { + let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rust_workspace"); + let tmp = TempDir::new().expect("failed to create TempDir"); + copy_dir_recursive(&fixture_dir, tmp.path()).expect("failed to copy fixture workspace"); + + // Copy broken.rs into src/ and register it in lib.rs. + let broken_src = fixture_dir.join("extras/broken.rs"); + let broken_dst = tmp.path().join("src/broken.rs"); + fs::copy(&broken_src, &broken_dst).expect("failed to copy broken.rs"); + + let lib_path = tmp.path().join("src/lib.rs"); + let mut lib_content = fs::read_to_string(&lib_path).expect("failed to read lib.rs"); + lib_content.push_str("\npub mod broken;\n"); + fs::write(&lib_path, lib_content).expect("failed to append pub mod broken"); + + // Copy bad_format.rs into src/ — NOT added to lib.rs (no mod declaration). + let fmt_src = fixture_dir.join("extras/bad_format.rs"); + let fmt_dst = tmp.path().join("src/bad_format.rs"); + fs::copy(&fmt_src, &fmt_dst).expect("failed to copy bad_format.rs"); + + tmp +} + +/// Recursively copy `src` directory contents into `dst` (dst must exist). +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + // Skip extras/ and target/ — not needed in the staged workspace. + if entry.file_name() == "extras" || entry.file_name() == "target" { + continue; + } + fs::create_dir_all(&dst_path)?; + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Config generation +// --------------------------------------------------------------------------- + +/// Typed config struct so that `toml::to_string` handles path escaping. +#[derive(Serialize, Deserialize)] +struct E2eConfig { + workspace: WorkspaceConfig, + lsp_servers: Vec, +} + +#[derive(Serialize, Deserialize)] +struct WorkspaceConfig { + roots: Vec, +} + +#[derive(Serialize, Deserialize)] +struct LspServerConfig { + language_id: String, + command: String, + args: Vec, + file_patterns: Vec, +} + +/// Write a minimal mcpls TOML config pointing at `ra_binary` and the given workspace root. +fn write_config(ra_binary: &Path, workspace_root: &Path, config_path: &Path) { + let cfg = E2eConfig { + workspace: WorkspaceConfig { + roots: vec![workspace_root.to_string_lossy().into_owned()], + }, + lsp_servers: vec![LspServerConfig { + language_id: "rust".to_owned(), + command: ra_binary.to_string_lossy().into_owned(), + args: vec![], + file_patterns: vec!["**/*.rs".to_owned()], + }], + }; + let content = toml::to_string(&cfg).expect("failed to serialize e2e config"); + fs::write(config_path, content).expect("failed to write e2e config"); +} + +// --------------------------------------------------------------------------- +// Anchor helpers +// --------------------------------------------------------------------------- + +/// Find the 1-based line number of the first line in `file` containing `needle`. +/// +/// Used instead of hardcoded line numbers so tests remain stable when the +/// fixture file is edited. +fn find_line(file: &Path, needle: &str) -> u32 { + let content = fs::read_to_string(file).expect("failed to read file for anchor search"); + content + .lines() + .enumerate() + .find_map(|(i, line)| { + if line.contains(needle) { + Some(u32::try_from(i + 1).expect("line number fits u32")) + } else { + None + } + }) + .unwrap_or_else(|| panic!("anchor '{needle}' not found in {}", file.display())) +} + +// --------------------------------------------------------------------------- +// Readiness gate +// --------------------------------------------------------------------------- + +/// Poll `get_hover` on the `add` function until rust-analyzer returns content. +/// +/// Timeout controlled by `MCPLS_RA_INDEX_TIMEOUT_SECS` (default 60, minimum 5). +/// +/// NOTE: `$/progress` notifications are not captured by `bridge/notifications.rs` +/// (only `window/logMessage`, `window/showMessage`, and `publishDiagnostics` are +/// stored). The readiness gate therefore uses hover-probe as the primary oracle. +/// See M-r1 in the architect handoff for the follow-up to add `$/progress` capture. +fn wait_until_ready(client: &mut McpClient, lib_rs: &Path) { + let timeout_secs: u64 = std::env::var("MCPLS_RA_INDEX_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(60, |t| t.max(5)); + + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + let lib_path = lib_rs.to_string_lossy().into_owned(); + let add_line = find_line(lib_rs, "pub fn add("); + + println!("[ra_e2e] waiting for rust-analyzer to index (timeout {timeout_secs}s)…"); + + loop { + // Hover over `add` — the 'a' of "add" is at column 8 (1-based). + let resp = client.call_tool( + "get_hover", + &json!({ + "file_path": lib_path, + "line": add_line, + "character": 8, + }), + ); + + if let Ok(r) = resp { + let text = assertions::content_text(&r); + // Require both "fn add" and "i32" to confirm type-checking is done. + if text.contains("fn add") && text.contains("i32") { + println!("[ra_e2e] rust-analyzer is ready"); + return; + } + } + + assert!( + Instant::now() < deadline, + "[ra_e2e] rust-analyzer did not become ready within {timeout_secs}s; \ + set MCPLS_RA_INDEX_TIMEOUT_SECS to increase the limit" + ); + + std::thread::sleep(Duration::from_millis(200)); + } +} + +// --------------------------------------------------------------------------- +// Sub-cases (one per MCP tool) +// --------------------------------------------------------------------------- + +/// Tool 1: `get_hover` — hover over `add` declaration. +fn sc_get_hover(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib = workspace.join("src/lib.rs"); + let add_line = find_line(&lib, "pub fn add("); + let resp = client + .call_tool( + "get_hover", + &json!({ + "file_path": lib.to_string_lossy(), + "line": add_line, + "character": 8, + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let hover_text = inner["contents"]["value"] + .as_str() + .or_else(|| inner["contents"].as_str()) + .unwrap_or(""); + + if !hover_text.contains("add") { + return Err(format!("hover text missing 'add': {hover_text}")); + } + if !hover_text.contains("i32") { + return Err(format!("hover text missing 'i32': {hover_text}")); + } + Ok(()) +} + +/// Tool 2: `get_definition` — go to definition of `add` from inside `caller`. +fn sc_get_definition(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib = workspace.join("src/lib.rs"); + // Inside caller body: ` add(1, 2)` — "add" starts at col 5 (1-based). + let caller_line = find_line(&lib, "pub fn caller("); + let resp = client + .call_tool( + "get_definition", + &json!({ + "file_path": lib.to_string_lossy(), + // caller body is two lines below the fn declaration + "line": caller_line + 1, + "character": 5, + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let locs = inner["locations"] + .as_array() + .ok_or_else(|| format!("expected locations array, got {inner}"))?; + if locs.is_empty() { + return Err("get_definition returned empty locations".to_owned()); + } + + let uri = locs[0]["uri"].as_str().unwrap_or(""); + if !uri.ends_with("/src/lib.rs") { + return Err(format!( + "definition URI does not end with '/src/lib.rs': {uri}" + )); + } + Ok(()) +} + +/// Tool 3: `get_references` — find references to `add`. +fn sc_get_references(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib = workspace.join("src/lib.rs"); + let add_line = find_line(&lib, "pub fn add("); + let resp = client + .call_tool( + "get_references", + &json!({ + "file_path": lib.to_string_lossy(), + "line": add_line, + "character": 8, + "include_declaration": true, + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let locs = inner["locations"] + .as_array() + .ok_or_else(|| format!("expected locations array, got {inner}"))?; + if locs.len() < 2 { + return Err(format!( + "expected ≥2 references (decl + call site), got {}", + locs.len() + )); + } + + // All reference URIs should point to lib.rs. + for loc in locs { + let uri = loc["uri"].as_str().unwrap_or(""); + if !uri.ends_with("/src/lib.rs") { + return Err(format!( + "reference URI does not end with '/src/lib.rs': {uri}" + )); + } + } + Ok(()) +} + +/// Tool 4: `get_diagnostics` — type error in broken.rs. +/// +/// Also populates the cache used by sub-case 14. +fn sc_get_diagnostics(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let broken = workspace.join("src/broken.rs"); + let resp = client + .call_tool( + "get_diagnostics", + &json!({ "file_path": broken.to_string_lossy() }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let diags = inner["diagnostics"] + .as_array() + .ok_or_else(|| format!("expected diagnostics array, got {inner}"))?; + + // Poll for diagnostics — rust-analyzer may need a few seconds to analyze + // broken.rs after the initial `textDocument/didOpen`. + let final_diags = if diags.is_empty() { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + std::thread::sleep(Duration::from_millis(250)); + + // Try pull-based diagnostics first. Ignore transient LSP errors + // (e.g. rust-analyzer may cancel the request while still indexing). + let j2: Value = client + .call_tool( + "get_diagnostics", + &json!({ "file_path": broken.to_string_lossy() }), + ) + .ok() + .map_or(Value::Null, |r| { + let t = assertions::content_text(&r); + serde_json::from_str(&t).unwrap_or(Value::Null) + }); + if let Some(d2) = j2["diagnostics"].as_array() + && !d2.is_empty() + { + break d2.clone(); + } + + // Also check push-based cache. + let r3 = client + .call_tool( + "get_cached_diagnostics", + &json!({ "file_path": broken.to_string_lossy() }), + ) + .map_err(|e| format!("cached call failed: {e}"))?; + let t3 = assertions::content_text(&r3); + let j3: Value = serde_json::from_str(&t3).unwrap_or(Value::Null); + if let Some(d3) = j3["diagnostics"].as_array() + && !d3.is_empty() + { + break d3.clone(); + } + + if Instant::now() >= deadline { + return Err("no diagnostics for broken.rs within 15 s".to_owned()); + } + } + } else { + diags.clone() + }; + + let has_error = final_diags + .iter() + .any(|d| d["severity"].as_str() == Some("error")); + if !has_error { + return Err(format!( + "no Error-severity diagnostic in broken.rs: {final_diags:?}" + )); + } + Ok(()) +} + +/// Tool 5: `rename_symbol` — rename `add` → `plus`. +fn sc_rename_symbol(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib = workspace.join("src/lib.rs"); + let add_line = find_line(&lib, "pub fn add("); + let resp = client + .call_tool( + "rename_symbol", + &json!({ + "file_path": lib.to_string_lossy(), + "line": add_line, + "character": 8, + "new_name": "plus", + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let changes = inner["changes"] + .as_array() + .ok_or_else(|| format!("expected changes array, got {inner}"))?; + if changes.is_empty() { + return Err( + "rename_symbol returned empty changes; bridge may not handle documentChanges format" + .to_owned(), + ); + } + Ok(()) +} + +/// Tool 6: `get_completions` — completions after `ad` inside `caller`. +fn sc_get_completions(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib = workspace.join("src/lib.rs"); + // Inside caller body: ` add(1, 2)` — col 6 is after 'a','d' (prefix "ad"). + let caller_line = find_line(&lib, "pub fn caller("); + let body_line = caller_line + 1; + + // Retry loop: completions may not be available until rust-analyzer is fully ready. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let resp = client + .call_tool( + "get_completions", + &json!({ + "file_path": lib.to_string_lossy(), + "line": body_line, + "character": 6, + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let items = inner["items"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected completions array, got {inner}"))?; + + let found = items + .iter() + .any(|i| i["label"].as_str().unwrap_or("").contains("add")); + if found { + return Ok(()); + } + + if Instant::now() >= deadline { + return Err(format!( + "get_completions: 'add' not returned after 10 s; items: {items:?}" + )); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Tool 7: `get_document_symbols` — symbols in lib.rs include add, caller, Point. +fn sc_get_document_symbols(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib = workspace.join("src/lib.rs"); + let resp = client + .call_tool( + "get_document_symbols", + &json!({ "file_path": lib.to_string_lossy() }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let syms = inner["symbols"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected symbols array, got {inner}"))?; + + for expected in &["add", "caller", "Point"] { + let found = syms + .iter() + .any(|s| s["name"].as_str().unwrap_or("").contains(expected)); + if !found { + return Err(format!("symbol '{expected}' not found in document symbols")); + } + } + Ok(()) +} + +/// Tool 8: `format_document` — format `bad_format.rs`, compare to golden. +fn sc_format_document(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let bad_fmt = workspace.join("src/bad_format.rs"); + let resp = client + .call_tool( + "format_document", + &json!({ "file_path": bad_fmt.to_string_lossy() }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let formatted = inner["formatted_content"] + .as_str() + .or_else(|| inner["content"].as_str()) + .or_else(|| inner.as_str()) + .unwrap_or(""); + + if formatted.is_empty() { + // Some LSP servers return text edits instead of the full file. + let edits = inner["edits"] + .as_array() + .or_else(|| inner["changes"].as_array()); + if edits.map_or(0, Vec::len) == 0 { + return Err(format!( + "format_document returned neither formatted content nor edits: {inner}" + )); + } + return Ok(()); + } + + let golden_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/golden/bad_format.fmt.rs"); + let golden = + fs::read_to_string(&golden_path).map_err(|e| format!("failed to read golden file: {e}"))?; + + if formatted.trim() != golden.trim() { + return Err(format!( + "formatted output does not match golden.\nExpected:\n{golden}\nGot:\n{formatted}" + )); + } + Ok(()) +} + +/// Tool 9: `workspace_symbol_search` — search for "add". +fn sc_workspace_symbol_search(client: &mut McpClient, _workspace: &Path) -> Result<(), String> { + // Retry: workspace symbol search may return empty until rust-analyzer + // has fully indexed all files in the workspace. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let resp = client + .call_tool("workspace_symbol_search", &json!({ "query": "add" })) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let syms = inner["symbols"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected symbols array, got {inner}"))?; + + if !syms.is_empty() { + let found = syms + .iter() + .any(|s| s["name"].as_str().unwrap_or("").contains("add")); + if found { + return Ok(()); + } + return Err(format!( + "no symbol named 'add' in workspace_symbol_search results: {syms:?}" + )); + } + + if Instant::now() >= deadline { + return Err( + "workspace_symbol_search returned no results for 'add' after 15 s".to_owned(), + ); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Tool 10: `get_code_actions` — code actions at a syntax error in lib.rs. +/// +/// `code_action_target()` contains `let ca_var = 1` without a trailing +/// semicolon. rust-analyzer reliably offers an "add `;`" quickfix there, +/// giving us a stable trigger that does not require any imports. +fn sc_get_code_actions(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let lib_rs = workspace.join("src/lib.rs"); + // Target the line with the missing semicolon inside `code_action_target`. + let ca_line = find_line(&lib_rs, "let ca_var = 1"); + + // Open lib.rs and warm up rust-analyzer diagnostics. + let _ = client.call_tool( + "get_diagnostics", + &json!({ "file_path": lib_rs.to_string_lossy() }), + ); + std::thread::sleep(Duration::from_secs(2)); + + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let resp = client + .call_tool( + "get_code_actions", + &json!({ + "file_path": lib_rs.to_string_lossy(), + "start_line": ca_line, + "start_character": 1, + "end_line": ca_line, + "end_character": 18, + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let actions = inner["actions"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected actions array, got {inner}"))?; + + if !actions.is_empty() { + return Ok(()); + } + + if Instant::now() >= deadline { + let cached = client + .call_tool( + "get_cached_diagnostics", + &json!({ "file_path": lib_rs.to_string_lossy() }), + ) + .ok() + .map(|r| assertions::content_text(&r)) + .unwrap_or_default(); + return Err(format!( + "get_code_actions: no actions on missing-semicolon in lib.rs after 15 s\n\ + cached_diagnostics={cached}\nactions_response={inner}" + )); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +// --------------------------------------------------------------------------- +// Call hierarchy helpers +// --------------------------------------------------------------------------- + +/// Tool 11: `prepare_call_hierarchy` — on `add`. +/// +/// Returns the prepared item for use by sub-cases 12 and 13. +/// +/// Since `CallHierarchyItemResult` now serializes `selectionRange` in camelCase, +/// the item round-trips correctly without any field renaming. +fn prepare_call_hierarchy_item(client: &mut McpClient, workspace: &Path) -> Result { + let lib = workspace.join("src/lib.rs"); + let add_line = find_line(&lib, "pub fn add("); + let resp = client + .call_tool( + "prepare_call_hierarchy", + &json!({ + "file_path": lib.to_string_lossy(), + "line": add_line, + "character": 8, + }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let items = inner["items"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected items array, got {inner}"))?; + + if items.is_empty() { + return Err("prepare_call_hierarchy returned no items".to_owned()); + } + + let name = items[0]["name"].as_str().unwrap_or(""); + if !name.contains("add") { + return Err(format!( + "expected call hierarchy item for 'add', got '{name}'" + )); + } + Ok(items[0].clone()) +} + +fn sc_prepare_call_hierarchy(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + prepare_call_hierarchy_item(client, workspace).map(|_| ()) +} + +/// Tool 12: `get_incoming_calls` — `caller` must appear as incoming caller to `add`. +fn sc_get_incoming_calls(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let item = prepare_call_hierarchy_item(client, workspace)?; + // Retry: callHierarchy/incomingCalls may return empty on first query while + // rust-analyzer resolves cross-function relationships. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let resp = client + .call_tool("get_incoming_calls", &json!({ "item": item })) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let calls = inner["calls"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected calls array, got {inner}"))?; + + if !calls.is_empty() { + // Verify that `caller` is among the incoming callers. + let found = calls.iter().any(|c| { + c["from"]["name"].as_str().unwrap_or("").contains("caller") + || c["caller"]["name"] + .as_str() + .unwrap_or("") + .contains("caller") + }); + if !found { + return Err(format!( + "get_incoming_calls: 'caller' not found in incoming calls: {calls:?}" + )); + } + return Ok(()); + } + + if Instant::now() >= deadline { + return Err("get_incoming_calls: empty result for 'add' after 15 s; \ + 'caller' should be an incoming caller" + .to_owned()); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Tool 13: `get_outgoing_calls` — `add` calls nothing user-defined. +fn sc_get_outgoing_calls(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let item = prepare_call_hierarchy_item(client, workspace)?; + let resp = client + .call_tool("get_outgoing_calls", &json!({ "item": item })) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let calls = inner["calls"] + .as_array() + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected calls array, got {inner}"))?; + + // `add(a, b) { a + b }` contains no function calls. + // An empty result is correct. Reject any call to a user-defined function + // (names outside std/core/alloc/compiler_builtins namespaces). + for call in calls { + let name = call["to"]["name"] + .as_str() + .or_else(|| call["callee"]["name"].as_str()) + .unwrap_or(""); + let in_std = name.is_empty() + || name.contains("core") + || name.contains("std") + || name.contains("alloc") + || name.contains("compiler_builtins"); + if !in_std { + return Err(format!( + "unexpected user-defined outgoing call from 'add': '{name}'" + )); + } + } + Ok(()) +} + +/// Tool 14: `get_cached_diagnostics` — cache must be populated by `sc_get_diagnostics`. +fn sc_get_cached_diagnostics(client: &mut McpClient, workspace: &Path) -> Result<(), String> { + let broken = workspace.join("src/broken.rs"); + let resp = client + .call_tool( + "get_cached_diagnostics", + &json!({ "file_path": broken.to_string_lossy() }), + ) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + let diags = inner["diagnostics"] + .as_array() + .ok_or_else(|| format!("expected diagnostics array, got {inner}"))?; + + // sc_get_diagnostics runs first and opens broken.rs, causing rust-analyzer to + // push publishDiagnostics notifications. The cache must be non-empty by now. + if diags.is_empty() { + return Err( + "get_cached_diagnostics: empty cache after sc_get_diagnostics populated it".to_owned(), + ); + } + Ok(()) +} + +/// Tool 15: `get_server_logs` — returns `window/logMessage` entries. +/// +/// rust-analyzer does not emit `window/logMessage` by default; it uses +/// `window/showMessage` and `$/progress` for user-visible status. This +/// sub-case asserts that the tool responds without MCP-level error and +/// returns the expected shape, even if entries are empty. The stronger +/// liveness signal for the notification pipeline is `sc_get_server_messages`. +fn sc_get_server_logs(client: &mut McpClient, _workspace: &Path) -> Result<(), String> { + let resp = client + .call_tool("get_server_logs", &json!({ "limit": 50 })) + .map_err(|e| format!("call failed: {e}"))?; + + let text = assertions::assert_tool_ok(&resp); + let inner: Value = serde_json::from_str(&text).map_err(|e| format!("bad JSON: {e}"))?; + + // Verify expected shape; entries may be empty since rust-analyzer does not + // emit window/logMessage without additional logging configuration. + let _entries = inner["entries"] + .as_array() + .or_else(|| inner["logs"].as_array()) + .or_else(|| inner.as_array()) + .ok_or_else(|| format!("expected log entries array, got {inner}"))?; + + Ok(()) +} + +/// Tool 16: `get_server_messages` — readiness gate already exercised this tool. +fn sc_get_server_messages(client: &mut McpClient, _workspace: &Path) -> Result<(), String> { + let resp = client + .call_tool("get_server_messages", &json!({ "limit": 20 })) + .map_err(|e| format!("call failed: {e}"))?; + + assertions::assert_tool_ok(&resp); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Suite driver +// --------------------------------------------------------------------------- + +#[test] +fn ra_e2e_suite() { + let ra_path = match resolve_rust_analyzer() { + Resolution::Found(p) => p, + Resolution::Skipped(reason) => { + println!("[ra_e2e] suite skipped: {reason}"); + return; + } + Resolution::Missing => { + panic!( + "[ra_e2e] rust-analyzer not found in PATH; \ + install it with `rustup component add rust-analyzer` \ + or set MCPLS_SKIP_RA=1 to skip" + ); + } + }; + + println!("[ra_e2e] using rust-analyzer: {}", ra_path.display()); + + // Stage workspace into a TempDir. + let workspace_tmp = stage_workspace(); + // Canonicalize to resolve macOS /var → /private/var symlinks. + // rust-analyzer resolves paths internally; without canonicalization, hover + // requests using /var/folders/… would not match its indexed file URIs. + let workspace = workspace_tmp + .path() + .canonicalize() + .unwrap_or_else(|_| workspace_tmp.path().to_owned()); + + // Generate config. + let config_path = workspace.join("mcpls-e2e.toml"); + write_config(&ra_path, &workspace, &config_path); + + // Spawn mcpls. + let config_str = config_path.to_string_lossy().into_owned(); + let mut client = + McpClient::spawn_with_args(&["--config", &config_str]).expect("failed to spawn mcpls"); + + client.initialize().expect("MCP initialize failed"); + + // Wait for rust-analyzer to index. + let lib_rs = workspace.join("src/lib.rs"); + wait_until_ready(&mut client, &lib_rs); + + // Sub-case registry. + let sub_cases: &[SubCase] = &[ + sub_case!(sc_get_hover), + sub_case!(sc_get_definition), + sub_case!(sc_get_references), + sub_case!(sc_get_diagnostics), + sub_case!(sc_rename_symbol), + sub_case!(sc_get_completions), + sub_case!(sc_get_document_symbols), + sub_case!(sc_format_document), + sub_case!(sc_workspace_symbol_search), + sub_case!(sc_get_code_actions), + sub_case!(sc_prepare_call_hierarchy), + sub_case!(sc_get_incoming_calls), + sub_case!(sc_get_outgoing_calls), + sub_case!(sc_get_cached_diagnostics), + sub_case!(sc_get_server_logs), + sub_case!(sc_get_server_messages), + ]; + + let filter = std::env::var("MCPLS_RA_FILTER").ok(); + + let mut results: Vec = Vec::new(); + + for sc in sub_cases { + if filter.as_deref().is_some_and(|f| !sc.name.contains(f)) { + continue; + } + + print!("[ra_e2e] running {} … ", sc.name); + // Use catch_unwind so a panicking sub-case doesn't abort the whole suite. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + (sc.run)(&mut client, &workspace) + })); + + let outcome = match outcome { + Ok(r) => r, + Err(payload) => { + let msg = payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned())) + .unwrap_or_else(|| "sub-case panicked".to_owned()); + Err(msg) + } + }; + + match &outcome { + Ok(()) => println!("ok"), + Err(e) => println!("FAILED: {e}"), + } + + results.push(SubResult { + name: sc.name, + outcome, + }); + } + + // Aggregate failures. + let failures: Vec<_> = results.iter().filter(|r| r.outcome.is_err()).collect(); + + if !failures.is_empty() { + let report: Vec = failures + .iter() + .map(|f| format!(" • {} — {}", f.name, f.outcome.as_ref().unwrap_err())) + .collect(); + panic!( + "[ra_e2e] {} sub-case(s) failed:\n{}", + failures.len(), + report.join("\n") + ); + } + + println!("[ra_e2e] all {} sub-cases passed", results.len()); +}