#![allow(
clippy::expect_used,
clippy::unwrap_used,
clippy::uninlined_format_args,
clippy::unnecessary_unwrap
)]
use std::path::Path;
use std::sync::{Arc, Once};
use std::time::{Duration, Instant};
use mcpls_core::bridge::{NotificationCache, Translator};
use mcpls_core::config::{LspServerConfig, ServerId, ToolRouter};
use mcpls_core::lsp::{LspServer, ServerInitConfig};
use tokio::sync::Mutex;
use tokio::time::timeout;
use crate::common::test_utils::{rust_analyzer_available, rust_workspace_path};
static INIT_TRACING: Once = Once::new();
fn init_tracing() {
INIT_TRACING.call_once(|| {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::TRACE.into()),
)
.with_test_writer()
.init();
});
}
async fn setup_rust_analyzer() -> Arc<Mutex<Translator>> {
init_tracing();
let workspace_path = rust_workspace_path();
let lsp_config = LspServerConfig {
language_id: "rust".to_string(),
command: "rust-analyzer".to_string(),
args: vec![],
env: std::collections::HashMap::new(),
file_patterns: vec!["**/*.rs".to_string()],
initialization_options: None,
timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
};
let server_init_config = ServerInitConfig {
server_config: lsp_config,
workspace_roots: vec![workspace_path.clone()],
initialization_options: None,
notification_tx: None,
};
let server = LspServer::spawn(server_init_config)
.await
.expect("Failed to spawn rust-analyzer");
let client = server.client().clone();
let extension_map = std::collections::HashMap::from([("rs".to_string(), "rust".to_string())]);
let mut translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([(
ServerId::from("rust"),
"rust".to_string(),
)]));
translator.set_workspace_roots(vec![workspace_path]);
translator.register_client("rust".to_string(), client);
translator.register_server("rust".to_string(), server);
Arc::new(Mutex::new(translator))
}
async fn wait_for_indexing_ready(
translator: &Arc<Mutex<Translator>>,
workspace: &Path,
timeout: Duration,
) {
let lib_rs = workspace.join("src/lib.rs");
let file_path = lib_rs.to_string_lossy().to_string();
let add_line: u32 = 51;
let add_col: u32 = 8;
let deadline = Instant::now() + timeout;
let required_consecutive: u32 = 3;
let mut consecutive = 0u32;
loop {
if Instant::now() >= deadline {
tracing::warn!("Timed out waiting for rust-analyzer readiness");
return;
}
let hover_result = translator
.lock()
.await
.handle_hover(file_path.clone(), add_line, add_col)
.await;
match hover_result {
Ok(result) => {
let text = serde_json::to_string(&result).unwrap_or_default();
if text.contains("fn add") && text.contains("i32") {
consecutive += 1;
if consecutive >= required_consecutive {
return;
}
} else {
consecutive = 0;
}
}
Err(_) => {
consecutive = 0;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_hover_on_std_vec() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let file_path = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_hover(
file_path.to_string_lossy().to_string(),
20,
19, ),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let hover_result = result.unwrap();
assert!(
hover_result.is_ok(),
"Should successfully get hover: {:?}",
hover_result.err()
);
let hover_json = hover_result.unwrap();
let hover_str = serde_json::to_string(&hover_json).unwrap();
assert!(
hover_str.contains("String") || hover_str.contains("string"),
"Hover should contain String type information, got: {}",
hover_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_hover_on_u64_type() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let file_path = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_hover(
file_path.to_string_lossy().to_string(),
19,
13, ),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let hover_result = result.unwrap();
assert!(hover_result.is_ok(), "Should successfully get hover");
let hover_json = hover_result.unwrap();
let hover_str = serde_json::to_string(&hover_json).unwrap();
assert!(
hover_str.contains("u64") || hover_str.contains("unsigned"),
"Hover should contain u64 type information, got: {}",
hover_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_definition_user_struct() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let types_file = workspace_path.join("src/types.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_definition(
types_file.to_string_lossy().to_string(),
9,
16, ),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let def_result = result.unwrap();
assert!(
def_result.is_ok(),
"Should successfully get definition: {:?}",
def_result.err()
);
let def_json = def_result.unwrap();
let def_str = serde_json::to_string(&def_json).unwrap();
assert!(
def_str.contains("lib.rs") && def_str.contains("User"),
"Definition should reference User struct in lib.rs, got: {}",
def_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_definition_across_files() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let functions_file = workspace_path.join("src/functions.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_definition(
functions_file.to_string_lossy().to_string(),
3,
24, ),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let def_result = result.unwrap();
assert!(def_result.is_ok(), "Should successfully get definition");
let def_json = def_result.unwrap();
let def_str = serde_json::to_string(&def_json).unwrap();
assert!(
def_str.contains("types.rs") || def_str.contains("Repository"),
"Definition should reference Repository in types.rs, got: {}",
def_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_references_create_repo_function() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let functions_file = workspace_path.join("src/functions.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_references(
functions_file.to_string_lossy().to_string(),
7,
12, true, ),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let refs_result = result.unwrap();
assert!(
refs_result.is_ok(),
"Should successfully get references: {:?}",
refs_result.err()
);
let refs_json = refs_result.unwrap();
assert!(
!refs_json.locations.is_empty(),
"Should find at least one reference (the definition)"
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_references_user_struct() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let lib_file = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_references(
lib_file.to_string_lossy().to_string(),
18,
15, true,
),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let refs_result = result.unwrap();
assert!(refs_result.is_ok(), "Should successfully get references");
let refs_json = refs_result.unwrap();
assert!(
refs_json.locations.len() >= 2,
"Should find multiple references to User struct, got: {}",
refs_json.locations.len()
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_diagnostics_with_error() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let lib_file = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let notification_cache = Mutex::new(NotificationCache::new());
let result = timeout(
Duration::from_secs(10),
translator
.lock()
.await
.handle_diagnostics(lib_file.to_string_lossy().to_string(), ¬ification_cache),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let diag_result = result.unwrap();
assert!(
diag_result.is_ok(),
"Should successfully get diagnostics: {:?}",
diag_result.err()
);
let diag_json = diag_result.unwrap();
let diag_str = serde_json::to_string(&diag_json).unwrap();
assert!(
diag_str.contains("undefined_variable") || diag_str.contains("cannot find"),
"Diagnostics should report the intentional error, got: {}",
diag_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_diagnostics_no_errors() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let types_file = workspace_path.join("src/types.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let notification_cache = Mutex::new(NotificationCache::new());
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_diagnostics(
types_file.to_string_lossy().to_string(),
¬ification_cache,
),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let diag_result = result.unwrap();
assert!(diag_result.is_ok(), "Should successfully get diagnostics");
let diag_json = diag_result.unwrap();
let errors: Vec<_> = diag_json
.diagnostics
.iter()
.filter(|d| matches!(d.severity, mcpls_core::bridge::DiagnosticSeverity::Error))
.collect();
assert!(
errors.is_empty(),
"types.rs should have no errors, got: {:?}",
errors
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_document_symbols() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let lib_file = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator
.lock()
.await
.handle_document_symbols(lib_file.to_string_lossy().to_string()),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let symbols_result = result.unwrap();
assert!(
symbols_result.is_ok(),
"Should successfully get symbols: {:?}",
symbols_result.err()
);
let symbols_json = symbols_result.unwrap();
let symbols_str = serde_json::to_string(&symbols_json).unwrap();
assert!(
symbols_str.contains("User"),
"Should find User struct, got: {}",
symbols_str
);
assert!(
symbols_str.contains("has_error"),
"Should find has_error function, got: {}",
symbols_str
);
assert!(
symbols_str.contains("has_warning"),
"Should find has_warning function, got: {}",
symbols_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_document_symbols_types_file() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let types_file = workspace_path.join("src/types.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator
.lock()
.await
.handle_document_symbols(types_file.to_string_lossy().to_string()),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let symbols_result = result.unwrap();
assert!(symbols_result.is_ok(), "Should successfully get symbols");
let symbols_json = symbols_result.unwrap();
let symbols_str = serde_json::to_string(&symbols_json).unwrap();
assert!(
symbols_str.contains("Repository"),
"Should find Repository struct, got: {}",
symbols_str
);
assert!(
symbols_str.contains("new") || symbols_str.contains("get_owner"),
"Should find struct methods, got: {}",
symbols_str
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_completions_basic() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let functions_file = workspace_path.join("src/functions.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_completions(
functions_file.to_string_lossy().to_string(),
23,
11, None,
),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let completions_result = result.unwrap();
if let Ok(completions_json) = completions_result {
let completions_str = serde_json::to_string(&completions_json).unwrap();
assert!(
completions_str.contains("get_owner")
|| completions_str.contains("name")
|| completions_str.contains("stars"),
"Completions should include Repository fields/methods, got: {}",
completions_str
);
}
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_format_document() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let lib_file = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_format_document(
lib_file.to_string_lossy().to_string(),
4, true, ),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let format_result = result.unwrap();
match format_result {
Ok(format_json) => {
println!("Format response: {:?}", format_json);
}
Err(e) => {
println!("Format not supported or failed (expected): {:?}", e);
}
}
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_timeout_handling() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let lib_file = workspace_path.join("src/lib.rs");
let result = timeout(
Duration::from_millis(1), translator
.lock()
.await
.handle_hover(lib_file.to_string_lossy().to_string(), 20, 19),
)
.await;
assert!(result.is_err(), "Should timeout with 1ms timeout");
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_invalid_file_path() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let result = translator
.lock()
.await
.handle_hover("/nonexistent/file.rs".to_string(), 1, 1)
.await;
assert!(result.is_err(), "Should fail for non-existent file");
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_out_of_bounds_position() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
let workspace_path = rust_workspace_path();
let lib_file = workspace_path.join("src/lib.rs");
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_hover(
lib_file.to_string_lossy().to_string(),
99999, 1,
),
)
.await;
assert!(
result.is_ok(),
"Should not timeout even with out-of-bounds position"
);
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_workspace_symbol_search_basic() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator
.lock()
.await
.handle_workspace_symbol("User".to_string(), None, 100),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let symbol_result = result.unwrap();
assert!(
symbol_result.is_ok(),
"Should successfully search symbols: {:?}",
symbol_result.err()
);
let symbols = symbol_result.unwrap();
let symbols_str = serde_json::to_string(&symbols).unwrap();
println!("Workspace symbols for 'User': {}", symbols_str);
assert!(
!symbols.symbols.is_empty(),
"Should find at least one symbol for 'User'"
);
let has_user_struct = symbols.symbols.iter().any(|s| s.name == "User");
assert!(has_user_struct, "Should find User struct in results");
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_workspace_symbol_search_with_kind_filter() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_workspace_symbol(
String::new(), Some("Struct".to_string()),
100,
),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let symbol_result = result.unwrap();
if let Ok(symbols) = symbol_result {
println!("Found {} struct symbols", symbols.symbols.len());
for symbol in &symbols.symbols {
assert_eq!(
symbol.kind, "Struct",
"All filtered results should be Struct kind"
);
}
}
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_workspace_symbol_search_max_results() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator
.lock()
.await
.handle_workspace_symbol(String::new(), None, 5),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let symbol_result = result.unwrap();
if let Ok(symbols) = symbol_result {
println!("Found {} symbols (limited to 5)", symbols.symbols.len());
assert!(
symbols.symbols.len() <= 5,
"Should respect max_results limit of 5"
);
}
}
#[tokio::test]
#[ignore = "Requires rust-analyzer installed"]
async fn test_workspace_symbol_search_function() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return;
}
let translator = setup_rust_analyzer().await;
wait_for_indexing_ready(&translator, &rust_workspace_path(), Duration::from_secs(30)).await;
let result = timeout(
Duration::from_secs(10),
translator.lock().await.handle_workspace_symbol(
"create".to_string(),
Some("Function".to_string()),
100,
),
)
.await;
assert!(result.is_ok(), "Should not timeout");
let symbol_result = result.unwrap();
if let Ok(symbols) = symbol_result {
println!("Found {} function symbols", symbols.symbols.len());
for symbol in &symbols.symbols {
assert_eq!(
symbol.kind, "Function",
"All filtered results should be Function kind"
);
}
if !symbols.symbols.is_empty() {
let has_create = symbols
.symbols
.iter()
.any(|s| s.name.to_lowercase().contains("create"));
println!("Has 'create' in name: {}", has_create);
}
}
}