diff options
| author | main <main@swarm.moe> | 2026-03-19 15:49:41 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 15:49:41 -0400 |
| commit | fa1bd32800b65aab31ea732dd240261b4047522c (patch) | |
| tree | 2fd08af6f36b8beb3c7c941990becc1a0a091d62 /crates/ra-mcp-engine/src/bin/fake-rust-analyzer.rs | |
| download | adequate-rust-mcp-310b00d40db7639654bdc1d416ae222de481e8fc.zip | |
Release adequate-rust-mcp 1.0.0v1.0.0
Diffstat (limited to 'crates/ra-mcp-engine/src/bin/fake-rust-analyzer.rs')
| -rw-r--r-- | crates/ra-mcp-engine/src/bin/fake-rust-analyzer.rs | 467 |
1 files changed, 467 insertions, 0 deletions
diff --git a/crates/ra-mcp-engine/src/bin/fake-rust-analyzer.rs b/crates/ra-mcp-engine/src/bin/fake-rust-analyzer.rs new file mode 100644 index 0000000..c64b68b --- /dev/null +++ b/crates/ra-mcp-engine/src/bin/fake-rust-analyzer.rs @@ -0,0 +1,467 @@ +//! Fault-injectable fake rust-analyzer used by integration tests. + +use lsp_types as _; +use ra_mcp_domain as _; +use ra_mcp_engine as _; +use serde as _; +use serde_json::{Value, json}; +#[cfg(test)] +use serial_test as _; +use std::{ + fs, + io::{self, BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, + time::Duration, +}; +#[cfg(test)] +use tempfile as _; +use thiserror as _; +use tokio as _; +use tracing as _; +use url as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Stable, + CrashOnFirstHover, +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + run().map_err(|error| Box::new(error) as Box<dyn std::error::Error>) +} + +fn run() -> io::Result<()> { + let mut mode = Mode::Stable; + let mut marker = None::<PathBuf>; + let mut hover_delay = Duration::ZERO; + let mut execute_command_delay = Duration::ZERO; + let mut execute_command_log = None::<PathBuf>; + let mut diagnostic_warmup_count = 0_u8; + let mut diagnostic_cancel_count = 0_u8; + let mut strict_root_match = false; + let mut workspace_root = None::<PathBuf>; + let mut args = std::env::args().skip(1); + loop { + let argument = args.next(); + let Some(argument) = argument else { + break; + }; + match argument.as_str() { + "--mode" => { + if let Some(value) = args.next() { + mode = parse_mode(&value).unwrap_or(Mode::Stable); + } + } + "--crash-marker" => { + if let Some(value) = args.next() { + marker = Some(PathBuf::from(value)); + } + } + "--hover-delay-ms" => { + if let Some(value) = args.next() { + let parsed = value.parse::<u64>().ok(); + if let Some(delay_ms) = parsed { + hover_delay = Duration::from_millis(delay_ms); + } + } + } + "--execute-command-delay-ms" => { + if let Some(value) = args.next() { + let parsed = value.parse::<u64>().ok(); + if let Some(delay_ms) = parsed { + execute_command_delay = Duration::from_millis(delay_ms); + } + } + } + "--execute-command-log" => { + if let Some(value) = args.next() { + execute_command_log = Some(PathBuf::from(value)); + } + } + "--diagnostic-warmup-count" => { + if let Some(value) = args.next() { + let parsed = value.parse::<u8>().ok(); + if let Some(count) = parsed { + diagnostic_warmup_count = count; + } + } + } + "--diagnostic-cancel-count" => { + if let Some(value) = args.next() { + let parsed = value.parse::<u8>().ok(); + if let Some(count) = parsed { + diagnostic_cancel_count = count; + } + } + } + "--strict-root-match" => { + strict_root_match = true; + } + _ => {} + } + } + + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + let mut writer = stdout.lock(); + + loop { + let frame = match read_frame(&mut reader) { + Ok(frame) => frame, + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(error) => return Err(error), + }; + let message: Value = serde_json::from_slice(&frame) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + if let Some(method) = message.get("method").and_then(Value::as_str) { + if method == "initialized" { + continue; + } + + let request_id = message.get("id").cloned(); + let Some(request_id) = request_id else { + continue; + }; + if method == "initialize" { + workspace_root = initialized_workspace_root(&message); + } + + if mode == Mode::CrashOnFirstHover + && method == "textDocument/hover" + && should_crash(&marker)? + { + std::process::exit(0); + } + if method == "textDocument/hover" && !hover_delay.is_zero() { + std::thread::sleep(hover_delay); + } + if method == "workspace/executeCommand" { + if let Some(path) = execute_command_log.as_ref() { + log_execute_command_effect(path, &message)?; + } + if !execute_command_delay.is_zero() { + std::thread::sleep(execute_command_delay); + } + } + + let response = if strict_root_match + && request_targets_outside_workspace(&message, workspace_root.as_deref()) + { + strict_root_mismatch_response(method, request_id, &message) + } else if method == "textDocument/diagnostic" && diagnostic_cancel_count > 0 { + diagnostic_cancel_count = diagnostic_cancel_count.saturating_sub(1); + server_cancelled_response(request_id) + } else if method == "textDocument/diagnostic" && diagnostic_warmup_count > 0 { + diagnostic_warmup_count = diagnostic_warmup_count.saturating_sub(1); + warmup_unlinked_diagnostic_response(request_id) + } else { + make_response(method, request_id, &message) + }; + write_frame(&mut writer, &response)?; + } + } + + Ok(()) +} + +fn parse_mode(raw: &str) -> Option<Mode> { + match raw { + "stable" => Some(Mode::Stable), + "crash_on_first_hover" => Some(Mode::CrashOnFirstHover), + _ => None, + } +} + +fn should_crash(marker: &Option<PathBuf>) -> io::Result<bool> { + let Some(marker) = marker else { + return Ok(true); + }; + if marker.exists() { + return Ok(false); + } + fs::write(marker, b"crashed")?; + Ok(true) +} + +fn log_execute_command_effect(path: &PathBuf, request: &Value) -> io::Result<()> { + let command = request + .get("params") + .and_then(|params| params.get("command")) + .and_then(Value::as_str) + .unwrap_or("<missing-command>"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(file, "{command}")?; + Ok(()) +} + +fn initialized_workspace_root(request: &Value) -> Option<PathBuf> { + let root_uri = request + .get("params") + .and_then(|params| params.get("rootUri")) + .and_then(Value::as_str)?; + let root_url = url::Url::parse(root_uri).ok()?; + root_url.to_file_path().ok() +} + +fn request_targets_outside_workspace(request: &Value, workspace_root: Option<&Path>) -> bool { + let Some(workspace_root) = workspace_root else { + return false; + }; + let file_path = request_document_path(request); + let Some(file_path) = file_path else { + return false; + }; + !file_path.starts_with(workspace_root) +} + +fn request_document_path(request: &Value) -> Option<PathBuf> { + let uri = request + .get("params") + .and_then(|params| params.get("textDocument")) + .and_then(|doc| doc.get("uri")) + .and_then(Value::as_str)?; + let url = url::Url::parse(uri).ok()?; + url.to_file_path().ok() +} + +fn strict_root_mismatch_response(method: &str, request_id: Value, request: &Value) -> Value { + match method { + "textDocument/hover" => json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": Value::Null + }), + "textDocument/definition" => json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": Value::Null + }), + "textDocument/references" => json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": Value::Null + }), + "textDocument/rename" => { + let uri = request + .get("params") + .and_then(|params| params.get("textDocument")) + .and_then(|doc| doc.get("uri")) + .and_then(Value::as_str) + .unwrap_or("file:///tmp/fallback.rs") + .to_owned(); + json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "changes": { + uri: [] + } + } + }) + } + "textDocument/diagnostic" => warmup_unlinked_diagnostic_response(request_id), + _ => make_response(method, request_id, request), + } +} + +fn make_response(method: &str, request_id: Value, request: &Value) -> Value { + match method { + "initialize" => json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "capabilities": {} + } + }), + "textDocument/hover" => json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "contents": { + "kind": "markdown", + "value": "hover::ok" + } + } + }), + "textDocument/definition" => { + let uri = request + .get("params") + .and_then(|params| params.get("textDocument")) + .and_then(|doc| doc.get("uri")) + .cloned() + .unwrap_or(Value::String("file:///tmp/fallback.rs".to_owned())); + json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": [{ + "uri": uri, + "range": { + "start": { "line": 2, "character": 3 }, + "end": { "line": 2, "character": 8 } + } + }] + }) + } + "textDocument/references" => { + let uri = request + .get("params") + .and_then(|params| params.get("textDocument")) + .and_then(|doc| doc.get("uri")) + .cloned() + .unwrap_or(Value::String("file:///tmp/fallback.rs".to_owned())); + json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": [{ + "uri": uri, + "range": { + "start": { "line": 4, "character": 1 }, + "end": { "line": 4, "character": 5 } + } + }] + }) + } + "textDocument/rename" => { + let uri = request + .get("params") + .and_then(|params| params.get("textDocument")) + .and_then(|doc| doc.get("uri")) + .and_then(Value::as_str) + .unwrap_or("file:///tmp/fallback.rs") + .to_owned(); + json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "changes": { + uri: [ + { + "range": { + "start": { "line": 1, "character": 1 }, + "end": { "line": 1, "character": 4 } + }, + "newText": "renamed_symbol" + } + ] + } + } + }) + } + "textDocument/diagnostic" => json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "kind": "full", + "items": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 3 } + }, + "severity": 1, + "message": "fake diagnostic" + }] + } + }), + "workspace/executeCommand" => { + let command = request + .get("params") + .and_then(|params| params.get("command")) + .cloned() + .unwrap_or(Value::Null); + json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "ack": "ok", + "command": command + } + }) + } + _ => json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32601, + "message": format!("method not found: {method}") + } + }), + } +} + +fn warmup_unlinked_diagnostic_response(request_id: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "kind": "full", + "items": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "severity": 2, + "code": "unlinked-file", + "message": "This file is not part of any crate, so rust-analyzer can't offer IDE services." + }] + } + }) +} + +fn server_cancelled_response(request_id: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32802, + "message": "server cancelled request during workspace reload" + } + }) +} + +fn read_frame(reader: &mut BufReader<impl Read>) -> io::Result<Vec<u8>> { + let mut content_length = None::<usize>; + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "EOF while reading headers", + )); + } + if line == "\r\n" || line == "\n" { + break; + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if let Some(raw_length) = trimmed.strip_prefix("Content-Length:") { + let parsed = raw_length.trim().parse::<usize>().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid Content-Length header: {error}"), + ) + })?; + content_length = Some(parsed); + } + } + + let length = content_length.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length header") + })?; + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload)?; + Ok(payload) +} + +fn write_frame(writer: &mut impl Write, payload: &Value) -> io::Result<()> { + let serialized = serde_json::to_vec(payload) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + let header = format!("Content-Length: {}\r\n\r\n", serialized.len()); + writer.write_all(header.as_bytes())?; + writer.write_all(&serialized)?; + writer.flush()?; + Ok(()) +} |